Files
Camper-Monitor/VanControl/Bluetooth/JBDProtocol.swift
fototeddyandClaude Sonnet 5 303a9735d0 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>
2026-09-06 21:25:28 +02:00

201 lines
7.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// Protokoll der JBD-/Xiaoxiang-BMS, wie sie unter anderem in WattCycle-Akkus
/// verbaut sind. Bekannt auch als „Smart BMS“ oder Overkill-Solar-Protokoll.
///
/// Rahmenaufbau:
///
/// Anfrage: DD A5 <Kommando> <Länge=00> <Prüfsumme 2 Byte> 77
/// Antwort: DD <Kommando> <Status> <Länge> <Daten…> <Prüfsumme 2 Byte> 77
///
/// Die Prüfsumme ist `0x10000 (Status + Länge + Daten)`, big-endian; in der
/// Anfrage entsprechend über Kommando und Länge.
enum JBDProtocol {
enum Command: UInt8, CaseIterable {
case basicInfo = 0x03
case cellVoltages = 0x04
}
static func requestFrame(_ command: Command) -> Data {
let checksum = checksum(over: [command.rawValue, 0x00])
return Data([0xDD, 0xA5, command.rawValue, 0x00,
UInt8(checksum >> 8), UInt8(checksum & 0xFF), 0x77])
}
static func checksum(over bytes: [UInt8]) -> UInt16 {
let sum = bytes.reduce(UInt32(0)) { $0 + UInt32($1) }
return UInt16(truncatingIfNeeded: 0x1_0000 &- sum)
}
struct Frame {
let command: UInt8
let payload: [UInt8]
}
/// Sucht vollständige, prüfsummenkorrekte Rahmen im Puffer.
static func extractFrames(from buffer: [UInt8]) -> (frames: [Frame], remainder: [UInt8]) {
var frames: [Frame] = []
var index = 0
var consumed = 0
while index + 7 <= buffer.count {
guard buffer[index] == 0xDD else {
index += 1
continue
}
let length = Int(buffer[index + 3])
let total = 4 + length + 3 // Kopf + Daten + Prüfsumme + 0x77
guard index + total <= buffer.count else { break } // Rest abwarten
let frame = Array(buffer[index..<(index + total)])
guard frame[total - 1] == 0x77 else {
index += 1
continue
}
let expected = checksum(over: Array(frame[2..<(4 + length)]))
let actual = UInt16(frame[4 + length]) << 8 | UInt16(frame[5 + length])
guard expected == actual else {
index += 1
continue
}
// Status ≠ 0 meldet einen Fehler; der Rahmen ist dann leer.
if frame[2] == 0x00 {
frames.append(Frame(command: frame[1],
payload: Array(frame[4..<(4 + length)])))
}
index += total
consumed = index
}
let keepFrom = max(consumed, max(0, buffer.count - 128))
return (frames, Array(buffer[keepFrom...]))
}
/// Klartext der Schutzabschaltungen aus der 16-Bit-Maske.
static func protectionReasons(_ mask: UInt16) -> [String] {
guard mask != 0 else { return [] }
let table: [(UInt16, String)] = [
(1 << 0, "Zellüberspannung"),
(1 << 1, "Zellunterspannung"),
(1 << 2, "Batterie Überspannung"),
(1 << 3, "Batterie Unterspannung"),
(1 << 4, "Ladetemperatur zu hoch"),
(1 << 5, "Ladetemperatur zu niedrig"),
(1 << 6, "Entladetemperatur zu hoch"),
(1 << 7, "Entladetemperatur zu niedrig"),
(1 << 8, "Ladestrom zu hoch"),
(1 << 9, "Entladestrom zu hoch"),
(1 << 10, "Kurzschluss"),
(1 << 11, "Fehler im Messkreis"),
(1 << 12, "MOSFET gesperrt"),
]
return table.filter { mask & $0.0 != 0 }.map(\.1)
}
}
/// Sammelt die Antworten eines JBD-BMS.
struct JBDState {
var totalVoltage: Double?
var current: Double?
var remainingCapacityAh: Double?
var nominalCapacityAh: Double?
var cycles: Int?
var soc: Double?
var chargeMOSOn: Bool?
var dischargeMOSOn: Bool?
var protections: [String] = []
var cellMillivolts: [Int] = []
var temperatures: [Double] = []
var hasUsableData: Bool { totalVoltage != nil || soc != nil || !cellMillivolts.isEmpty }
mutating func apply(_ frame: JBDProtocol.Frame) {
let d = frame.payload
func u16(_ i: Int) -> Int { Int(d[i]) << 8 | Int(d[i + 1]) }
func i16(_ i: Int) -> Int { Int(Int16(bitPattern: UInt16(u16(i)))) }
switch frame.command {
case 0x03:
guard d.count >= 23 else { return }
totalVoltage = Double(u16(0)) * 0.01 // 10 mV je Schritt
current = Double(i16(2)) * 0.01 // 10 mA, negativ = Entladung
remainingCapacityAh = Double(u16(4)) * 0.01
nominalCapacityAh = Double(u16(6)) * 0.01
cycles = u16(8)
protections = JBDProtocol.protectionReasons(UInt16(u16(16)))
soc = Double(d[19])
chargeMOSOn = d[20] & 0x01 != 0
dischargeMOSOn = d[20] & 0x02 != 0
// Ab Byte 23 folgen die NTC-Fühler, je zwei Byte in Zehntel-Kelvin.
let sensorCount = Int(d[22])
var readings: [Double] = []
for sensor in 0..<sensorCount {
let offset = 23 + sensor * 2
guard offset + 1 < d.count else { break }
readings.append((Double(u16(offset)) - 2731) / 10)
}
temperatures = readings
case 0x04:
var millivolts: [Int] = []
var offset = 0
while offset + 1 < d.count {
millivolts.append(u16(offset))
offset += 2
}
cellMillivolts = millivolts
default:
break
}
}
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", totalVoltage, unit: "V", precision: 2),
Metric("current", "Strom", current, unit: "A", precision: 1),
]
if let v = totalVoltage, let a = current {
metrics.append(Metric("power", "Leistung", v * a, unit: "W", precision: 0))
}
if let remainingCapacityAh {
metrics.append(Metric("capacity", "Restkapazität", remainingCapacityAh, unit: "Ah", precision: 1))
}
if let nominalCapacityAh {
metrics.append(Metric("capacity_nominal", "Nennkapazität", nominalCapacityAh, unit: "Ah", precision: 1))
}
if let maxV = cellMillivolts.max(), let minV = cellMillivolts.min() {
metrics.append(Metric("cell_delta", "Zell-Differenz", Double(maxV - minV), unit: "mV", precision: 0))
metrics.append(Metric("cell_max", "Höchste Zelle", Double(maxV) / 1000, unit: "V", precision: 3))
metrics.append(Metric("cell_min", "Niedrigste Zelle", Double(minV) / 1000, unit: "V", precision: 3))
}
if let warmest = temperatures.max() {
metrics.append(Metric("temp_max", "Temperatur", warmest, unit: "°C", precision: 0))
}
if let cycles {
metrics.append(Metric("cycles", "Ladezyklen", Double(cycles), unit: "", precision: 0))
}
snapshot.metrics = metrics
snapshot.cellVoltages = cellMillivolts.map { Double($0) / 1000 }
snapshot.temperatures = temperatures
snapshot.fault = protections.isEmpty ? nil : protections.joined(separator: ", ")
var notes: [String] = []
if chargeMOSOn == false { notes.append("Laden gesperrt") }
if dischargeMOSOn == false { notes.append("Entladen gesperrt") }
snapshot.offReasons = notes
if let current {
if current > 0.3 { snapshot.state = "Lädt" }
else if current < -0.3 { snapshot.state = "Entlädt" }
else { snapshot.state = "Ruhend" }
}
return snapshot
}
}