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)) }
|
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
|
/// Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
|
||||||
/// Gerät sie wieder zusammensetzen kann.
|
/// Gerät sie wieder zusammensetzen kann.
|
||||||
static let chunkDelay: TimeInterval = 0.15
|
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.
|
/// Zerlegt ein Paket in schreibbare Stücke.
|
||||||
///
|
///
|
||||||
/// Ohne ausgehandelte MTU nimmt BLE nur 20 Nutzbytes je Schreibvorgang an.
|
/// Ohne ausgehandelte MTU nimmt BLE nur 20 Nutzbytes je Schreibvorgang an.
|
||||||
@@ -143,16 +157,24 @@ struct AlpicoolState: Equatable {
|
|||||||
/// Übersteuerung aus den Geräteeinstellungen.
|
/// Übersteuerung aus den Geräteeinstellungen.
|
||||||
var zoneMode: FridgeZoneMode = .automatic
|
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] = []
|
var rightZoneBytes: [UInt8] = []
|
||||||
|
|
||||||
/// Ob die Box wirklich eine zweite Zone hat.
|
/// Ob die Box wirklich eine zweite Zone hat.
|
||||||
///
|
///
|
||||||
/// Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen
|
/// Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen
|
||||||
/// Datensatz teils mit, füllen den zweiten Block aber mit Nullen. Als
|
/// Datensatz teils mit und füllen den zweiten Block auf. Zwei Anzeichen
|
||||||
/// zweizonig gilt deshalb nur, wo dieser Block überhaupt etwas enthält.
|
/// 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 {
|
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 }
|
return rightZoneBytes.contains { $0 != 0x00 } && rightZoneBytes.contains { $0 != 0xFF }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +224,7 @@ struct AlpicoolState: Equatable {
|
|||||||
rightTarget = AlpicoolProtocol.signed(p[18])
|
rightTarget = AlpicoolProtocol.signed(p[18])
|
||||||
rightCurrent = AlpicoolProtocol.signed(p[26])
|
rightCurrent = AlpicoolProtocol.signed(p[26])
|
||||||
runningStatus = Int(p[27])
|
runningStatus = Int(p[27])
|
||||||
rightZoneBytes = Array(p[18...26])
|
rightZoneBytes = Array(p[18...25])
|
||||||
} else {
|
} else {
|
||||||
rightTarget = nil
|
rightTarget = nil
|
||||||
rightCurrent = 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
|
// MARK: - Stellbefehle
|
||||||
|
|
||||||
static func setTarget(zone: Zone, to value: Int) -> Data {
|
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
|
// Der zweite Block gehört nur an den Befehl, wenn die Box wirklich
|
||||||
// zwei Zonen hat. Einzonen-Boxen senden den langen Datensatz teils
|
// zwei Zonen hat. Eine Einzonen-Box sendet den langen Datensatz teils
|
||||||
// mit, nehmen den langen Befehl aber nicht an.
|
// 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 {
|
if isDualZone, p.count >= 28 {
|
||||||
data += [
|
data += [
|
||||||
p[18], // Sollwert rechts
|
p[18], // Sollwert rechts
|
||||||
|
|||||||
@@ -91,11 +91,24 @@ final class BMSSession: NSObject {
|
|||||||
}
|
}
|
||||||
/// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde.
|
/// Ob die Kühlbox in dieser Sitzung schon angemeldet wurde.
|
||||||
private var didBind = false
|
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 buffer: [UInt8] = []
|
||||||
private var pollTimer: DispatchSourceTimer?
|
private var pollTimer: DispatchSourceTimer?
|
||||||
private var lastResponse: Data?
|
private var lastResponse: Data?
|
||||||
private var lastCommand: Data?
|
private var lastCommand: Data?
|
||||||
private var lastCommandAt: Date?
|
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 receivedByteCount = 0
|
||||||
private var sentFrameCount = 0
|
private var sentFrameCount = 0
|
||||||
private var gattSummary: [String] = []
|
private var gattSummary: [String] = []
|
||||||
@@ -108,6 +121,17 @@ final class BMSSession: NSObject {
|
|||||||
/// Ob auf diesem Kandidaten schon freigeschaltet wurde.
|
/// Ob auf diesem Kandidaten schon freigeschaltet wurde.
|
||||||
private var didUnlock = false
|
private var didUnlock = false
|
||||||
private var lastSendAt: Date?
|
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.
|
/// Abstand zwischen zwei Abfragerunden im Normalbetrieb.
|
||||||
var pollInterval: TimeInterval = 5
|
var pollInterval: TimeInterval = 5
|
||||||
@@ -208,10 +232,17 @@ final class BMSSession: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bekannte Paare nach vorn, der Rest in Fundreihenfolge.
|
// Bekannte Paare nach vorn, der Rest in Fundreihenfolge. Die
|
||||||
endpoints = candidates.sorted { lhs, rhs in
|
// Fundreihenfolge muss dabei erhalten bleiben: `sorted` allein
|
||||||
lhs.isKnownPair && !rhs.isKnownPair
|
// 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
|
endpointIndex = 0
|
||||||
|
|
||||||
guard !endpoints.isEmpty else {
|
guard !endpoints.isEmpty else {
|
||||||
@@ -228,6 +259,8 @@ final class BMSSession: NSObject {
|
|||||||
isNotifyActive = false
|
isNotifyActive = false
|
||||||
didUnlock = false
|
didUnlock = false
|
||||||
didBind = false
|
didBind = false
|
||||||
|
bindAcknowledged = false
|
||||||
|
didRebindForControl = false
|
||||||
buffer.removeAll()
|
buffer.removeAll()
|
||||||
activationToken += 1
|
activationToken += 1
|
||||||
let token = activationToken
|
let token = activationToken
|
||||||
@@ -291,6 +324,7 @@ final class BMSSession: NSObject {
|
|||||||
|
|
||||||
private func beginPolling() {
|
private func beginPolling() {
|
||||||
pollTimer?.cancel()
|
pollTimer?.cancel()
|
||||||
|
flushWaitingControls()
|
||||||
poll()
|
poll()
|
||||||
let interval = dialect == .unknown ? searchInterval : pollInterval
|
let interval = dialect == .unknown ? searchInterval : pollInterval
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
@@ -387,23 +421,14 @@ final class BMSSession: NSObject {
|
|||||||
sentFrameCount += 1
|
sentFrameCount += 1
|
||||||
lastSendAt = Date()
|
lastSendAt = Date()
|
||||||
|
|
||||||
// Was in einen Schreibvorgang passt, hängt an der ausgehandelten MTU.
|
let pieces = AlpicoolProtocol.chunks(data, limit: writeLimit(for: endpoint))
|
||||||
// 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)
|
|
||||||
|
|
||||||
for (index, piece) in pieces.enumerated() {
|
for (index, piece) in pieces.enumerated() {
|
||||||
guard index > 0 else {
|
guard index > 0 else { enqueue(piece); continue }
|
||||||
peripheral.writeValue(piece, for: endpoint.write, type: endpoint.writeType)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
queue.asyncAfter(
|
queue.asyncAfter(
|
||||||
deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay
|
deadline: .now() + Double(index) * AlpicoolProtocol.chunkDelay
|
||||||
) { [weak self] in
|
) { [weak self] in
|
||||||
guard let self, self.peripheral.state == .connected,
|
self?.enqueue(piece)
|
||||||
let current = self.currentEndpoint else { return }
|
|
||||||
self.peripheral.writeValue(piece, for: current.write, type: current.writeType)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +437,40 @@ final class BMSSession: NSObject {
|
|||||||
publishDiagnostics()
|
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
|
// MARK: - Steuern
|
||||||
|
|
||||||
/// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit
|
/// Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit
|
||||||
@@ -419,75 +478,177 @@ final class BMSSession: NSObject {
|
|||||||
func sendControl(_ packet: Data) {
|
func sendControl(_ packet: Data) {
|
||||||
lastCommand = packet
|
lastCommand = packet
|
||||||
lastCommandAt = Date()
|
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)
|
send(packet)
|
||||||
|
|
||||||
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
|
// Genug Abstand, damit ein aufgeteiltes Paket vollständig draußen ist.
|
||||||
queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||||
guard let self, self.dialect == .alpicool else { return }
|
guard let self, self.dialect == .alpicool else { return }
|
||||||
self.send(AlpicoolProtocol.packet(.query))
|
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
|
// MARK: - Auswertung
|
||||||
|
|
||||||
private func consume(_ data: Data) {
|
private func consume(_ data: Data) {
|
||||||
lastResponse = data
|
lastResponse = data
|
||||||
receivedByteCount += data.count
|
receivedByteCount += data.count
|
||||||
buffer.append(contentsOf: [UInt8](data))
|
buffer.append(contentsOf: [UInt8](data))
|
||||||
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
||||||
|
|
||||||
let (fridgeFrames, fridgeRemainder) = AlpicoolProtocol.extractFrames(from: buffer)
|
// Steht der Dialekt fest, nur noch diesen prüfen. Bei jeder Antwort
|
||||||
if !fridgeFrames.isEmpty {
|
// alle fünf Parser durchzugehen belastet die Funk-Queue ohne Nutzen.
|
||||||
buffer = fridgeRemainder
|
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)
|
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
|
alpicoolState.zoneMode = fridgeZoneMode
|
||||||
onFridgeState?(alpicoolState)
|
onFridgeState?(alpicoolState)
|
||||||
publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil),
|
publish(alpicoolState.snapshot(deviceID: deviceID, rssi: nil),
|
||||||
usable: alpicoolState.hasStatus)
|
usable: alpicoolState.hasStatus)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let (wattFrames, wattRemainder) = WattCycleProtocol.extractFrames(from: buffer)
|
@discardableResult
|
||||||
if !wattFrames.isEmpty {
|
private func consumeWattCycle() -> Bool {
|
||||||
buffer = wattRemainder
|
let (frames, remainder) = WattCycleProtocol.extractFrames(from: buffer)
|
||||||
|
guard !frames.isEmpty else { return false }
|
||||||
|
buffer = remainder
|
||||||
adopt(.wattCycle)
|
adopt(.wattCycle)
|
||||||
for frame in wattFrames { wattCycleState.apply(frame) }
|
for frame in frames { wattCycleState.apply(frame) }
|
||||||
publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil),
|
publish(wattCycleState.snapshot(deviceID: deviceID, rssi: nil),
|
||||||
usable: wattCycleState.hasUsableData)
|
usable: wattCycleState.hasUsableData)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// JBD als Nächstes: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig.
|
/// JBD: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig.
|
||||||
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
|
@discardableResult
|
||||||
if !jbdFrames.isEmpty {
|
private func consumeJBD() -> Bool {
|
||||||
buffer = jbdRemainder
|
let (frames, remainder) = JBDProtocol.extractFrames(from: buffer)
|
||||||
|
guard !frames.isEmpty else { return false }
|
||||||
|
buffer = remainder
|
||||||
adopt(.jbd)
|
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)
|
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if let start = buffer.firstIndex(where: { $0 == 0xD2 }),
|
@discardableResult
|
||||||
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) {
|
private func consumeDalyModbus() -> Bool {
|
||||||
|
guard let start = buffer.firstIndex(where: { $0 == 0xD2 }),
|
||||||
|
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...]))
|
||||||
|
else { return false }
|
||||||
buffer.removeAll()
|
buffer.removeAll()
|
||||||
adopt(.dalyModbus)
|
adopt(.dalyModbus)
|
||||||
dalyState.apply(registers: registers)
|
dalyState.apply(registers: registers)
|
||||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer)
|
@discardableResult
|
||||||
if !dalyFrames.isEmpty {
|
private func consumeDalyClassic() -> Bool {
|
||||||
buffer = dalyRemainder
|
let (frames, remainder) = DalyProtocol.extractA5Frames(from: buffer)
|
||||||
|
guard !frames.isEmpty else { return false }
|
||||||
|
buffer = remainder
|
||||||
adopt(.dalyClassic)
|
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)
|
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||||
return
|
return true
|
||||||
}
|
|
||||||
|
|
||||||
// Etwas kam an, ließ sich aber nicht zuordnen: für die Diagnose sichtbar
|
|
||||||
// machen, damit sich das Protokoll nachträglich bestimmen lässt.
|
|
||||||
publishDiagnostics()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt
|
/// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt
|
||||||
@@ -514,6 +675,11 @@ final class BMSSession: NSObject {
|
|||||||
serviceUUID: currentEndpoint?.write.service?.uuid.uuidString,
|
serviceUUID: currentEndpoint?.write.service?.uuid.uuidString,
|
||||||
isConnected: peripheral.state == .connected,
|
isConnected: peripheral.state == .connected,
|
||||||
isNotifyActive: isNotifyActive,
|
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,
|
gattSummary: gattSummary,
|
||||||
sentFrames: sentFrameCount,
|
sentFrames: sentFrameCount,
|
||||||
receivedBytes: receivedByteCount,
|
receivedBytes: receivedByteCount,
|
||||||
@@ -596,4 +762,22 @@ extension BMSSession: CBPeripheralDelegate {
|
|||||||
guard error == nil, let value = characteristic.value, !value.isEmpty else { return }
|
guard error == nil, let value = characteristic.value, !value.isEmpty else { return }
|
||||||
consume(value)
|
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
|
var isConnected: Bool
|
||||||
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
|
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
|
||||||
var isNotifyActive: Bool
|
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.
|
/// Vollständiger Dienst-/Merkmalsbaum des Geräts.
|
||||||
var gattSummary: [String]
|
var gattSummary: [String]
|
||||||
var sentFrames: Int
|
var sentFrames: Int
|
||||||
@@ -162,6 +173,7 @@ final class BluetoothManager: NSObject {
|
|||||||
let transport: DeviceTransport
|
let transport: DeviceTransport
|
||||||
let role: DeviceRole
|
let role: DeviceRole
|
||||||
let fridgeZoneMode: FridgeZoneMode
|
let fridgeZoneMode: FridgeZoneMode
|
||||||
|
let sensorOrientation: SensorOrientation
|
||||||
let victronKey: [UInt8]?
|
let victronKey: [UInt8]?
|
||||||
}
|
}
|
||||||
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
|
private var managed: [UUID: ManagedDevice] = [:] // Schlüssel: peripheralID
|
||||||
@@ -170,6 +182,35 @@ final class BluetoothManager: NSObject {
|
|||||||
private var levelSessions: [UUID: LevelSession] = [:]
|
private var levelSessions: [UUID: LevelSession] = [:]
|
||||||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||||||
private var reconnectTimer: DispatchSourceTimer?
|
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 discoveryFlushTimer: DispatchSourceTimer?
|
||||||
private var discovering = false
|
private var discovering = false
|
||||||
|
|
||||||
@@ -245,6 +286,7 @@ final class BluetoothManager: NSObject {
|
|||||||
transport: device.role.transport,
|
transport: device.role.transport,
|
||||||
role: device.role,
|
role: device.role,
|
||||||
fridgeZoneMode: device.fridgeZoneMode,
|
fridgeZoneMode: device.fridgeZoneMode,
|
||||||
|
sensorOrientation: device.sensorOrientation,
|
||||||
victronKey: store.victronKeyBytes(for: device.id))
|
victronKey: store.victronKeyBytes(for: device.id))
|
||||||
}
|
}
|
||||||
let known = Set(devices.map(\.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) {
|
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
|
||||||
queue.async {
|
sendFridgeSettings(for: deviceID) { _ in AlpicoolState.setTarget(zone: zone, to: celsius) }
|
||||||
self.session(for: deviceID)?
|
|
||||||
.sendControl(AlpicoolState.setTarget(zone: zone, to: celsius))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
||||||
@@ -300,12 +339,53 @@ final class BluetoothManager: NSObject {
|
|||||||
|
|
||||||
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
/// Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut –
|
||||||
/// und zwar auf der Funk-Queue, wo dieser Zustand lebt.
|
/// 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,
|
private func sendFridgeSettings(for deviceID: UUID,
|
||||||
build: @escaping (AlpicoolState) -> Data?) {
|
build: @escaping (AlpicoolState) -> Data?) {
|
||||||
queue.async {
|
queue.async {
|
||||||
guard let session = self.session(for: deviceID),
|
guard let device = self.managed.values.first(where: { $0.id == deviceID })
|
||||||
let packet = build(session.alpicoolState) else { return }
|
else { return }
|
||||||
session.sendControl(packet)
|
|
||||||
|
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
|
levelSessions[peripheralID] = nil
|
||||||
disconnect(peripheralID)
|
disconnect(peripheralID)
|
||||||
}
|
}
|
||||||
// Zoneneinstellung an bestehende Sitzungen weiterreichen.
|
// Einstellungen an bestehende Sitzungen weiterreichen.
|
||||||
for device in devices {
|
for device in devices {
|
||||||
bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
|
bmsSessions[device.peripheralID]?.fridgeZoneMode = device.fridgeZoneMode
|
||||||
|
levelSessions[device.peripheralID]?.orientation = device.sensorOrientation
|
||||||
}
|
}
|
||||||
lastHandledAdvertisement.removeAll()
|
lastHandledAdvertisement.removeAll()
|
||||||
lastDiagnosticsUpdate.removeAll()
|
lastDiagnosticsUpdate.removeAll()
|
||||||
|
nextConnectAttempt.removeAll()
|
||||||
|
connectFailures.removeAll()
|
||||||
|
connectedSince.removeAll()
|
||||||
|
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
@@ -391,6 +475,8 @@ final class BluetoothManager: NSObject {
|
|||||||
bmsSessions.removeAll()
|
bmsSessions.removeAll()
|
||||||
levelSessions.removeAll()
|
levelSessions.removeAll()
|
||||||
connectedPeripherals.removeAll()
|
connectedPeripherals.removeAll()
|
||||||
|
connectedSince.removeAll()
|
||||||
|
pendingControls.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restartScan() {
|
private func restartScan() {
|
||||||
@@ -416,6 +502,11 @@ final class BluetoothManager: NSObject {
|
|||||||
existing.state == .connected || existing.state == .connecting {
|
existing.state == .connected || existing.state == .connecting {
|
||||||
return
|
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?
|
guard let peripheral = central?
|
||||||
.retrievePeripherals(withIdentifiers: [device.peripheralID]).first else {
|
.retrievePeripherals(withIdentifiers: [device.peripheralID]).first else {
|
||||||
publish { self.linkStates[device.id] = .searching }
|
publish { self.linkStates[device.id] = .searching }
|
||||||
@@ -426,6 +517,21 @@ final class BluetoothManager: NSObject {
|
|||||||
central?.connect(peripheral, options: nil)
|
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
|
/// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig
|
||||||
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
|
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
|
||||||
private func scheduleReconnects() {
|
private func scheduleReconnects() {
|
||||||
@@ -623,6 +729,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
guard let device = managed[peripheral.identifier] else { return }
|
guard let device = managed[peripheral.identifier] else { return }
|
||||||
connectedPeripherals[peripheral.identifier] = peripheral
|
connectedPeripherals[peripheral.identifier] = peripheral
|
||||||
|
connectedSince[peripheral.identifier] = Date()
|
||||||
|
|
||||||
if device.role == .leveling {
|
if device.role == .leveling {
|
||||||
let session = LevelSession(
|
let session = LevelSession(
|
||||||
@@ -637,6 +744,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
self?.publish { self?.levelStates[device.id] = state }
|
self?.publish { self?.levelStates[device.id] = state }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
session.orientation = device.sensorOrientation
|
||||||
levelSessions[peripheral.identifier] = session
|
levelSessions[peripheral.identifier] = session
|
||||||
session.start()
|
session.start()
|
||||||
return
|
return
|
||||||
@@ -655,11 +763,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
session.onFridgeState = { [weak self] state in
|
session.onFridgeState = { [weak self] state in
|
||||||
|
self?.fridgeStateCache[peripheral.identifier] = state
|
||||||
self?.publish { self?.fridgeStates[device.id] = state }
|
self?.publish { self?.fridgeStates[device.id] = state }
|
||||||
}
|
}
|
||||||
session.fridgeZoneMode = device.fridgeZoneMode
|
session.fridgeZoneMode = device.fridgeZoneMode
|
||||||
bmsSessions[peripheral.identifier] = session
|
bmsSessions[peripheral.identifier] = session
|
||||||
session.start()
|
session.start()
|
||||||
|
flushPendingControls(peripheral.identifier, to: session)
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager,
|
func centralManager(_ central: CBCentralManager,
|
||||||
@@ -670,6 +780,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
publish { self.linkStates[device.id] = .failed(message) }
|
publish { self.linkStates[device.id] = .failed(message) }
|
||||||
}
|
}
|
||||||
connectedPeripherals[peripheral.identifier] = nil
|
connectedPeripherals[peripheral.identifier] = nil
|
||||||
|
backOff(peripheral.identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager,
|
func centralManager(_ central: CBCentralManager,
|
||||||
@@ -681,11 +792,24 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
levelSessions[peripheral.identifier] = nil
|
levelSessions[peripheral.identifier] = nil
|
||||||
connectedPeripherals[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] {
|
if let device = managed[peripheral.identifier] {
|
||||||
publish { self.linkStates[device.id] = .searching }
|
publish { self.linkStates[device.id] = .searching }
|
||||||
// Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das
|
if lifetime >= stableConnection {
|
||||||
// Gerät wieder in Reichweite ist.
|
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||||
connectIfNeeded(device)
|
// 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 calibrateCharacteristic: CBCharacteristic?
|
||||||
|
|
||||||
private var state = LevelState()
|
private var state = LevelState()
|
||||||
|
/// Aus den Geräteeinstellungen; rechnet Sensor- in Fahrzeugachsen um.
|
||||||
|
var orientation = SensorOrientation.identity {
|
||||||
|
didSet { applyOrientation() }
|
||||||
|
}
|
||||||
private var pollTimer: DispatchSourceTimer?
|
private var pollTimer: DispatchSourceTimer?
|
||||||
/// Nur nötig, solange das Gerät die Werte nicht von selbst schickt.
|
/// Nur nötig, solange das Gerät die Werte nicht von selbst schickt.
|
||||||
private var needsPolling = false
|
private var needsPolling = false
|
||||||
@@ -136,6 +140,13 @@ final class LevelSession: NSObject {
|
|||||||
pollTimer = timer
|
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() {
|
private func publish() {
|
||||||
guard state.hasReading else { return }
|
guard state.hasReading else { return }
|
||||||
onStateChange(.live)
|
onStateChange(.live)
|
||||||
@@ -212,9 +223,11 @@ extension LevelSession: CBPeripheralDelegate {
|
|||||||
|
|
||||||
switch characteristic.uuid {
|
switch characteristic.uuid {
|
||||||
case Self.pitchUUID:
|
case Self.pitchUUID:
|
||||||
state.pitch = VanAlignProtocol.angle(from: value)
|
state.rawPitch = VanAlignProtocol.angle(from: value)
|
||||||
|
applyOrientation()
|
||||||
case Self.rollUUID:
|
case Self.rollUUID:
|
||||||
state.roll = VanAlignProtocol.angle(from: value)
|
state.rawRoll = VanAlignProtocol.angle(from: value)
|
||||||
|
applyOrientation()
|
||||||
case Self.offsetsUUID:
|
case Self.offsetsUUID:
|
||||||
if let offsets = VanAlignProtocol.offsets(from: value) {
|
if let offsets = VanAlignProtocol.offsets(from: value) {
|
||||||
state.pitchOffset = offsets.pitch
|
state.pitchOffset = offsets.pitch
|
||||||
|
|||||||
@@ -53,9 +53,13 @@ enum VanAlignProtocol {
|
|||||||
/// Zustand des Neigungsmessers.
|
/// Zustand des Neigungsmessers.
|
||||||
struct LevelState: Equatable {
|
struct LevelState: Equatable {
|
||||||
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
/// Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
|
||||||
|
/// Bereits auf die Einbaulage des Sensors umgerechnet.
|
||||||
var pitch: Double?
|
var pitch: Double?
|
||||||
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
/// Querneigung: positiv bedeutet, die rechte Seite steht höher.
|
||||||
var roll: Double?
|
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 pitchOffset: Double?
|
||||||
var rollOffset: Double?
|
var rollOffset: Double?
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
|||||||
var advertisedName: String?
|
var advertisedName: String?
|
||||||
/// Nur für Kühlboxen.
|
/// Nur für Kühlboxen.
|
||||||
var fridgeZoneMode: FridgeZoneMode = .automatic
|
var fridgeZoneMode: FridgeZoneMode = .automatic
|
||||||
|
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
|
||||||
|
var sensorOrientation = SensorOrientation.identity
|
||||||
|
|
||||||
init(id: UUID = UUID(),
|
init(id: UUID = UUID(),
|
||||||
name: String,
|
name: String,
|
||||||
@@ -112,5 +114,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
|
|||||||
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
|
advertisedName = try container.decodeIfPresent(String.self, forKey: .advertisedName)
|
||||||
fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
|
fridgeZoneMode = try container.decodeIfPresent(FridgeZoneMode.self, forKey: .fridgeZoneMode)
|
||||||
?? .automatic
|
?? .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 assistant = AlignmentAssistant()
|
||||||
@State private var didAnnounceTarget = false
|
@State private var didAnnounceTarget = false
|
||||||
|
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||||
|
|
||||||
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
private var state: LevelState { bluetooth.levelStates[device.id] ?? LevelState() }
|
||||||
|
|
||||||
@@ -21,9 +22,21 @@ struct AlignmentAssistantView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
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)
|
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||||
.frame(maxWidth: 320)
|
.frame(maxWidth: 320)
|
||||||
.padding(.top, 8)
|
case .vehicle:
|
||||||
|
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||||
|
}
|
||||||
|
|
||||||
adviceBanner
|
adviceBanner
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
@State private var isAddingDevice = false
|
@State private var isAddingDevice = false
|
||||||
@State private var isManagingProfiles = false
|
@State private var isManagingProfiles = false
|
||||||
|
@State private var isShowingSettings = false
|
||||||
|
|
||||||
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
|
private let columns = [GridItem(.adaptive(minimum: 300), spacing: 16)]
|
||||||
|
|
||||||
@@ -55,6 +56,9 @@ struct DashboardView: View {
|
|||||||
.sheet(isPresented: $isManagingProfiles) {
|
.sheet(isPresented: $isManagingProfiles) {
|
||||||
ProfilesView()
|
ProfilesView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isShowingSettings) {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +78,12 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button("Fahrzeuge verwalten…", systemImage: "gearshape") {
|
Button("Fahrzeuge verwalten…", systemImage: "car.2") {
|
||||||
isManagingProfiles = true
|
isManagingProfiles = true
|
||||||
}
|
}
|
||||||
|
Button("Einstellungen…", systemImage: "gearshape") {
|
||||||
|
isShowingSettings = true
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(store.activeProfile?.name ?? "Fahrzeug",
|
Label(store.activeProfile?.name ?? "Fahrzeug",
|
||||||
systemImage: store.activeProfile?.symbol ?? "box.truck")
|
systemImage: store.activeProfile?.symbol ?? "box.truck")
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ struct DeviceDetailView: View {
|
|||||||
@State private var editedName = ""
|
@State private var editedName = ""
|
||||||
@State private var keyInput = ""
|
@State private var keyInput = ""
|
||||||
@State private var showDeleteConfirmation = false
|
@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
|
/// Immer der aktuelle Stand aus dem Speicher – `device` ist die
|
||||||
/// Momentaufnahme beim Öffnen und veraltet nach jeder Änderung.
|
/// Momentaufnahme beim Öffnen und veraltet nach jeder Änderung.
|
||||||
@@ -88,8 +103,8 @@ struct DeviceDetailView: View {
|
|||||||
|
|
||||||
if currentDevice.role.transport == .advertisement {
|
if currentDevice.role.transport == .advertisement {
|
||||||
keySection
|
keySection
|
||||||
diagnosticsSection
|
if showsTechnicalDetails { diagnosticsSection }
|
||||||
} else {
|
} else if showsTechnicalDetails {
|
||||||
bmsDiagnosticsSection
|
bmsDiagnosticsSection
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +273,33 @@ struct DeviceDetailView: View {
|
|||||||
return String(format: "0x%02X", byte)
|
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.
|
/// Welches Protokoll das BMS spricht und was zuletzt ankam.
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var bmsDiagnosticsSection: some View {
|
private var bmsDiagnosticsSection: some View {
|
||||||
@@ -286,6 +328,24 @@ struct DeviceDetailView: View {
|
|||||||
systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle")
|
systemImage: info.isNotifyActive ? "checkmark.circle" : "xmark.circle")
|
||||||
.foregroundStyle(info.isNotifyActive ? .green : .orange)
|
.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",
|
LabeledContent("Gesendet / empfangen",
|
||||||
value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte")
|
value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte")
|
||||||
if let lastSendAt = info.lastSendAt {
|
if let lastSendAt = info.lastSendAt {
|
||||||
@@ -318,6 +378,22 @@ struct DeviceDetailView: View {
|
|||||||
.textSelection(.enabled)
|
.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: {
|
} header: {
|
||||||
Text("Diagnose")
|
Text("Diagnose")
|
||||||
} footer: {
|
} footer: {
|
||||||
|
|||||||
@@ -45,11 +45,24 @@ struct FridgeControls: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Steuerung")
|
Text("Steuerung")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Änderungen gehen direkt an die Box. Der angezeigte Stand kommt "
|
Text(footerText)
|
||||||
+ "aus ihrer Antwort, nicht aus der Eingabe.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
private func targetStepper(zone: AlpicoolState.Zone, title: String, value: Int?) -> some View {
|
||||||
let range = state.targetRange
|
let range = state.targetRange
|
||||||
return Stepper(value: Binding(
|
return Stepper(value: Binding(
|
||||||
|
|||||||
@@ -109,12 +109,26 @@ struct LevelControls: View {
|
|||||||
@Environment(DeviceStore.self) private var store
|
@Environment(DeviceStore.self) private var store
|
||||||
@State private var showResetConfirmation = false
|
@State private var showResetConfirmation = false
|
||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
|
@AppStorage("levelDisplayStyle") private var displayStyle: LevelDisplayStyle = .bubble
|
||||||
|
@State private var showSensorSetup = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
VStack(spacing: 16) {
|
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)
|
LevelBubble(pitch: state.pitch, roll: state.roll)
|
||||||
.frame(maxHeight: 220)
|
.frame(maxHeight: 220)
|
||||||
|
case .vehicle:
|
||||||
|
VehicleTiltView(pitch: state.pitch, roll: state.roll)
|
||||||
|
}
|
||||||
|
|
||||||
if let instruction = state.instruction {
|
if let instruction = state.instruction {
|
||||||
Label(instruction,
|
Label(instruction,
|
||||||
@@ -151,6 +165,24 @@ struct LevelControls: View {
|
|||||||
.disabled(!state.hasReading)
|
.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 {
|
Section {
|
||||||
Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") {
|
Button("Auf aktuelle Lage kalibrieren", systemImage: "scope") {
|
||||||
bluetooth.calibrateLevel(for: device.id)
|
bluetooth.calibrateLevel(for: device.id)
|
||||||
@@ -177,6 +209,9 @@ struct LevelControls: View {
|
|||||||
+ "Ob bereits kalibriert wurde, meldet dieses Gerät nicht zurück.")
|
+ "Ob bereits kalibriert wurde, meldet dieses Gerät nicht zurück.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showSensorSetup) {
|
||||||
|
SensorSetupView(device: device)
|
||||||
|
}
|
||||||
.fullScreenCover(isPresented: $showAssistant) {
|
.fullScreenCover(isPresented: $showAssistant) {
|
||||||
AlignmentAssistantView(device: device, profile: store.activeProfile)
|
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
|
seinen Dienst, wird beim Einrichten also sicher erkannt und die Art
|
||||||
vorbelegt.
|
vorbelegt.
|
||||||
|
|
||||||
Die Detailansicht zeigt eine Libelle: die Blase wandert dorthin, wo das
|
Die Detailansicht bietet zwei Darstellungen, umschaltbar und über Starts
|
||||||
Fahrzeug höher steht, wie bei einer echten Wasserwaage. Grün heißt eben
|
hinweg gemerkt:
|
||||||
(bis 0,5°), orange bis zwei Grad, darüber rot. Dazu steht in Worten, welche
|
|
||||||
Seite höher steht.
|
|
||||||
|
|
||||||
**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,
|
aktuelle Lage kalibrieren*. Ohne das zeigt die Anzeige die Lage des Sensors,
|
||||||
nicht die des Fahrzeugs – je nachdem, wie schief er eingebaut ist.
|
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
|
tieferstehende Seite. Die Maße stehen im Fahrzeugprofil, erreichbar über das
|
||||||
ⓘ in der Fahrzeugliste.
|
ⓘ 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
|
## Mehrere Fahrzeuge
|
||||||
|
|
||||||
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
|
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
|
||||||
@@ -208,6 +239,7 @@ CamperMonitor/
|
|||||||
├── Models/
|
├── Models/
|
||||||
│ ├── Profile.swift Fahrzeug samt Maßen
|
│ ├── Profile.swift Fahrzeug samt Maßen
|
||||||
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
|
│ ├── AlignmentAssistant.swift Verlauf, Tendenz und Keilberechnung
|
||||||
|
│ ├── SensorOrientation.swift Einbaulage und ihre Erkennung
|
||||||
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
|
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
|
||||||
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
|
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
|
||||||
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
||||||
@@ -235,6 +267,9 @@ CamperMonitor/
|
|||||||
├── FridgeControls.swift Bedienelemente der Kühlbox
|
├── FridgeControls.swift Bedienelemente der Kühlbox
|
||||||
├── LevelView.swift Libelle und Kalibrierung
|
├── LevelView.swift Libelle und Kalibrierung
|
||||||
├── AlignmentAssistantView.swift Ausrichtungs-Assistent fürs Rangieren
|
├── 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
|
└── 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
|
// 20 Byte noch in einen Schreibvorgang, aus dem langen nicht mehr. Ohne
|
||||||
// Aufteilen verwirft die Box ihn wortlos – genau das war der Fehler.
|
// Aufteilen verwirft die Box ihn wortlos – genau das war der Fehler.
|
||||||
checkEqual("Stellbefehl aus dem kurzen Datensatz", offBytes.count, 20)
|
checkEqual("Stellbefehl aus dem kurzen Datensatz", offBytes.count, 20)
|
||||||
// Eine als einzonig erkannte Box bekommt auch nur den kurzen Befehl,
|
// Eine Einzonen-Box, die den langen Datensatz nur auffüllt, bekommt den
|
||||||
// obwohl sie den langen Datensatz sendet.
|
// kurzen Befehl – den langen nimmt sie nicht an.
|
||||||
checkEqual("kurzer Stellbefehl bei einzoniger Box",
|
checkEqual("aufgefüllter Datensatz heisst trotzdem kurzer Stellbefehl",
|
||||||
padded.settingsCommand(poweredOn: false)?.count, 20)
|
padded.settingsCommand(poweredOn: false)?.count, 20)
|
||||||
|
|
||||||
padded.zoneMode = .dual
|
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")
|
longPacket.count > 20, "-> \(longPacket.count) Byte")
|
||||||
let pieces = AlpicoolProtocol.chunks(longPacket, limit: 20)
|
let pieces = AlpicoolProtocol.chunks(longPacket, limit: 20)
|
||||||
checkEqual("wird in zwei Stücke geteilt", pieces.count, 2)
|
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("kein Stück überschreitet die Grenze", pieces.allSatisfy { $0.count <= 20 }, true)
|
||||||
checkEqual("zusammengesetzt ergibt sich das Original",
|
checkEqual("zusammengesetzt ergibt sich das Original",
|
||||||
[UInt8](pieces.reduce(Data(), +)), [UInt8](longPacket))
|
[UInt8](pieces.reduce(Data(), +)), [UInt8](longPacket))
|
||||||
@@ -741,5 +849,105 @@ checkEqual("innerhalb der Toleranz kein Keil",
|
|||||||
checkEqual("ohne Maß kein Keil",
|
checkEqual("ohne Maß kein Keil",
|
||||||
LevelingWedge.across(roll: 3.0, trackWidth: 0) == nil, true)
|
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.")
|
print(failures == 0 ? "\nAlle Prüfungen bestanden." : "\n\(failures) Prüfung(en) fehlgeschlagen.")
|
||||||
exit(failures == 0 ? 0 : 1)
|
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.
|
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/ConfiguredDevice.swift \
|
||||||
CamperMonitor/Models/Profile.swift \
|
CamperMonitor/Models/Profile.swift \
|
||||||
CamperMonitor/Models/AlignmentAssistant.swift \
|
CamperMonitor/Models/AlignmentAssistant.swift \
|
||||||
|
CamperMonitor/Models/SensorOrientation.swift \
|
||||||
CamperMonitor/Models/VictronCodes.swift \
|
CamperMonitor/Models/VictronCodes.swift \
|
||||||
CamperMonitor/Store/KeychainStore.swift \
|
CamperMonitor/Store/KeychainStore.swift \
|
||||||
Tests/main.swift
|
Tests/main.swift
|
||||||
|
|||||||
Reference in New Issue
Block a user