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:
fototeddy
2026-09-06 21:25:28 +02:00
co-authored by Claude Sonnet 5
parent 83ea85f3b8
commit 303a9735d0
79 changed files with 190 additions and 190 deletions
+344
View File
@@ -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
}
}