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
@@ -0,0 +1,272 @@
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
}
}