forked from fritob/Camper-Monitor
App-Namen im iOS-Projekt auf VanControl vereinheitlichen
CamperMonitor (Haupt-Repo) und VanAligneiOS (aus dem gemergten solar-integration-Branch) liefen unter zwei verschiedenen internen Namen, obwohl die App nach aussen längst einheitlich "VanControl Pro" heisst. Jetzt durchgängig VanControl: - Ordner: CamperMonitor/, CamperMonitorWatch/, CamperMonitorComplication/, VanAligneiOSWidget/ → VanControl/, VanControlWatch/, VanControlComplication/, VanControlWidget/ - Xcode-Projekt: CamperMonitor.xcodeproj → VanControl.xcodeproj, alle Targets/Schemes/Produktnamen entsprechend umbenannt - Bundle-Identifier auf Wunsch mitgeändert: de.s0.fototeddy.VanControl* (App noch nicht veröffentlicht); dabei auch die WKCompanionAppBundleIdentifier-Werte korrigiert, die noch das alte de.fritob-Präfix statt des tatsächlichen de.s0.fototeddy-Präfixes trugen - Swift-Dateien/Typen: CamperMonitorApp → VanControlApp, VanAligneiOSWidget* → VanControlWidget* - Config/*-Info.plist umbenannt, README.md/Tools/README.md/run-tests.sh auf die neuen Pfade angepasst Bewusst unverändert: firmware/vanalign und alle Bezüge auf "VanAlign" als Namen der Neigungsmesser-Hardware (eigenständiges Produkt, kein App-Name) sowie der komplette Android/-Ordner. Build (App, Watch, Debug) und Protokoll-Testlauf grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
83ea85f3b8
commit
303a9735d0
@@ -0,0 +1,344 @@
|
||||
import Foundation
|
||||
|
||||
/// Protokoll der Alpicool-Kompressorkühlboxen. Dieselbe Elektronik steckt
|
||||
/// unter anderem in den IceCube-Boxen von Plug-in Festivals sowie in Modellen
|
||||
/// von BrassMonkey und Ocean Comfort.
|
||||
///
|
||||
/// Gesprochen wird über zwei Charakteristiken: geschrieben auf `00001235-…`,
|
||||
/// Antworten kommen über `00001236-…`.
|
||||
///
|
||||
/// Rahmenaufbau in beide Richtungen:
|
||||
///
|
||||
/// FE FE <Länge> <Kommando> <Daten…> <Prüfsumme 2 Byte>
|
||||
///
|
||||
/// `Länge` zählt Kommando, Daten und Prüfsumme, die Gesamtlänge ist also
|
||||
/// `3 + Länge`. Die Prüfsumme ist die Summe aller vorangehenden Bytes,
|
||||
/// höherwertiges Byte zuerst.
|
||||
///
|
||||
/// Vor der ersten Abfrage muss einmal `BIND` geschickt werden. Steht „APP“ im
|
||||
/// Display der Box, verlangt sie dabei einen Tastendruck am Gerät.
|
||||
///
|
||||
/// Feldbelegung nach Gruni22/alpicool_ha_ble.
|
||||
enum AlpicoolProtocol {
|
||||
|
||||
static let header: [UInt8] = [0xFE, 0xFE]
|
||||
|
||||
enum Command: UInt8 {
|
||||
case bind = 0x00
|
||||
case query = 0x01
|
||||
case set = 0x02
|
||||
case reset = 0x04
|
||||
case setLeft = 0x05
|
||||
case setRight = 0x06
|
||||
}
|
||||
|
||||
/// Summe aller Bytes, auf 16 Bit beschnitten.
|
||||
static func checksum(_ bytes: [UInt8]) -> UInt16 {
|
||||
UInt16(truncatingIfNeeded: bytes.reduce(UInt32(0)) { $0 + UInt32($1) })
|
||||
}
|
||||
|
||||
static func packet(_ command: Command, data: [UInt8] = []) -> Data {
|
||||
var packet = header
|
||||
packet.append(UInt8(data.count + 3)) // Kommando + Daten + Prüfsumme
|
||||
packet.append(command.rawValue)
|
||||
packet.append(contentsOf: data)
|
||||
let sum = checksum(packet)
|
||||
packet.append(UInt8(sum >> 8))
|
||||
packet.append(UInt8(sum & 0xFF))
|
||||
return Data(packet)
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
let command: UInt8
|
||||
/// Nutzdaten ohne Kommando und ohne Prüfsumme.
|
||||
let payload: [UInt8]
|
||||
}
|
||||
|
||||
/// Sucht vollständige Rahmen im Puffer.
|
||||
///
|
||||
/// Auf Stellbefehle antwortet die Box mit zwei Paketen in einer einzigen
|
||||
/// Benachrichtigung: erst ein Echo des Befehls, dann der volle Status.
|
||||
/// Deshalb wird in der Schleife weitergesucht, statt nach dem ersten
|
||||
/// Treffer abzubrechen.
|
||||
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
||||
var frames: [Frame] = []
|
||||
var index = 0
|
||||
var consumed = 0
|
||||
|
||||
while index + 3 <= buffer.count {
|
||||
guard buffer[index] == 0xFE, buffer[index + 1] == 0xFE else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let total = 3 + Int(buffer[index + 2])
|
||||
guard total >= 6, total <= 128 else { index += 1; continue }
|
||||
guard index + total <= buffer.count else { break } // Rest abwarten
|
||||
|
||||
let packet = Array(buffer[index..<(index + total)])
|
||||
let expected = checksum(Array(packet[0..<(total - 2)]))
|
||||
let actual = UInt16(packet[total - 2]) << 8 | UInt16(packet[total - 1])
|
||||
guard expected == actual else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
frames.append(Frame(command: packet[3],
|
||||
payload: Array(packet[4..<(total - 2)])))
|
||||
index += total
|
||||
consumed = index
|
||||
}
|
||||
let keepFrom = max(consumed, max(0, buffer.count - 128))
|
||||
return (frames, Array(buffer[keepFrom...]))
|
||||
}
|
||||
|
||||
static func signed(_ byte: UInt8) -> Int { Int(Int8(bitPattern: byte)) }
|
||||
|
||||
/// Womit diese Boxen einen nicht vorhandenen Fühler melden.
|
||||
static let missingSensorReading = -128
|
||||
|
||||
/// Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
|
||||
/// Gerät sie wieder zusammensetzen kann.
|
||||
static let chunkDelay: TimeInterval = 0.15
|
||||
|
||||
/// Wieviel die Box je Schreibvorgang annimmt.
|
||||
///
|
||||
/// Das sind die 20 Nutzbytes der Standard-MTU – unabhängig davon, was auf
|
||||
/// der Verbindung ausgehandelt wurde. Ein längerer Schreibvorgang wird von
|
||||
/// diesen Boxen abgelehnt; belegt an einer Maentum/Plug-in Festival
|
||||
/// IceCube Dual, bei der genau deshalb das Ein- und Ausschalten scheiterte,
|
||||
/// während der kurze Temperaturbefehl durchging
|
||||
/// (Gruni22/alpicool_ha_ble#20). Das Ändern der Solltemperatur geht mit
|
||||
/// sieben Byte durch, der Einstellungsblock mit 31 nicht.
|
||||
static let maxWriteSize = 20
|
||||
|
||||
/// Zerlegt ein Paket in schreibbare Stücke.
|
||||
///
|
||||
/// Ohne ausgehandelte MTU nimmt BLE nur 20 Nutzbytes je Schreibvorgang an.
|
||||
/// Der Einstellungsblock einer Kühlbox ist mit bis zu 31 Byte länger und
|
||||
/// würde sonst stillschweigend verworfen.
|
||||
static func chunks(_ data: Data, limit: Int) -> [Data] {
|
||||
guard limit > 0 else { return [data] }
|
||||
guard data.count > limit else { return [data] }
|
||||
return stride(from: 0, to: data.count, by: limit).map { start in
|
||||
data.subdata(in: start..<min(start + limit, data.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zustand einer Kühlbox – Messwerte und die Einstellungen, die sich ändern
|
||||
/// lassen.
|
||||
struct AlpicoolState: Equatable {
|
||||
var isLocked = false
|
||||
var isPoweredOn = true
|
||||
/// 0 = Max, 1 = Eco.
|
||||
var runMode = 0
|
||||
var batterySaver = 0
|
||||
|
||||
var leftTarget: Int?
|
||||
var leftCurrent: Int?
|
||||
var rightTarget: Int?
|
||||
var rightCurrent: Int?
|
||||
|
||||
var temperatureMin: Int?
|
||||
var temperatureMax: Int?
|
||||
var startDelayMinutes: Int?
|
||||
var returnDifference: Int?
|
||||
/// 0 = °C, 1 = °F.
|
||||
var unit = 0
|
||||
var runningStatus: Int?
|
||||
|
||||
var batteryPercent: Int?
|
||||
var batteryVolts: Double?
|
||||
|
||||
/// Die vollständige Nutzlast der letzten Statusantwort. Stellbefehle für
|
||||
/// Ein/Aus und Betriebsart schicken den gesamten Einstellungsblock zurück,
|
||||
/// deshalb wird er aufgehoben.
|
||||
var lastPayload: [UInt8] = []
|
||||
|
||||
/// Übersteuerung aus den Geräteeinstellungen.
|
||||
var zoneMode: FridgeZoneMode = .automatic
|
||||
|
||||
/// Die Einstellungsbytes der rechten Zone, für die Erkennung und die
|
||||
/// Diagnose. Ohne den Messwert – der wird getrennt beurteilt.
|
||||
var rightZoneBytes: [UInt8] = []
|
||||
|
||||
/// Ob die Box wirklich eine zweite Zone hat.
|
||||
///
|
||||
/// Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen
|
||||
/// Datensatz teils mit und füllen den zweiten Block auf. Zwei Anzeichen
|
||||
/// verraten das. Erstens meldet die Box für den fehlenden zweiten Fühler
|
||||
/// -128, den üblichen Platzhalter. Zweitens stehen die Einstellungen der
|
||||
/// rechten Zone dann auf lauter Nullen oder lauter 0xFF.
|
||||
///
|
||||
/// Das ist keine Frage der Anzeige allein: der Stellbefehl fällt für eine
|
||||
/// Box mit zwei Zonen länger aus, und die falsche Länge wird verworfen.
|
||||
var detectedDualZone: Bool {
|
||||
guard let current = rightCurrent,
|
||||
current != AlpicoolProtocol.missingSensorReading else { return false }
|
||||
guard !rightZoneBytes.isEmpty else { return false }
|
||||
return rightZoneBytes.contains { $0 != 0x00 } && rightZoneBytes.contains { $0 != 0xFF }
|
||||
}
|
||||
|
||||
var isDualZone: Bool {
|
||||
switch zoneMode {
|
||||
case .automatic: return detectedDualZone
|
||||
case .single: return false
|
||||
case .dual: return rightCurrent != nil
|
||||
}
|
||||
}
|
||||
var isEco: Bool { runMode == 1 }
|
||||
var usesFahrenheit: Bool { unit == 1 }
|
||||
var hasStatus: Bool { !lastPayload.isEmpty }
|
||||
|
||||
var unitSymbol: String { usesFahrenheit ? "°F" : "°C" }
|
||||
|
||||
/// Grenzen für den Sollwert. Meldet die Box keine brauchbaren, gelten
|
||||
/// die üblichen Werte der Baureihe.
|
||||
var targetRange: ClosedRange<Int> {
|
||||
let low = temperatureMin ?? (usesFahrenheit ? -22 : -30)
|
||||
let high = temperatureMax ?? (usesFahrenheit ? 68 : 20)
|
||||
return low < high ? low...high : (usesFahrenheit ? -22...68 : -30...20)
|
||||
}
|
||||
|
||||
mutating func apply(_ frame: AlpicoolProtocol.Frame) {
|
||||
// Nur Statusantworten auswerten; das Echo eines Stellbefehls ist kurz.
|
||||
guard frame.command == AlpicoolProtocol.Command.query.rawValue,
|
||||
frame.payload.count >= 18 else { return }
|
||||
let p = frame.payload
|
||||
lastPayload = p
|
||||
|
||||
isLocked = p[0] != 0
|
||||
isPoweredOn = p[1] != 0
|
||||
runMode = Int(p[2])
|
||||
batterySaver = Int(p[3])
|
||||
leftTarget = AlpicoolProtocol.signed(p[4])
|
||||
temperatureMax = AlpicoolProtocol.signed(p[5])
|
||||
temperatureMin = AlpicoolProtocol.signed(p[6])
|
||||
returnDifference = AlpicoolProtocol.signed(p[7])
|
||||
startDelayMinutes = Int(p[8])
|
||||
unit = Int(p[9])
|
||||
leftCurrent = AlpicoolProtocol.signed(p[14])
|
||||
batteryPercent = Int(p[15])
|
||||
batteryVolts = Double(p[16]) + Double(p[17]) / 10
|
||||
|
||||
if p.count >= 28 {
|
||||
rightTarget = AlpicoolProtocol.signed(p[18])
|
||||
rightCurrent = AlpicoolProtocol.signed(p[26])
|
||||
runningStatus = Int(p[27])
|
||||
rightZoneBytes = Array(p[18...25])
|
||||
} else {
|
||||
rightTarget = nil
|
||||
rightCurrent = nil
|
||||
rightZoneBytes = []
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Stand, der die Verbindung überdauert – ohne Messwerte.
|
||||
var settings: FridgeSettings? {
|
||||
guard hasStatus else { return nil }
|
||||
return FridgeSettings(isPoweredOn: isPoweredOn,
|
||||
isEco: isEco,
|
||||
isLocked: isLocked,
|
||||
isDualZone: isDualZone,
|
||||
usesFahrenheit: usesFahrenheit,
|
||||
leftTarget: leftTarget,
|
||||
rightTarget: isDualZone ? rightTarget : nil,
|
||||
updated: Date())
|
||||
}
|
||||
|
||||
/// Die Bytes, die ein Stellbefehl ändert.
|
||||
///
|
||||
/// Messwerte gehören nicht dazu: Temperatur und Spannung schwanken
|
||||
/// ohnehin, an ihnen liesse sich nicht ablesen, ob ein Befehl gewirkt hat.
|
||||
var settingsFingerprint: [UInt8] {
|
||||
guard lastPayload.count >= 18 else { return [] }
|
||||
var bytes = [lastPayload[0], lastPayload[1], lastPayload[2], lastPayload[4]]
|
||||
if lastPayload.count >= 28 { bytes.append(lastPayload[18]) }
|
||||
return bytes
|
||||
}
|
||||
|
||||
// MARK: - Stellbefehle
|
||||
|
||||
static func setTarget(zone: Zone, to value: Int) -> Data {
|
||||
AlpicoolProtocol.packet(zone == .left ? .setLeft : .setRight,
|
||||
data: [UInt8(bitPattern: Int8(clamping: value))])
|
||||
}
|
||||
|
||||
enum Zone { case left, right }
|
||||
|
||||
/// Baut den Einstellungsblock neu auf und ändert darin einzelne Bytes.
|
||||
/// Ein Teil-Update gibt es bei diesem Kommando nicht – die Box erwartet
|
||||
/// den kompletten Block, sonst überschreibt sie Einstellungen mit Nullen.
|
||||
func settingsCommand(poweredOn: Bool? = nil,
|
||||
eco: Bool? = nil,
|
||||
locked: Bool? = nil) -> Data? {
|
||||
guard lastPayload.count >= 18 else { return nil }
|
||||
let p = lastPayload
|
||||
|
||||
var data: [UInt8] = [
|
||||
locked.map { $0 ? 1 : 0 } ?? p[0],
|
||||
poweredOn.map { $0 ? 1 : 0 } ?? p[1],
|
||||
eco.map { $0 ? 1 : 0 } ?? p[2],
|
||||
p[3], // Batteriewächter
|
||||
p[4], // Sollwert links
|
||||
p[5], p[6], // Grenzen
|
||||
p[7], // Rückschaltdifferenz
|
||||
p[8], // Anlaufverzögerung
|
||||
p[9], // Einheit
|
||||
p[10], p[11], p[12], p[13], // Kompressordrehzahlen
|
||||
]
|
||||
|
||||
// Der zweite Block gehört nur an den Befehl, wenn die Box wirklich
|
||||
// zwei Zonen hat. Eine Einzonen-Box sendet den langen Datensatz teils
|
||||
// trotzdem – nimmt aber nur den kurzen Befehl an. Stimmt die Erkennung
|
||||
// im Einzelfall nicht, lässt sie sich in den Geräteeinstellungen von
|
||||
// Hand festlegen.
|
||||
if isDualZone, p.count >= 28 {
|
||||
data += [
|
||||
p[18], // Sollwert rechts
|
||||
0, 0,
|
||||
p[21], // Rückschaltdifferenz rechts
|
||||
p[22], p[23], p[24], p[25],
|
||||
0, 0, 0,
|
||||
]
|
||||
}
|
||||
return AlpicoolProtocol.packet(.set, data: data)
|
||||
}
|
||||
|
||||
// MARK: - Anzeige
|
||||
|
||||
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
||||
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
||||
let unit = unitSymbol
|
||||
|
||||
var metrics: [Metric] = [
|
||||
Metric("temp_left", isDualZone ? "Temperatur links" : "Temperatur",
|
||||
leftCurrent.map(Double.init), unit: unit, precision: 0, primary: true),
|
||||
Metric("target_left", isDualZone ? "Soll links" : "Solltemperatur",
|
||||
leftTarget.map(Double.init), unit: unit, precision: 0),
|
||||
]
|
||||
if isDualZone {
|
||||
metrics.append(Metric("temp_right", "Temperatur rechts",
|
||||
rightCurrent.map(Double.init), unit: unit, precision: 0))
|
||||
metrics.append(Metric("target_right", "Soll rechts",
|
||||
rightTarget.map(Double.init), unit: unit, precision: 0))
|
||||
}
|
||||
metrics.append(Metric("supply_voltage", "Bordspannung", batteryVolts, unit: "V", precision: 1))
|
||||
metrics.append(Metric("battery_percent", "Batterieanzeige",
|
||||
batteryPercent.map(Double.init), unit: "%", precision: 0))
|
||||
snapshot.metrics = metrics
|
||||
|
||||
if !isPoweredOn {
|
||||
snapshot.state = "Aus"
|
||||
} else if runningStatus == 1 {
|
||||
snapshot.state = isEco ? "Kühlt (Eco)" : "Kühlt (Max)"
|
||||
} else {
|
||||
snapshot.state = isEco ? "Eco" : "Max"
|
||||
}
|
||||
|
||||
var notes: [String] = []
|
||||
if isLocked { notes.append("Bedienfeld gesperrt") }
|
||||
snapshot.offReasons = notes
|
||||
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user