forked from fritob/Camper-Monitor
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>
273 lines
10 KiB
Swift
273 lines
10 KiB
Swift
import Foundation
|
|
|
|
/// Protokoll der WattCycle-BLE-Akkus.
|
|
///
|
|
/// Weder Daly noch JBD, sondern ein eigenes Modbus-artiges Format. Zwei
|
|
/// Besonderheiten:
|
|
///
|
|
/// * Vor der ersten Abfrage muss der ASCII-Text `HiLink` auf eine eigene
|
|
/// Freischalt-Charakteristik (`FFFA`) geschrieben werden. Ohne das bleibt
|
|
/// der Akku auf jede Anfrage stumm.
|
|
/// * Anfragen gehen auf `FFF2`, Antworten kommen über `FFF1`.
|
|
///
|
|
/// Rahmenaufbau:
|
|
///
|
|
/// Anfrage (11 Byte):
|
|
/// 1E 00 01 03 <Datenpunkt 2 Byte> 00 00 <CRC16 2 Byte> 0D
|
|
/// Antwort:
|
|
/// 7E <Ver> <Adr> <Funktion> <Datenpunkt 2 Byte> <Länge 2 Byte>
|
|
/// <Daten…> <CRC16 2 Byte> 0D
|
|
///
|
|
/// Die Prüfsumme ist der übliche Modbus-CRC16 über alles vor der Prüfsumme,
|
|
/// höherwertiges Byte zuerst. Nachgerechnet gegen die Tabellenvariante der
|
|
/// Referenzimplementierung (frabnet/esphome-wattcycle-ble).
|
|
enum WattCycleProtocol {
|
|
|
|
static let frameHeadRequest: UInt8 = 0x1E
|
|
static let frameHeadResponse: UInt8 = 0x7E
|
|
static let frameTail: UInt8 = 0x0D
|
|
static let functionRead: UInt8 = 0x03
|
|
static let functionError: UInt8 = 0x86
|
|
|
|
/// Der Freischalt-Text, der vor der ersten Abfrage geschrieben wird.
|
|
static let authPayload = Data("HiLink".utf8)
|
|
|
|
enum Datapoint: UInt16 {
|
|
case analog = 0x008C // Messwerte
|
|
case product = 0x0092 // Modell, Hersteller, Seriennummer
|
|
}
|
|
|
|
static func requestFrame(_ datapoint: Datapoint) -> Data {
|
|
var frame: [UInt8] = [
|
|
frameHeadRequest,
|
|
0x00, // Version
|
|
0x01, // Adresse
|
|
functionRead,
|
|
UInt8(datapoint.rawValue >> 8),
|
|
UInt8(datapoint.rawValue & 0xFF),
|
|
0x00, 0x00, // Anzahl: 0 liefert den ganzen Datensatz
|
|
]
|
|
let crc = DalyProtocol.crc16Modbus(frame)
|
|
frame.append(UInt8(crc >> 8))
|
|
frame.append(UInt8(crc & 0xFF))
|
|
frame.append(frameTail)
|
|
return Data(frame)
|
|
}
|
|
|
|
struct Frame {
|
|
let function: UInt8
|
|
let datapoint: UInt16
|
|
let payload: [UInt8]
|
|
|
|
var isError: Bool { function == functionError }
|
|
}
|
|
|
|
/// Sucht vollständige, prüfsummenkorrekte Antwortrahmen im Puffer.
|
|
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
|
|
var frames: [Frame] = []
|
|
var index = 0
|
|
var consumed = 0
|
|
|
|
while index + 11 <= buffer.count {
|
|
guard buffer[index] == frameHeadResponse else {
|
|
index += 1
|
|
continue
|
|
}
|
|
let dataLength = Int(buffer[index + 6]) << 8 | Int(buffer[index + 7])
|
|
let total = dataLength + 11
|
|
guard total <= 512 else { index += 1; continue }
|
|
guard index + total <= buffer.count else { break } // Rest abwarten
|
|
|
|
let frame = Array(buffer[index..<(index + total)])
|
|
guard frame[total - 1] == frameTail else {
|
|
index += 1
|
|
continue
|
|
}
|
|
let expected = DalyProtocol.crc16Modbus(Array(frame[0..<(total - 3)]))
|
|
let actual = UInt16(frame[total - 3]) << 8 | UInt16(frame[total - 2])
|
|
guard expected == actual else {
|
|
index += 1
|
|
continue
|
|
}
|
|
frames.append(Frame(
|
|
function: frame[3],
|
|
datapoint: UInt16(frame[4]) << 8 | UInt16(frame[5]),
|
|
payload: Array(frame[8..<(8 + dataLength)])
|
|
))
|
|
index += total
|
|
consumed = index
|
|
}
|
|
let keepFrom = max(consumed, max(0, buffer.count - 256))
|
|
return (frames, Array(buffer[keepFrom...]))
|
|
}
|
|
|
|
/// Temperaturen kommen in Zehntel-Kelvin.
|
|
static func temperature(_ raw: UInt16) -> Double {
|
|
(Double(raw) - 2730) / 10
|
|
}
|
|
|
|
/// Der Strom hat ein eigenes Format: Bit 15 ist das Vorzeichen, Bit 14 gibt
|
|
/// an, ob der Rest in Zehntel-Ampere zu lesen ist, der Rest ist der Betrag.
|
|
static func current(high: UInt8, low: UInt8) -> Double {
|
|
let isNegative = high & 0x80 != 0
|
|
let hasDecimal = high & 0x40 != 0
|
|
let magnitude = Double(Int(low) | (Int(high & 0x3F) << 8))
|
|
let value = hasDecimal ? magnitude / 10 : magnitude
|
|
return isNegative ? -value : value
|
|
}
|
|
}
|
|
|
|
/// Sammelt die Antworten eines WattCycle-Akkus.
|
|
struct WattCycleState {
|
|
var cellVolts: [Double] = []
|
|
var mosTemperature: Double?
|
|
var pcbTemperature: Double?
|
|
var cellTemperatures: [Double] = []
|
|
var current: Double?
|
|
var voltage: Double?
|
|
var remainingAh: Double?
|
|
var totalAh: Double?
|
|
var designAh: Double?
|
|
var cycles: Int?
|
|
var soc: Double?
|
|
|
|
var model: String?
|
|
var manufacturer: String?
|
|
var serial: String?
|
|
|
|
var hasUsableData: Bool { voltage != nil || soc != nil || !cellVolts.isEmpty }
|
|
var hasProductInfo: Bool { model != nil || manufacturer != nil || serial != nil }
|
|
|
|
mutating func apply(_ frame: WattCycleProtocol.Frame) {
|
|
guard !frame.isError else { return }
|
|
switch WattCycleProtocol.Datapoint(rawValue: frame.datapoint) {
|
|
case .analog: applyAnalog(frame.payload)
|
|
case .product: applyProduct(frame.payload)
|
|
case nil: break
|
|
}
|
|
}
|
|
|
|
/// Der Messwert-Datensatz ist selbstbeschreibend: erst die Zellenanzahl,
|
|
/// dann die Zellspannungen, dann die Fühleranzahl und so weiter. Die
|
|
/// Feldlängen stehen also nicht fest und werden mitgelesen.
|
|
private mutating func applyAnalog(_ data: [UInt8]) {
|
|
var offset = 0
|
|
|
|
func readUInt16() -> UInt16? {
|
|
guard offset + 1 < data.count else { return nil }
|
|
defer { offset += 2 }
|
|
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
|
}
|
|
func readUInt8() -> UInt8? {
|
|
guard offset < data.count else { return nil }
|
|
defer { offset += 1 }
|
|
return data[offset]
|
|
}
|
|
|
|
guard let cellCount = readUInt8() else { return }
|
|
var cells: [Double] = []
|
|
for _ in 0..<Int(cellCount) {
|
|
guard let millivolts = readUInt16() else { return }
|
|
cells.append(Double(millivolts) / 1000)
|
|
}
|
|
cellVolts = cells
|
|
|
|
// Die ersten beiden Fühler sind MOSFET und Platine, danach die Zellen.
|
|
guard let temperatureCount = readUInt8(), temperatureCount >= 2 else { return }
|
|
guard let mos = readUInt16(), let pcb = readUInt16() else { return }
|
|
mosTemperature = WattCycleProtocol.temperature(mos)
|
|
pcbTemperature = WattCycleProtocol.temperature(pcb)
|
|
|
|
var probes: [Double] = []
|
|
for _ in 0..<(Int(temperatureCount) - 2) {
|
|
guard let raw = readUInt16() else { return }
|
|
probes.append(WattCycleProtocol.temperature(raw))
|
|
}
|
|
cellTemperatures = probes
|
|
|
|
guard offset + 1 < data.count else { return }
|
|
current = WattCycleProtocol.current(high: data[offset], low: data[offset + 1])
|
|
offset += 2
|
|
|
|
guard let voltageRaw = readUInt16() else { return }
|
|
voltage = Double(voltageRaw) / 100
|
|
|
|
guard let remaining = readUInt16(),
|
|
let total = readUInt16(),
|
|
let cycleCount = readUInt16(),
|
|
let design = readUInt16(),
|
|
let charge = readUInt16() else { return }
|
|
remainingAh = Double(remaining) / 10
|
|
totalAh = Double(total) / 10
|
|
cycles = Int(cycleCount)
|
|
designAh = Double(design) / 10
|
|
soc = Double(charge)
|
|
}
|
|
|
|
/// Drei ASCII-Felder à 20 Byte.
|
|
private mutating func applyProduct(_ data: [UInt8]) {
|
|
guard data.count >= 60 else { return }
|
|
func text(_ range: Range<Int>) -> String? {
|
|
let value = String(decoding: data[range], as: UTF8.self)
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\0 "))
|
|
return value.isEmpty ? nil : value
|
|
}
|
|
model = text(0..<20)
|
|
manufacturer = text(20..<40)
|
|
serial = text(40..<60)
|
|
}
|
|
|
|
func snapshot(deviceID: UUID, rssi: Int?) -> DeviceSnapshot {
|
|
var snapshot = DeviceSnapshot(deviceID: deviceID, timestamp: Date(), rssi: rssi)
|
|
|
|
var metrics: [Metric] = [
|
|
Metric("soc", "Ladezustand", soc, unit: "%", precision: 0, primary: true),
|
|
Metric("voltage", "Spannung", voltage, unit: "V", precision: 2),
|
|
Metric("current", "Strom", current, unit: "A", precision: 1),
|
|
]
|
|
if let voltage, let current {
|
|
metrics.append(Metric("power", "Leistung", voltage * current, unit: "W", precision: 0))
|
|
}
|
|
metrics.append(Metric("capacity", "Restkapazität", remainingAh, unit: "Ah", precision: 1))
|
|
if let totalAh {
|
|
metrics.append(Metric("capacity_total", "Kapazität geladen", totalAh, unit: "Ah", precision: 1))
|
|
}
|
|
if let designAh {
|
|
metrics.append(Metric("capacity_design", "Nennkapazität", designAh, unit: "Ah", precision: 1))
|
|
}
|
|
if let maxV = cellVolts.max(), let minV = cellVolts.min() {
|
|
metrics.append(Metric("cell_delta", "Zell-Differenz",
|
|
(maxV - minV) * 1000, unit: "mV", precision: 0))
|
|
metrics.append(Metric("cell_max", "Höchste Zelle", maxV, unit: "V", precision: 3))
|
|
metrics.append(Metric("cell_min", "Niedrigste Zelle", minV, unit: "V", precision: 3))
|
|
}
|
|
if let mosTemperature {
|
|
metrics.append(Metric("temp_mos", "Temperatur MOSFET", mosTemperature, unit: "°C", precision: 1))
|
|
}
|
|
if let pcbTemperature {
|
|
metrics.append(Metric("temp_pcb", "Temperatur Platine", pcbTemperature, unit: "°C", precision: 1))
|
|
}
|
|
if let cycles {
|
|
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
|
|
}
|
|
snapshot.metrics = metrics
|
|
|
|
snapshot.cellVoltages = cellVolts
|
|
snapshot.temperatures = cellTemperatures
|
|
|
|
if let current {
|
|
if current > 0.3 { snapshot.state = "Lädt" }
|
|
else if current < -0.3 { snapshot.state = "Entlädt" }
|
|
else { snapshot.state = "Ruhend" }
|
|
}
|
|
|
|
var info: [DeviceSnapshot.InfoItem] = []
|
|
if let model { info.append(.init(label: "Modell / Firmware", value: model)) }
|
|
if let manufacturer { info.append(.init(label: "Hersteller", value: manufacturer)) }
|
|
if let serial { info.append(.init(label: "Seriennummer", value: serial)) }
|
|
snapshot.info = info
|
|
|
|
return snapshot
|
|
}
|
|
}
|