first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
@@ -0,0 +1,34 @@
package de.fritob.campermonitor.protocol
import java.security.GeneralSecurityException
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* AES-128 im Counter-Modus.
*
* Anders als unter iOS, wo CryptoKit CTR nicht anbietet und CommonCrypto
* herhalten muss, bringt die JVM den Modus mit. Der Zähler wird dort wie bei
* Victron big-endian hochgezählt.
*/
object AesCounterMode {
/**
* @param data Der verschlüsselte Nutzteil des Advertisements.
* @param key 16 Byte Geräteschlüssel aus VictronConnect.
* @param nonce Der 16-Byte-Zählerblock (Victron: Nonce little-endian in den
* ersten beiden Bytes, Rest 0).
*/
fun crypt(data: ByteArray, key: ByteArray, nonce: ByteArray): ByteArray? {
if (key.size != 16 || nonce.size != 16) return null
return try {
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
// CTR ist symmetrisch; entschlüsselt wird mit derselben Operation.
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(nonce))
cipher.doFinal(data)
} catch (_: GeneralSecurityException) {
null
}
}
}
@@ -0,0 +1,186 @@
package de.fritob.campermonitor.protocol
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Hilft beim Ausrichten des Fahrzeugs während des Rangierens.
*
* Bewusst **ohne** Positionsbestimmung: Aus einem MEMS-Beschleunigungssensor
* lässt sich keine brauchbare Strecke ableiten, weil der Fehler beim
* zweifachen Integrieren quadratisch mit der Zeit wächst. Beim Rangieren im
* Schritttempo gehen die tatsächlichen Beschleunigungen ohnehin im Rauschen
* unter.
*
* Gebraucht wird das auch gar nicht. Die Frage beim Einparken lautet nie „wo
* stehe ich", sondern „wird es besser oder schlechter, und wo war es am
* besten". Beides steckt bereits im zeitlichen Verlauf der Neigung ganz
* ohne Annahmen über das Gelände.
*
* Die Zeit kommt in Millisekunden von aussen herein, damit sich der Verlauf in
* Prüfungen vorgeben lässt, statt von der Uhr abzuhängen.
*/
class AlignmentAssistant {
data class Sample(val time: Long, val pitch: Double, val roll: Double) {
/** Gesamtabweichung von der Waagerechten. */
val deviation: Double get() = sqrt(pitch * pitch + roll * roll)
}
enum class Trend(val text: String) {
IMPROVING("Wird besser"),
WORSENING("Wird schlechter"),
STEADY("Bleibt gleich"),
UNKNOWN("Messe…"),
}
private val _samples = mutableListOf<Sample>()
val samples: List<Sample> get() = _samples
fun add(pitch: Double, roll: Double, at: Long = System.currentTimeMillis()) {
_samples.add(Sample(at, pitch, roll))
val cutoff = at - MEMORY_MS
_samples.removeAll { it.time < cutoff }
}
fun reset() = _samples.clear()
val current: Sample? get() = _samples.lastOrNull()
/** Der flachste Punkt, den wir gesehen haben. */
val best: Sample? get() = _samples.minByOrNull { it.deviation }
/**
* Ob und wie stark sich die Lage gerade ändert.
*
* Verglichen wird das jüngste Drittel mit dem davorliegenden. Einzelne
* Messwerte wären zu unruhig; das Fahrzeug wippt beim Rangieren.
*/
val trend: Trend
get() {
if (_samples.size < 6) return Trend.UNKNOWN
val recent = _samples.takeLast(3)
val previous = _samples.dropLast(3).takeLast(3)
if (previous.isEmpty()) return Trend.UNKNOWN
val now = recent.sumOf { it.deviation } / recent.size
val before = previous.sumOf { it.deviation } / previous.size
val change = now - before
if (abs(change) < TREND_THRESHOLD) return Trend.STEADY
return if (change < 0) Trend.IMPROVING else Trend.WORSENING
}
/**
* Wieviel besser der beste Punkt gegenüber jetzt war null, wenn es sich
* nicht lohnt oder wir gerade selbst am besten Punkt stehen.
*/
val improvementAtBest: Double?
get() {
val now = current ?: return null
val best = best ?: return null
if (best.time >= now.time) return null
val gain = now.deviation - best.deviation
return if (gain >= WORTH_GOING_BACK) gain else null
}
/** Wie lange der beste Punkt zurückliegt, in Sekunden. */
val secondsSinceBest: Double?
get() {
if (improvementAtBest == null) return null
val now = current ?: return null
val best = best ?: return null
return (now.time - best.time) / 1000.0
}
/** Was der Fahrer jetzt tun soll. */
val advice: String
get() {
val now = current ?: return "Warte auf Messwerte…"
if (now.deviation <= LevelState.LEVEL_TOLERANCE) return "Steht eben anhalten"
secondsSinceBest?.let {
return String.format(
Locale.GERMANY, "Vor %d s stand es besser ein Stück zurück", it.roundToLong()
)
}
return when (trend) {
Trend.IMPROVING -> "Wird besser weiter so"
Trend.WORSENING -> "Wird schlechter andere Richtung"
Trend.STEADY -> "Ändert sich kaum andere Richtung versuchen"
Trend.UNKNOWN -> "Langsam weiterfahren"
}
}
val hasReachedTarget: Boolean
get() = (current?.deviation ?: Double.POSITIVE_INFINITY) <= LevelState.LEVEL_TOLERANCE
companion object {
/** Wie lange zurückgeschaut wird. */
const val MEMORY_MS = 90_000L
/** Ab dieser Verbesserung lohnt der Hinweis auf einen früheren Punkt. */
const val WORTH_GOING_BACK = 0.2
/** Unterhalb dieser Änderung gilt die Lage als unverändert. */
const val TREND_THRESHOLD = 0.08
}
}
/**
* Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
*
* Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
* beziehungsweise der Räder.
*/
data class LevelingWedge(
/** Wo der Keil hin muss. */
val side: Side,
/** Höhe in Metern. */
val height: Double,
/** Der zugrundeliegende Winkel in Grad. */
val angle: Double,
) {
enum class Side(val text: String) {
FRONT("vorne"),
REAR("hinten"),
LEFT("links"),
RIGHT("rechts"),
}
val heightInCentimetres: Double get() = height * 100
companion object {
/**
* Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
* heisst, dass rechts höher steht der Keil gehört also nach links.
*/
fun across(roll: Double, trackWidth: Double?): LevelingWedge? =
wedge(roll, trackWidth, Side.LEFT, Side.RIGHT)
/**
* Längs: positiver Pitch heisst, dass das Heck höher steht der Keil
* gehört unter die Vorderräder.
*/
fun along(pitch: Double, wheelbase: Double?): LevelingWedge? =
wedge(pitch, wheelbase, Side.FRONT, Side.REAR)
private fun wedge(
angle: Double,
distance: Double?,
whenPositive: Side,
whenNegative: Side,
): LevelingWedge? {
if (distance == null || distance <= 0) return null
if (abs(angle) <= LevelState.LEVEL_TOLERANCE) return null
val height = tan(abs(angle) * Math.PI / 180) * distance
return LevelingWedge(
side = if (angle > 0) whenPositive else whenNegative,
height = height,
angle = abs(angle),
)
}
}
}
@@ -0,0 +1,361 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/** Übersteuerung der Zonenerkennung aus den Geräteeinstellungen. */
enum class FridgeZoneMode(val title: String) {
AUTOMATIC("Automatisch"),
SINGLE("Eine Zone"),
DUAL("Zwei Zonen"),
}
/**
* 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.
*/
object AlpicoolProtocol {
enum class Command(val raw: Int) {
BIND(0x00),
QUERY(0x01),
SET(0x02),
RESET(0x04),
SET_LEFT(0x05),
SET_RIGHT(0x06),
}
/** Summe aller Bytes, auf 16 Bit beschnitten. */
fun checksum(bytes: ByteArray): Int {
var sum = 0
for (b in bytes) sum += b.toInt() and 0xFF
return sum and 0xFFFF
}
fun packet(command: Command, data: ByteArray = ByteArray(0)): ByteArray {
val head = byteArrayOf(
0xFE.toByte(), 0xFE.toByte(),
(data.size + 3).toByte(), // Kommando + Daten + Prüfsumme
command.raw.toByte(),
) + data
val sum = checksum(head)
return head + byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte())
}
class Frame(val command: Int, val payload: ByteArray)
/**
* 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.
*/
fun extractFrames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var consumed = 0
while (index + 3 <= buffer.size) {
if (buffer.u(index) != 0xFE || buffer.u(index + 1) != 0xFE) {
index += 1
continue
}
val total = 3 + buffer.u(index + 2)
if (total < 6 || total > 128) {
index += 1
continue
}
if (index + total > buffer.size) break // Rest abwarten
val packet = buffer.copyOfRange(index, index + total)
val expected = checksum(packet.copyOfRange(0, total - 2))
val actual = (packet.u(total - 2) shl 8) or packet.u(total - 1)
if (expected != actual) {
index += 1
continue
}
frames.add(Frame(packet.u(3), packet.copyOfRange(4, total - 2)))
index += total
consumed = index
}
val keepFrom = maxOf(consumed, maxOf(0, buffer.size - 128))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
fun signed(byte: Int): Int = byte.toByte().toInt()
/**
* Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
* Gerät sie wieder zusammensetzen kann.
*/
const val CHUNK_DELAY_MS = 150L
/**
* 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).
*/
const val MAX_WRITE_SIZE = 20
/** Womit diese Boxen einen nicht vorhandenen Fühler melden. */
const val MISSING_SENSOR_READING = -128
/** Zerlegt ein Paket in schreibbare Stücke. */
fun chunks(data: ByteArray, limit: Int): List<ByteArray> {
if (limit <= 0 || data.size <= limit) return listOf(data)
return (data.indices step limit).map {
data.copyOfRange(it, minOf(it + limit, data.size))
}
}
}
/** Zonen einer Kühlbox. */
enum class FridgeZone { LEFT, RIGHT }
/** Zustand einer Kühlbox Messwerte und die Einstellungen, die sich ändern lassen. */
class AlpicoolState {
var isLocked = false
var isPoweredOn = true
/** 0 = Max, 1 = Eco. */
var runMode = 0
var batterySaver = 0
var leftTarget: Int? = null
var leftCurrent: Int? = null
var rightTarget: Int? = null
var rightCurrent: Int? = null
var temperatureMin: Int? = null
var temperatureMax: Int? = null
var startDelayMinutes: Int? = null
var returnDifference: Int? = null
/** 0 = °C, 1 = °F. */
var unit = 0
var runningStatus: Int? = null
var batteryPercent: Int? = null
var batteryVolts: Double? = null
/**
* 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: ByteArray = ByteArray(0)
var zoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC
/**
* Die Einstellungsbytes der rechten Zone, für die Erkennung und die
* Diagnose. Ohne den Messwert der wird getrennt beurteilt.
*/
var rightZoneBytes: ByteArray = ByteArray(0)
/**
* 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.
*/
val detectedDualZone: Boolean
get() {
val current = rightCurrent ?: return false
if (current == AlpicoolProtocol.MISSING_SENSOR_READING) return false
if (rightZoneBytes.isEmpty()) return false
return rightZoneBytes.any { it.toInt() != 0x00 } &&
rightZoneBytes.any { (it.toInt() and 0xFF) != 0xFF }
}
val isDualZone: Boolean
get() = when (zoneMode) {
FridgeZoneMode.AUTOMATIC -> detectedDualZone
FridgeZoneMode.SINGLE -> false
FridgeZoneMode.DUAL -> rightCurrent != null
}
val isEco: Boolean get() = runMode == 1
val usesFahrenheit: Boolean get() = unit == 1
val hasStatus: Boolean get() = lastPayload.isNotEmpty()
val unitSymbol: String get() = if (usesFahrenheit) "°F" else "°C"
/**
* Grenzen für den Sollwert. Meldet die Box keine brauchbaren, gelten die
* üblichen Werte der Baureihe.
*/
val targetRange: IntRange
get() {
val low = temperatureMin ?: if (usesFahrenheit) -22 else -30
val high = temperatureMax ?: if (usesFahrenheit) 68 else 20
return if (low < high) low..high else if (usesFahrenheit) -22..68 else -30..20
}
fun apply(frame: AlpicoolProtocol.Frame) {
// Nur Statusantworten auswerten; das Echo eines Stellbefehls ist kurz.
if (frame.command != AlpicoolProtocol.Command.QUERY.raw || frame.payload.size < 18) return
val p = frame.payload
lastPayload = p
isLocked = p.u(0) != 0
isPoweredOn = p.u(1) != 0
runMode = p.u(2)
batterySaver = p.u(3)
leftTarget = AlpicoolProtocol.signed(p.u(4))
temperatureMax = AlpicoolProtocol.signed(p.u(5))
temperatureMin = AlpicoolProtocol.signed(p.u(6))
returnDifference = AlpicoolProtocol.signed(p.u(7))
startDelayMinutes = p.u(8)
unit = p.u(9)
leftCurrent = AlpicoolProtocol.signed(p.u(14))
batteryPercent = p.u(15)
batteryVolts = p.u(16) + p.u(17) / 10.0
if (p.size >= 28) {
rightTarget = AlpicoolProtocol.signed(p.u(18))
rightCurrent = AlpicoolProtocol.signed(p.u(26))
runningStatus = p.u(27)
rightZoneBytes = p.copyOfRange(18, 26)
} else {
rightTarget = null
rightCurrent = null
rightZoneBytes = ByteArray(0)
}
}
// MARK: - Stellbefehle
/**
* 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.
*/
val settingsFingerprint: List<Int>
get() {
if (lastPayload.size < 18) return emptyList()
val bytes = mutableListOf(
lastPayload.u(0), lastPayload.u(1), lastPayload.u(2), lastPayload.u(4)
)
if (lastPayload.size >= 28) bytes.add(lastPayload.u(18))
return bytes
}
/**
* 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.
*/
fun settingsCommand(
poweredOn: Boolean? = null,
eco: Boolean? = null,
locked: Boolean? = null,
): ByteArray? {
if (lastPayload.size < 18) return null
val p = lastPayload
val data = mutableListOf(
locked?.let { if (it) 1 else 0 } ?: p.u(0),
poweredOn?.let { if (it) 1 else 0 } ?: p.u(1),
eco?.let { if (it) 1 else 0 } ?: p.u(2),
p.u(3), // Batteriewächter
p.u(4), // Sollwert links
p.u(5), p.u(6), // Grenzen
p.u(7), // Rückschaltdifferenz
p.u(8), // Anlaufverzögerung
p.u(9), // Einheit
p.u(10), p.u(11), p.u(12), p.u(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.
if (isDualZone && p.size >= 28) {
data += listOf(
p.u(18), // Sollwert rechts
0, 0,
p.u(21), // Rückschaltdifferenz rechts
p.u(22), p.u(23), p.u(24), p.u(25),
0, 0, 0,
)
}
return AlpicoolProtocol.packet(
AlpicoolProtocol.Command.SET,
ByteArray(data.size) { data[it].toByte() },
)
}
companion object {
fun setTarget(zone: FridgeZone, value: Int): ByteArray = AlpicoolProtocol.packet(
if (zone == FridgeZone.LEFT) AlpicoolProtocol.Command.SET_LEFT
else AlpicoolProtocol.Command.SET_RIGHT,
byteArrayOf(value.coerceIn(-128, 127).toByte()),
)
}
// MARK: - Anzeige
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val unitText = unitSymbol
val metrics = mutableListOf(
Metric("temp_left", if (isDualZone) "Temperatur links" else "Temperatur",
leftCurrent?.toDouble(), unitText, precision = 0, isPrimary = true),
Metric("target_left", if (isDualZone) "Soll links" else "Solltemperatur",
leftTarget?.toDouble(), unitText, precision = 0),
)
if (isDualZone) {
metrics.add(Metric("temp_right", "Temperatur rechts",
rightCurrent?.toDouble(), unitText, precision = 0))
metrics.add(Metric("target_right", "Soll rechts",
rightTarget?.toDouble(), unitText, precision = 0))
}
metrics.add(Metric("supply_voltage", "Bordspannung", batteryVolts, "V", precision = 1))
metrics.add(Metric("battery_percent", "Batterieanzeige",
batteryPercent?.toDouble(), "%", precision = 0))
val state = when {
!isPoweredOn -> "Aus"
runningStatus == 1 -> if (isEco) "Kühlt (Eco)" else "Kühlt (Max)"
else -> if (isEco) "Eco" else "Max"
}
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
state = state,
offReasons = if (isLocked) listOf("Bedienfeld gesperrt") else emptyList(),
)
}
}
@@ -0,0 +1,62 @@
package de.fritob.campermonitor.protocol
/** Der Wert eines Bytes ohne Vorzeichen. Kotlins `Byte` ist vorzeichenbehaftet. */
internal fun ByteArray.u(index: Int): Int = this[index].toInt() and 0xFF
/**
* Liest Felder beliebiger Bitbreite aus einem Byte-Array.
*
* Victron packt die Felder seiner Werbedaten little-endian und bitweise ohne
* Byte-Ausrichtung: das erste Feld beginnt am niederwertigsten Bit von Byte 0,
* jedes weitere schliesst direkt an.
*
* Gerechnet wird in `Long`, weil ein 32-Bit-Feld ohne Vorzeichen nicht in
* Kotlins `Int` passt.
*/
class BitReader(private val bytes: ByteArray) {
private var bitOffset = 0
val bitsRemaining: Int get() = bytes.size * 8 - bitOffset
/** Liest [width] Bits als vorzeichenlose Zahl, oder null bei zu kurzen Daten. */
fun read(width: Int): Long? {
if (width <= 0 || width > 32 || bitsRemaining < width) return null
var result = 0L
for (i in 0 until width) {
val absolute = bitOffset + i
val bit = (bytes.u(absolute / 8) shr (absolute % 8)) and 1
result = result or (bit.toLong() shl i)
}
bitOffset += width
return result
}
/**
* Wie [read], liefert aber null wenn alle Bits gesetzt sind so markiert
* Victron "Wert nicht verfügbar".
*/
fun readOptional(width: Int): Long? {
val raw = read(width) ?: return null
val notAvailable = if (width >= 32) 0xFFFF_FFFFL else (1L shl width) - 1
return if (raw == notAvailable) null else raw
}
/** Zweierkomplement-Feld. Der NA-Wert 0x7F..F wird zu null. */
fun readOptionalSigned(width: Int): Long? {
if (width <= 1) return null
val raw = read(width) ?: return null
val notAvailable = (1L shl (width - 1)) - 1
if (raw == notAvailable) return null
val signBit = 1L shl (width - 1)
return if (raw and signBit != 0L) raw - (1L shl width) else raw
}
/** Skaliertes, optionales Feld ohne Vorzeichen. */
fun scaled(width: Int, factor: Double): Double? =
readOptional(width)?.let { it.toDouble() * factor }
/** Skaliertes, optionales Feld mit Vorzeichen. */
fun scaledSigned(width: Int, factor: Double): Double? =
readOptionalSigned(width)?.let { it.toDouble() * factor }
}
@@ -0,0 +1,85 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* Welche Rolle ein Gerät im Camper spielt. Bestimmt Symbol, Sortierung und
* welche Kennzahl als "Hauptwert" auf der Kachel gross dargestellt wird.
*/
enum class DeviceRole(val title: String) {
CHARGE_BOOSTER("Ladebooster"),
SOLAR_CHARGER("Solarladeregler"),
BATTERY_MONITOR("Batteriemonitor"),
BMS("Batterie / BMS"),
FRIDGE("Kühlbox"),
LEVELING("Nivellierung");
/**
* Victron-Geräte werden passiv über das Advertisement gelesen, BMS,
* Kühlbox und Neigungsmesser brauchen eine echte GATT-Verbindung.
*/
val transport: DeviceTransport
get() = when (this) {
BMS, FRIDGE, LEVELING -> DeviceTransport.CONNECT
else -> DeviceTransport.ADVERTISEMENT
}
}
enum class DeviceTransport {
/** Passives Mitlesen der BLE-Werbedaten (Victron Instant Readout). */
ADVERTISEMENT,
/** Verbindungsaufbau, Kommando schreiben, Antwort per Notify lesen. */
CONNECT,
}
/**
* Ein eingerichtetes Gerät.
*
* Die Bluetooth-Adresse ist unter Android ein Text („AA:BB:CC:DD:EE:FF") und
* nicht wie unter iOS eine UUID iOS gibt die Hardware-Adresse gar nicht
* heraus, Android schon.
*/
data class ConfiguredDevice(
val id: UUID = UUID.randomUUID(),
val name: String,
val role: DeviceRole,
val profileID: UUID,
/** MAC-Adresse des Geräts. */
val address: String,
/** Wie das Gerät sich selbst nennt, für die Wiedererkennung. */
val advertisedName: String? = null,
val fridgeZoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC,
val sensorOrientation: SensorOrientation = SensorOrientation.IDENTITY,
)
/**
* Ein Fahrzeug. Jedes Profil hat seinen eigenen Satz Geräte; die App zeigt und
* funkt immer nur für das gerade gewählte.
*/
data class Profile(
val id: UUID = UUID.randomUUID(),
val name: String,
/** Symbolname für die Auswahl im Dashboard. */
val symbol: String = "box_truck",
/** Spurweite in Metern für die Berechnung der Auffahrkeile quer. */
val trackWidth: Double? = null,
/** Radstand in Metern dasselbe längs. */
val wheelbase: Double? = null,
) {
companion object {
/**
* Profil, dem Geräte aus der Zeit vor der Profilverwaltung zugeordnet
* werden. Feste Kennung, damit die Zuordnung beim Update erhalten bleibt.
*/
val DEFAULT_ID: UUID = UUID.fromString("00000000-0000-0000-0000-00000000c001")
fun initial() = Profile(id = DEFAULT_ID, name = "Mein Camper")
/** Auswahl für die Profilbearbeitung. */
val SYMBOLS = listOf(
"box_truck", "pickup", "bus", "double_decker",
"car", "car_side", "tent", "sailboat", "lodge", "mountains",
)
}
}
@@ -0,0 +1,128 @@
package de.fritob.campermonitor.protocol
/**
* Reine Protokoll-Logik für Daly-BMS ohne Bluetooth, damit sie sich isoliert
* prüfen lässt.
*
* Daly hat zwei Generationen im Umlauf:
*
* * **Klassisch (`A5`)** 13-Byte-Rahmen `A5 <adr> <cmd> 08 <8 Datenbytes> <Prüfsumme>`.
* Verbreitet bei den Smart-BMS mit dem blauen BLE-Stick, wie sie in vielen
* Bulltron-Akkus stecken.
* * **Neu (`D2`)** Modbus-RTU über BLE, `D2 03 <Startregister> <Anzahl> <CRC16>`.
*
* Welche Generation verbaut ist, erkennt die Sitzung anhand der Antwort.
*/
object DalyProtocol {
// MARK: - Klassisches A5-Protokoll
enum class Command(val raw: Int) {
SOC(0x90), // Spannung, Strom, Ladezustand
CELL_VOLTAGE_MIN_MAX(0x91),
TEMPERATURE_MIN_MAX(0x92),
MOSFET_STATUS(0x93),
STATUS_INFO(0x94),
CELL_VOLTAGES(0x95),
CELL_TEMPERATURES(0x96),
}
/** Adresse des Anfragenden. 0x80 = Bluetooth-Modul. */
const val HOST_ADDRESS = 0x80
fun requestFrame(command: Command): ByteArray {
val frame = ByteArray(13)
frame[0] = 0xA5.toByte()
frame[1] = HOST_ADDRESS.toByte()
frame[2] = command.raw.toByte()
frame[3] = 0x08
// Bytes 4..11 bleiben null.
var sum = 0
for (i in 0 until 12) sum = (sum + (frame[i].toInt() and 0xFF)) and 0xFF
frame[12] = sum.toByte()
return frame
}
class Frame(val address: Int, val command: Int, val payload: ByteArray)
/**
* Sucht vollständige, prüfsummenkorrekte A5-Rahmen im Puffer und gibt sie
* zusammen mit dem unverbrauchten Rest zurück.
*/
fun extractA5Frames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var lastConsumed = 0
while (index + 13 <= buffer.size) {
if (buffer.u(index) != 0xA5 || buffer.u(index + 3) != 0x08) {
index += 1
continue
}
var checksum = 0
for (i in index until index + 12) checksum = (checksum + buffer.u(i)) and 0xFF
if (checksum != buffer.u(index + 12)) {
index += 1
continue
}
frames.add(
Frame(
address = buffer.u(index + 1),
command = buffer.u(index + 2),
payload = buffer.copyOfRange(index + 4, index + 12),
)
)
index += 13
lastConsumed = index
}
// Angefangene Rahmen aufheben BLE liefert Antworten oft gestückelt.
val keepFrom = maxOf(lastConsumed, maxOf(0, buffer.size - 64))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
// MARK: - Modbus (D2)
/** Ein Lesekommando über alle interessanten Register. */
fun modbusReadFrame(start: Int = 0, count: Int = 62): ByteArray {
val frame = byteArrayOf(
0xD2.toByte(), 0x03,
(start shr 8).toByte(), (start and 0xFF).toByte(),
(count shr 8).toByte(), (count and 0xFF).toByte(),
)
val crc = crc16Modbus(frame)
return frame + byteArrayOf((crc and 0xFF).toByte(), (crc shr 8).toByte())
}
fun crc16Modbus(bytes: ByteArray): Int {
var crc = 0xFFFF
for (byte in bytes) {
crc = crc xor (byte.toInt() and 0xFF)
repeat(8) {
crc = if (crc and 1 != 0) (crc shr 1) xor 0xA001 else crc shr 1
}
}
return crc and 0xFFFF
}
/**
* Prüft einen vollständigen Modbus-Antwortrahmen und liefert die
* Registerwerte. Gibt null zurück, solange der Rahmen unvollständig ist.
*/
fun parseModbusResponse(buffer: ByteArray): IntArray? {
if (buffer.size < 5 || buffer.u(0) != 0xD2 || buffer.u(1) != 0x03) return null
val byteCount = buffer.u(2)
val total = 3 + byteCount + 2
if (buffer.size < total) return null
val body = buffer.copyOfRange(0, 3 + byteCount)
val expected = crc16Modbus(body)
val actual = buffer.u(3 + byteCount) or (buffer.u(4 + byteCount) shl 8)
if (expected != actual) return null
val registers = IntArray(byteCount / 2)
for (i in registers.indices) {
registers[i] = (body.u(3 + i * 2) shl 8) or body.u(3 + i * 2 + 1)
}
return registers
}
}
@@ -0,0 +1,223 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* Sammelt die Antworten eines Daly-BMS. Das klassische Protokoll verteilt die
* Werte auf mehrere Rahmen, deshalb wird hier über Abfragerunden hinweg
* akkumuliert und erst am Ende ein Snapshot gebaut.
*/
class DalyState {
var totalVoltage: Double? = null
var current: Double? = null
var soc: Double? = null
var maxCellMillivolts: Int? = null
var maxCellNumber: Int? = null
var minCellMillivolts: Int? = null
var minCellNumber: Int? = null
var maxTemperature: Double? = null
var minTemperature: Double? = null
var chargeMOSOn: Boolean? = null
var dischargeMOSOn: Boolean? = null
var chargeDischargeStatus: Int? = null
var remainingCapacityAh: Double? = null
var cellCount: Int? = null
var temperatureSensorCount: Int? = null
var cycles: Int? = null
/** Zellnummer (1-basiert) → Spannung in Millivolt. */
val cellMillivolts = sortedMapOf<Int, Int>()
/** Sensornummer (1-basiert) → Temperatur in °C. */
val sensorTemperatures = sortedMapOf<Int, Double>()
var usesModbus = false
// MARK: - Klassisches Protokoll
fun apply(frame: DalyProtocol.Frame) {
val d = frame.payload
fun u16(i: Int) = (d.u(i) shl 8) or d.u(i + 1)
when (frame.command) {
0x90 -> {
totalVoltage = u16(0) * 0.1
// Strom mit Offset 30000, damit Entladung negativ dargestellt wird.
current = (u16(4) - 30000) * 0.1
soc = u16(6) * 0.1
}
0x91 -> {
maxCellMillivolts = u16(0)
maxCellNumber = d.u(2)
minCellMillivolts = u16(3)
minCellNumber = d.u(5)
}
0x92 -> {
maxTemperature = (d.u(0) - 40).toDouble()
minTemperature = (d.u(2) - 40).toDouble()
}
0x93 -> {
chargeDischargeStatus = d.u(0)
chargeMOSOn = d.u(1) == 1
dischargeMOSOn = d.u(2) == 1
val capacityMilliAh = (d.u(4).toLong() shl 24) or (d.u(5).toLong() shl 16) or
(d.u(6).toLong() shl 8) or d.u(7).toLong()
remainingCapacityAh = capacityMilliAh / 1000.0
}
0x94 -> {
cellCount = d.u(0)
temperatureSensorCount = d.u(1)
cycles = u16(6)
}
0x95 -> {
// d[0] = Rahmennummer (1-basiert), danach drei Zellen à 2 Byte.
val frameNumber = d.u(0)
if (frameNumber > 0) {
for (slot in 0 until 3) {
val cell = (frameNumber - 1) * 3 + slot + 1
val millivolts = u16(1 + slot * 2)
if (millivolts in 1..5999) cellMillivolts[cell] = millivolts
}
}
}
0x96 -> {
val frameNumber = d.u(0)
if (frameNumber > 0) {
for (slot in 0 until 7) {
val sensor = (frameNumber - 1) * 7 + slot + 1
val raw = d.u(1 + slot)
if (raw != 0) sensorTemperatures[sensor] = (raw - 40).toDouble()
}
}
}
}
}
// MARK: - Modbus-Protokoll
/**
* Registerbelegung der neueren Daly-BMS.
*
* Achtung: Dieses Mapping variiert zwischen Firmwareständen. Die
* Detailansicht zeigt deshalb die Rohantwort an, damit sich die Belegung
* am realen Gerät nachprüfen lässt.
*/
fun apply(registers: IntArray) {
usesModbus = true
fun reg(i: Int): Int? = if (i < registers.size) registers[i] else null
// Register 047: Zellspannungen in mV, unbenutzte Plätze sind 0.
cellMillivolts.clear()
for (i in 0 until minOf(48, registers.size)) {
val millivolts = registers[i]
if (millivolts in 501..4999) cellMillivolts[i + 1] = millivolts
}
// Register 4855: Temperaturfühler mit Offset 40.
sensorTemperatures.clear()
for (i in 48 until minOf(56, registers.size)) {
val raw = registers[i]
if (raw in 1..199) sensorTemperatures[i - 47] = (raw - 40).toDouble()
}
reg(56)?.let { if (it > 0) totalVoltage = it * 0.1 }
reg(57)?.let { current = (it - 30000) * 0.1 }
reg(58)?.let { if (it <= 1000) soc = it * 0.1 }
maxCellMillivolts = cellMillivolts.values.maxOrNull()
minCellMillivolts = cellMillivolts.values.minOrNull()
maxCellNumber = cellMillivolts.maxByOrNull { it.value }?.key
minCellNumber = cellMillivolts.minByOrNull { it.value }?.key
maxTemperature = sensorTemperatures.values.maxOrNull()
minTemperature = sensorTemperatures.values.minOrNull()
cellCount = cellMillivolts.size.takeIf { it > 0 }
temperatureSensorCount = sensorTemperatures.size.takeIf { it > 0 }
}
// MARK: - Ausgabe
val hasUsableData: Boolean
get() = totalVoltage != null || soc != null || cellMillivolts.isNotEmpty()
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 1, isPrimary = true),
Metric("voltage", "Spannung", totalVoltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 1),
)
val v = totalVoltage
val a = current
if (v != null && a != null) {
metrics.add(Metric("power", "Leistung", v * a, "W", precision = 0))
}
remainingCapacityAh?.let {
metrics.add(Metric("capacity", "Restkapazität", it, "Ah", precision = 1))
}
val maxV = maxCellMillivolts
val minV = minCellMillivolts
if (maxV != null && minV != null) {
metrics.add(Metric("cell_delta", "Zell-Differenz", (maxV - minV).toDouble(), "mV", precision = 0))
metrics.add(
Metric("cell_max", "Höchste Zelle" + numberSuffix(maxCellNumber),
maxV / 1000.0, "V", precision = 3)
)
metrics.add(
Metric("cell_min", "Niedrigste Zelle" + numberSuffix(minCellNumber),
minV / 1000.0, "V", precision = 3)
)
}
maxTemperature?.let {
metrics.add(Metric("temp_max", "Temperatur", it, "°C", precision = 0))
}
minTemperature?.let {
if (it != maxTemperature) {
metrics.add(Metric("temp_min", "Temperatur min.", it, "°C", precision = 0))
}
}
cycles?.let {
metrics.add(Metric("cycles", "Ladezyklen", it.toDouble(), "", precision = 0))
}
val warnings = mutableListOf<String>()
if (chargeMOSOn == false) warnings.add("Lade-MOSFET aus")
if (dischargeMOSOn == false) warnings.add("Entlade-MOSFET aus")
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
state = stateText,
cellVoltages = cellMillivolts.values.map { it / 1000.0 },
temperatures = sensorTemperatures.values.toList(),
offReasons = warnings,
)
}
private fun numberSuffix(number: Int?): String = number?.let { " (Zelle $it)" } ?: ""
private val stateText: String?
get() {
when (chargeDischargeStatus) {
0 -> return "Ruhend"
1 -> return "Lädt"
2 -> return "Entlädt"
}
val c = current ?: return null
return when {
c > 0.3 -> "Lädt"
c < -0.3 -> "Entlädt"
else -> "Ruhend"
}
}
}
@@ -0,0 +1,86 @@
package de.fritob.campermonitor.protocol
import java.util.Locale
import java.util.UUID
/** Eine einzelne Messgrösse in einer bereits formatierten Form. */
data class Metric(
val key: String,
val label: String,
val value: Double?,
val unit: String,
/** Nachkommastellen für die Anzeige. */
val precision: Int = 2,
/** Wird auf der Kachel gross dargestellt. */
val isPrimary: Boolean = false,
) {
val formatted: String
get() = value?.let { String.format(Locale.GERMANY, "%.${precision}f", it) } ?: ""
val formattedWithUnit: String
get() = when {
value == null -> ""
unit.isEmpty() -> formatted
else -> "$formatted $unit"
}
}
/** Feste Angaben des Geräts, etwa Modell oder Seriennummer. */
data class InfoItem(val label: String, val value: String)
/**
* Der komplette, zuletzt empfangene Zustand eines Geräts.
*
* Der Zeitstempel ist die Zeit in Millisekunden seit 1970 statt eines
* `java.time`-Typs: das läuft ohne Rücksicht auf die Android-Version und lässt
* sich in Prüfungen vorgeben, statt von der Uhr abzuhängen.
*/
data class DeviceSnapshot(
val deviceID: UUID,
val timestamp: Long,
val metrics: List<Metric> = emptyList(),
/** z.B. "Bulk", "Float", "Aus" */
val state: String? = null,
/** Klartext einer aktiven Störung, sonst null. */
val fault: String? = null,
/** Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason). */
val offReasons: List<String> = emptyList(),
val rssi: Int? = null,
/** Einzelzellspannungen in Volt (nur BMS). */
val cellVoltages: List<Double> = emptyList(),
/** Temperaturfühler in °C (nur BMS). */
val temperatures: List<Double> = emptyList(),
val info: List<InfoItem> = emptyList(),
) {
val primaryMetric: Metric?
get() = metrics.firstOrNull { it.isPrimary } ?: metrics.firstOrNull()
fun ageInSeconds(now: Long = System.currentTimeMillis()): Double =
(now - timestamp) / 1000.0
/**
* Werte älter als eine Minute gelten als veraltet Victron sendet etwa
* jede Sekunde, das BMS wird alle paar Sekunden abgefragt.
*/
fun isStale(now: Long = System.currentTimeMillis()): Boolean = ageInSeconds(now) > 60
}
/** Verbindungszustand für die Oberfläche. */
sealed class DeviceLinkState {
data object Idle : DeviceLinkState()
data object Searching : DeviceLinkState()
data object Connecting : DeviceLinkState()
data object Live : DeviceLinkState()
data object NeedsKey : DeviceLinkState()
data class Failed(val message: String) : DeviceLinkState()
val label: String
get() = when (this) {
Idle -> "Inaktiv"
Searching -> "Suche…"
Connecting -> "Verbinde…"
Live -> "Live"
NeedsKey -> "Schlüssel fehlt"
is Failed -> message
}
}
@@ -0,0 +1,212 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* 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.
*/
object JbdProtocol {
enum class Command(val raw: Int) {
BASIC_INFO(0x03),
CELL_VOLTAGES(0x04),
}
fun requestFrame(command: Command): ByteArray {
val checksum = checksum(byteArrayOf(command.raw.toByte(), 0x00))
return byteArrayOf(
0xDD.toByte(), 0xA5.toByte(), command.raw.toByte(), 0x00,
(checksum shr 8).toByte(), (checksum and 0xFF).toByte(), 0x77,
)
}
fun checksum(bytes: ByteArray): Int {
var sum = 0
for (b in bytes) sum += b.toInt() and 0xFF
return (0x1_0000 - sum) and 0xFFFF
}
class Frame(val command: Int, val payload: ByteArray)
/** Sucht vollständige, prüfsummenkorrekte Rahmen im Puffer. */
fun extractFrames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var consumed = 0
while (index + 7 <= buffer.size) {
if (buffer.u(index) != 0xDD) {
index += 1
continue
}
val length = buffer.u(index + 3)
val total = 4 + length + 3 // Kopf + Daten + Prüfsumme + 0x77
if (index + total > buffer.size) break // Rest abwarten
val frame = buffer.copyOfRange(index, index + total)
if (frame.u(total - 1) != 0x77) {
index += 1
continue
}
val expected = checksum(frame.copyOfRange(2, 4 + length))
val actual = (frame.u(4 + length) shl 8) or frame.u(5 + length)
if (expected != actual) {
index += 1
continue
}
// Status ≠ 0 meldet einen Fehler; der Rahmen ist dann leer.
if (frame.u(2) == 0x00) {
frames.add(Frame(frame.u(1), frame.copyOfRange(4, 4 + length)))
}
index += total
consumed = index
}
val keepFrom = maxOf(consumed, maxOf(0, buffer.size - 128))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
/** Klartext der Schutzabschaltungen aus der 16-Bit-Maske. */
fun protectionReasons(mask: Int): List<String> {
if (mask == 0) return emptyList()
return PROTECTIONS.filter { mask and it.first != 0 }.map { it.second }
}
private val PROTECTIONS = listOf(
(1 shl 0) to "Zellüberspannung",
(1 shl 1) to "Zellunterspannung",
(1 shl 2) to "Batterie Überspannung",
(1 shl 3) to "Batterie Unterspannung",
(1 shl 4) to "Ladetemperatur zu hoch",
(1 shl 5) to "Ladetemperatur zu niedrig",
(1 shl 6) to "Entladetemperatur zu hoch",
(1 shl 7) to "Entladetemperatur zu niedrig",
(1 shl 8) to "Ladestrom zu hoch",
(1 shl 9) to "Entladestrom zu hoch",
(1 shl 10) to "Kurzschluss",
(1 shl 11) to "Fehler im Messkreis",
(1 shl 12) to "MOSFET gesperrt",
)
}
/** Sammelt die Antworten eines JBD-BMS. */
class JbdState {
var totalVoltage: Double? = null
var current: Double? = null
var remainingCapacityAh: Double? = null
var nominalCapacityAh: Double? = null
var cycles: Int? = null
var soc: Double? = null
var chargeMOSOn: Boolean? = null
var dischargeMOSOn: Boolean? = null
var protections: List<String> = emptyList()
var cellMillivolts: List<Int> = emptyList()
var temperatures: List<Double> = emptyList()
val hasUsableData: Boolean
get() = totalVoltage != null || soc != null || cellMillivolts.isNotEmpty()
fun apply(frame: JbdProtocol.Frame) {
val d = frame.payload
fun u16(i: Int) = (d.u(i) shl 8) or d.u(i + 1)
fun i16(i: Int) = u16(i).toShort().toInt()
when (frame.command) {
0x03 -> {
if (d.size < 23) return
totalVoltage = u16(0) * 0.01 // 10 mV je Schritt
current = i16(2) * 0.01 // 10 mA, negativ = Entladung
remainingCapacityAh = u16(4) * 0.01
nominalCapacityAh = u16(6) * 0.01
cycles = u16(8)
protections = JbdProtocol.protectionReasons(u16(16))
soc = d.u(19).toDouble()
chargeMOSOn = d.u(20) and 0x01 != 0
dischargeMOSOn = d.u(20) and 0x02 != 0
// Ab Byte 23 folgen die NTC-Fühler, je zwei Byte in Zehntel-Kelvin.
val sensorCount = d.u(22)
val readings = mutableListOf<Double>()
for (sensor in 0 until sensorCount) {
val offset = 23 + sensor * 2
if (offset + 1 >= d.size) break
readings.add((u16(offset) - 2731) / 10.0)
}
temperatures = readings
}
0x04 -> {
val millivolts = mutableListOf<Int>()
var offset = 0
while (offset + 1 < d.size) {
millivolts.add(u16(offset))
offset += 2
}
cellMillivolts = millivolts
}
}
}
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 0, isPrimary = true),
Metric("voltage", "Spannung", totalVoltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 1),
)
val v = totalVoltage
val a = current
if (v != null && a != null) {
metrics.add(Metric("power", "Leistung", v * a, "W", precision = 0))
}
remainingCapacityAh?.let {
metrics.add(Metric("capacity", "Restkapazität", it, "Ah", precision = 1))
}
nominalCapacityAh?.let {
metrics.add(Metric("capacity_nominal", "Nennkapazität", it, "Ah", precision = 1))
}
val maxV = cellMillivolts.maxOrNull()
val minV = cellMillivolts.minOrNull()
if (maxV != null && minV != null) {
metrics.add(Metric("cell_delta", "Zell-Differenz", (maxV - minV).toDouble(), "mV", precision = 0))
metrics.add(Metric("cell_max", "Höchste Zelle", maxV / 1000.0, "V", precision = 3))
metrics.add(Metric("cell_min", "Niedrigste Zelle", minV / 1000.0, "V", precision = 3))
}
temperatures.maxOrNull()?.let {
metrics.add(Metric("temp_max", "Temperatur", it, "°C", precision = 0))
}
cycles?.let {
metrics.add(Metric("cycles", "Ladezyklen", it.toDouble(), "", precision = 0))
}
val notes = mutableListOf<String>()
if (chargeMOSOn == false) notes.add("Laden gesperrt")
if (dischargeMOSOn == false) notes.add("Entladen gesperrt")
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
cellVoltages = cellMillivolts.map { it / 1000.0 },
temperatures = temperatures,
fault = protections.takeIf { it.isNotEmpty() }?.joinToString(", "),
offReasons = notes,
state = current?.let {
when {
it > 0.3 -> "Lädt"
it < -0.3 -> "Entlädt"
else -> "Ruhend"
}
},
)
}
}
@@ -0,0 +1,192 @@
package de.fritob.campermonitor.protocol
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
/**
* Wie der Neigungsmesser im Fahrzeug sitzt.
*
* Der Sensor kann quer eingebaut, gedreht oder kopfüber montiert sein. Dann
* stimmen seine Achsen nicht mit denen des Fahrzeugs überein: was er als
* Längsneigung meldet, ist womöglich die Querneigung, und Vorzeichen können
* vertauscht sein.
*
* Statt das raten zu lassen, ermittelt der Einrichtungsassistent die Lage
* durch zwei definierte Kippbewegungen.
*/
data class SensorOrientation(
/**
* Woher die Längsneigung kommt. Die Querneigung kommt aus der jeweils
* anderen Achse.
*/
val longitudinalSource: Source = Source.PITCH,
val invertLongitudinal: Boolean = false,
val invertLateral: Boolean = false,
/**
* Verdrehung des Sensors um die Hochachse, in Grad der Rest, den der
* Achsentausch nicht abdeckt.
*
* Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
* Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
* Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
* helfen dagegen nicht, die springen in 90°-Schritten.
*/
val twist: Double = 0.0,
) {
/** Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert. */
enum class Source { PITCH, ROLL }
val isIdentity: Boolean get() = this == IDENTITY
/**
* Rechnet Sensorwerte in Fahrzeugwerte um.
*
* Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
* Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
* Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene genau
* deshalb lässt sich die Verdrehung überhaupt herausrechnen.
*/
fun apply(pitch: Double?, roll: Double?): Pair<Double?, Double?> {
val longitudinal = if (longitudinalSource == Source.PITCH) pitch else roll
val lateral = if (longitudinalSource == Source.PITCH) roll else pitch
val mappedLongitudinal = longitudinal?.let { if (invertLongitudinal) -it else it }
val mappedLateral = lateral?.let { if (invertLateral) -it else it }
if (twist == 0.0 || mappedLongitudinal == null || mappedLateral == null) {
return mappedLongitudinal to mappedLateral
}
val angle = Math.toRadians(twist)
return (mappedLongitudinal * cos(angle) + mappedLateral * sin(angle)) to
(-mappedLongitudinal * sin(angle) + mappedLateral * cos(angle))
}
val summary: String
get() {
if (isIdentity) return "Achsen unverändert"
val parts = mutableListOf<String>()
if (longitudinalSource == Source.ROLL) parts.add("Achsen getauscht")
if (invertLongitudinal) parts.add("längs umgekehrt")
if (invertLateral) parts.add("quer umgekehrt")
if (twist != 0.0) parts.add("um %.0f° verdreht".format(twist))
return parts.joinToString(", ")
}
companion object {
val IDENTITY = SensorOrientation()
}
}
/**
* Wertet die Kippbewegungen des Einrichtungsassistenten aus.
*
* Zweimal wird gekippt: einmal die Front nach unten, einmal die linke Seite
* nach unten. Welche Sensorachse sich dabei jeweils bewegt und in welche
* Richtung, ergibt die Einbaulage.
*/
object OrientationDetection {
/**
* So weit muss gekippt werden, damit die Bewegung eindeutig ist.
* Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
*/
const val MINIMUM_TILT = 5.0
/** Soviel deutlicher muss die gewinnende Deutung sein als die andere. */
const val AMBIGUITY_MARGIN = 1.3
/** Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand. */
const val MINIMUM_TWIST = 2.0
data class Reading(val pitch: Double, val roll: Double) {
operator fun minus(other: Reading) = Reading(pitch - other.pitch, roll - other.roll)
}
enum class Failure(val message: String) {
/** Es wurde zu wenig oder gar nicht gekippt. */
TOO_LITTLE_MOVEMENT(
"Zu wenig Bewegung. Deutlicher kippen, mindestens eine Handbreit."
),
/** Beide Schritte haben dieselbe Achse bewegt. */
AMBIGUOUS(
"Beide Schritte haben dieselbe Achse bewegt. Im ersten Schritt nach " +
"vorne kippen, im zweiten zur Seite."
),
}
sealed class Result {
data class Success(val orientation: SensorOrientation) : Result()
data class Error(val failure: Failure) : Result()
}
/**
* Ermittelt aus beiden Kippbewegungen die Einbaulage.
*
* Beide Bewegungen werden gemeinsam beurteilt. Kippt man von Hand zur
* Seite, geht die andere Achse fast immer ein Stück mit jede Bewegung
* für sich betrachtet sähe das nach schrägem Kippen aus. Im Paar ist die
* Zuordnung trotzdem eindeutig: es gewinnt die Deutung, die beide
* Bewegungen zusammen am besten erklärt.
*
* @param nose Änderung beim Kippen der Front nach unten.
* @param side Änderung beim Kippen der linken Seite nach unten.
*/
fun orientation(nose: Reading, side: Reading): Result {
if (maxOf(abs(nose.pitch), abs(nose.roll)) < MINIMUM_TILT ||
maxOf(abs(side.pitch), abs(side.roll)) < MINIMUM_TILT
) {
return Result.Error(Failure.TOO_LITTLE_MOVEMENT)
}
// Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie
// erklären: die erste Achse längs und die zweite quer oder umgekehrt.
val pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll)
val rollIsLongitudinal = abs(nose.roll) + abs(side.pitch)
val winner = maxOf(pitchIsLongitudinal, rollIsLongitudinal)
val loser = minOf(pitchIsLongitudinal, rollIsLongitudinal)
// Liegen beide Deutungen dicht beieinander, ist die Zuordnung wirklich
// nicht zu treffen etwa wenn zweimal um dieselbe Achse gekippt wurde.
if (winner < AMBIGUITY_MARGIN * loser) return Result.Error(Failure.AMBIGUOUS)
val source = if (pitchIsLongitudinal > rollIsLongitudinal) {
SensorOrientation.Source.PITCH
} else {
SensorOrientation.Source.ROLL
}
val longitudinal = if (source == SensorOrientation.Source.PITCH) nose.pitch else nose.roll
val lateral = if (source == SensorOrientation.Source.PITCH) side.roll else side.pitch
// Die zugeordnete Achse muss in ihrem Schritt auch deutlich gewandert
// sein, sonst stünde das Vorzeichen auf wackligem Grund.
if (abs(longitudinal) < MINIMUM_TILT || abs(lateral) < MINIMUM_TILT) {
return Result.Error(Failure.TOO_LITTLE_MOVEMENT)
}
val coarse = SensorOrientation(
longitudinalSource = source,
// Front nach unten heisst: das Heck steht höher, die
// Längsneigung des Fahrzeugs ist also positiv.
invertLongitudinal = longitudinal < 0,
// Linke Seite nach unten heisst: rechts steht höher, die
// Querneigung ist positiv.
invertLateral = lateral < 0,
)
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
// schräg und zwar um genau diesen Winkel.
val (correctedLong, correctedLat) = coarse.apply(nose.pitch, nose.roll)
val residual = if (correctedLong != null && correctedLat != null) {
Math.toDegrees(atan2(correctedLat, correctedLong))
} else {
0.0
}
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
// als keine.
return Result.Success(
coarse.copy(twist = if (abs(residual) >= MINIMUM_TWIST) residual else 0.0)
)
}
}
@@ -0,0 +1,206 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
import kotlin.math.abs
/**
* Neigungsmesser „VanAlign Pro" ein ESP32 mit MPU6050, der Längs- und
* Querneigung des Fahrzeugs über Bluetooth bereitstellt.
*
* Anders als die übrigen Geräte gibt es hier kein Rahmenprotokoll: Jede
* Messgrösse liegt in einer eigenen Charakteristik als 32-Bit-Float.
*/
object VanAlignProtocol {
/** Wird vom Gerät beworben, das Gerät ist darüber auffindbar. */
const val SERVICE_UUID = "2A24B789-7AAB-4535-AF3E-EE76A35CC42D"
const val PITCH_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233424"
const val ROLL_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233425"
/** Zwei Floats: die gespeicherten Kalibrier-Offsets. */
const val OFFSETS_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233426"
/** Ein Byte: 0 setzt zurück, alles andere kalibriert auf die aktuelle Lage. */
const val CALIBRATE_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233427"
/** Acht Byte: die Einbaulage, lesbar und schreibbar. Siehe [orientation]. */
const val ORIENTATION_UUID = "CAD48E28-7FBE-41CF-BAE9-D77A6C233428"
val calibrateCommand = byteArrayOf(0x01)
val resetCommand = byteArrayOf(0x00)
/**
* Liest einen Winkel aus vier Bytes, little-endian.
*
* Die Firmware legt den Float per `memcpy` ab, und der ESP32 ist
* little-endian die Reihenfolge steht also fest. Die Web-Oberfläche des
* Ursprungsprojekts probiert zusätzlich die umgekehrte Reihenfolge, falls
* die erste unplausibel aussieht. Das ist nicht nur unnötig, sondern
* schädlich: ein vertauschter Float von 4,25° ergibt gelesen etwa 0,0 und
* wirkt damit völlig plausibel. Ein Vorzeichen- oder Wertfehler bliebe so
* unbemerkt.
*/
fun angle(data: ByteArray, offset: Int = 0): Double? {
if (data.size < offset + 4) return null
var raw = 0
for (index in 0 until 4) {
raw = raw or (data.u(offset + index) shl (8 * index))
}
val value = Float.fromBits(raw)
// NaN meldet die Firmware, solange der Sensor nichts liefert.
if (!value.isFinite() || abs(value) > 180) return null
return value.toDouble()
}
/**
* Die Einbaulage, wie sie im Gerät liegt.
*
* Acht Byte:
*
* 0 Version, 1 = gültig gesetzt, 0 = nie geschrieben
* 1 Längsachse: 0 = Pitch des Sensors, 1 = Roll des Sensors
* 2 längs umgekehrt (0/1)
* 3 quer umgekehrt (0/1)
* 4..7 Verdrehung um die Hochachse, float32, Grad
*
* Sie gehört ins Gerät, weil sie den Einbau beschreibt und nicht das
* Telefon: iPhone, Uhr und Android sollen dieselbe sehen, ohne sie je
* einzeln zu bestimmen. Gerechnet wird trotzdem in den Apps das Gerät
* verwahrt sie nur, sonst rechnete ein älterer Client die Korrektur ein
* zweites Mal ein.
*
* Version 0 heisst „hier stand noch nie etwas" und ergibt null; dann gilt,
* was die App örtlich gespeichert hat, und sie schreibt es hinauf.
*/
fun orientation(data: ByteArray): SensorOrientation? {
if (data.size < 8 || data.u(0) != 1) return null
val twist = angle(data, 4) ?: return null
return SensorOrientation(
longitudinalSource = if (data.u(1) == 1) {
SensorOrientation.Source.ROLL
} else {
SensorOrientation.Source.PITCH
},
invertLongitudinal = data.u(2) != 0,
invertLateral = data.u(3) != 0,
twist = twist,
)
}
/** Dieselben acht Byte in die andere Richtung. */
fun encoded(orientation: SensorOrientation): ByteArray {
val bytes = ByteArray(8)
bytes[0] = 1
bytes[1] = if (orientation.longitudinalSource == SensorOrientation.Source.ROLL) 1 else 0
bytes[2] = if (orientation.invertLongitudinal) 1 else 0
bytes[3] = if (orientation.invertLateral) 1 else 0
val raw = orientation.twist.toFloat().toRawBits()
for (index in 0 until 4) {
bytes[4 + index] = ((raw shr (8 * index)) and 0xFF).toByte()
}
return bytes
}
/** Die beiden gespeicherten Offsets. */
fun offsets(data: ByteArray): Pair<Double, Double>? {
if (data.size < 8) return null
val pitch = angle(data, 0) ?: return null
val roll = angle(data, 4) ?: return null
return pitch to roll
}
}
/** Zustand des Neigungsmessers. */
data class LevelState(
/**
* Längsneigung: positiv bedeutet, das Heck steht höher als die Front.
* Bereits auf die Einbaulage des Sensors umgerechnet.
*/
val pitch: Double? = null,
/** Querneigung: positiv bedeutet, die rechte Seite steht höher. */
val roll: Double? = null,
/** Wie der Sensor selbst meldet nötig, um die Einbaulage zu bestimmen. */
val rawPitch: Double? = null,
val rawRoll: Double? = null,
val pitchOffset: Double? = null,
val rollOffset: Double? = null,
) {
val hasReading: Boolean get() = pitch != null || roll != null
/**
* Ob kalibriert wurde oder null, wenn das Gerät es nicht verrät.
*
* Nicht jede Firmware stellt die Offsets bereit. Fehlen sie, heisst das
* „unbekannt" und nicht „nicht kalibriert": eine Warnung, die sich nie
* abstellen lässt, ist schlimmer als keine.
*/
val calibrationState: Boolean?
get() {
val p = pitchOffset ?: return null
val r = rollOffset ?: return null
return abs(p) > 0.001 || abs(r) > 0.001
}
/** Nur wahr, wenn es auch belegt ist. */
val isCalibrated: Boolean get() = calibrationState == true
/** Nur wahr, wenn das Gerät ausdrücklich Nulloffsets meldet. */
val isKnownUncalibrated: Boolean get() = calibrationState == false
val isLevel: Boolean
get() {
val p = pitch ?: return false
val r = roll ?: return false
return abs(p) <= LEVEL_TOLERANCE && abs(r) <= LEVEL_TOLERANCE
}
/**
* Die grössere der beiden Abweichungen das ist die, die man zuerst
* ausgleichen will.
*/
val largestDeviation: Double?
get() = when {
pitch != null && roll != null -> maxOf(abs(pitch), abs(roll))
pitch != null -> abs(pitch)
roll != null -> abs(roll)
else -> null
}
/**
* Was zu tun ist, in Worten. Beim Ausrichten schaut man aufs Handy,
* nicht auf Vorzeichen.
*/
val instruction: String?
get() {
val p = pitch ?: return null
val r = roll ?: return null
if (isLevel) return "Steht eben"
val parts = mutableListOf<String>()
if (abs(p) > LEVEL_TOLERANCE) {
parts.add(if (p > 0) "Heck steht höher" else "Front steht höher")
}
if (abs(r) > LEVEL_TOLERANCE) {
parts.add(if (r > 0) "rechts steht höher" else "links steht höher")
}
return parts.joinToString(", ")
}
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot =
DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = listOf(
Metric("pitch", "Längsneigung", pitch, "°", precision = 1, isPrimary = true),
Metric("roll", "Querneigung", roll, "°", precision = 1),
),
state = instruction,
offReasons = if (isKnownUncalibrated) listOf("Nicht kalibriert") else emptyList(),
)
companion object {
/** Bis hierhin gilt das Fahrzeug als eben genug. */
const val LEVEL_TOLERANCE = 0.5
}
}
@@ -0,0 +1,311 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* Dekodiert die "Instant Readout"-Werbedaten von Victron-Geräten.
*
* Aufbau der Herstellerdaten (inkl. der zwei Bytes Company-ID):
* ```
* [0..1] E1 02 Company-ID 0x02E1 (Victron Energy)
* [2..3] 10 00 Record-Typ "Product Advertisement", 16 Bit
* [4..5] ll hh Produkt-ID, little-endian
* [6] rr Art des Datensatzes (Solarlader, DC/DC, …)
* [7..8] ll hh Nonce / Zähler, little-endian
* [9] kk Erstes Byte des Geräteschlüssels (Prüfbyte)
* [10..] Mit AES-128-CTR verschlüsselte Nutzdaten
* ```
*
* Die Aufteilung ist an einem Orion XS belegt: Datensatztyp 0x0F passt zum
* Gerät, das Prüfbyte zum hinterlegten Schlüssel, und die verbleibenden
* 14 Byte entsprechen genau der Länge eines Orion-XS-Datensatzes.
*
* **Achtung, Unterschied zu iOS:** Android liefert die Herstellerdaten ohne
* die zwei Bytes der Company-ID die steht dort im Schlüssel der
* `manufacturerSpecificData`-Tabelle. Die Bluetooth-Schicht setzt sie wieder
* davor, damit hier derselbe Rahmen ankommt wie unter iOS und dieselben
* Prüfungen gelten.
*/
object VictronAdvertisement {
const val COMPANY_IDENTIFIER = 0x02E1
enum class RecordType(val raw: Int) {
SOLAR_CHARGER(0x01),
BATTERY_MONITOR(0x02),
INVERTER(0x03),
DCDC_CONVERTER(0x04),
SMART_LITHIUM(0x05),
INVERTER_RS(0x06),
GX_DEVICE(0x07),
AC_CHARGER(0x08),
SMART_BATTERY_PROTECT(0x09),
LYNX_SMART_BMS(0x0A),
MULTI_RS(0x0B),
VE_BUS(0x0C),
DC_ENERGY_METER(0x0D),
ORION_XS(0x0F);
companion object {
fun of(raw: Int): RecordType? = entries.firstOrNull { it.raw == raw }
}
}
sealed class DecodeError(val describe: String) : Exception(describe) {
data object NotVictron : DecodeError("Kein Victron-Advertisement")
data object Malformed : DecodeError("Advertisement zu kurz")
class KeyMismatch(val expected: Int, val got: Int) : DecodeError(
"Schlüssel passt nicht: Das Gerät sendet 0x%02X als erstes Byte, ".format(expected) +
"der eingetragene Schlüssel beginnt mit 0x%02X.".format(got)
)
data object DecryptionFailed : DecodeError("Entschlüsselung fehlgeschlagen")
class UnsupportedRecord(val record: Int) :
DecodeError("Datensatz-Typ 0x%02X wird nicht unterstützt".format(record))
}
/**
* Der unverschlüsselte Rahmen lässt sich auch ohne Schlüssel lesen und
* wird beim Einrichten benutzt, um Victron-Geräte zu erkennen.
*/
data class Envelope(
val productID: Int,
val recordType: Int,
val nonce: Int,
val keyCheckByte: Int,
val ciphertext: ByteArray,
) {
val knownRecord: RecordType? get() = RecordType.of(recordType)
val productIDText: String get() = "0x%04X".format(productID)
// ByteArray hat keine sinnvolle Gleichheit; die data class braucht sie.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Envelope) return false
return productID == other.productID && recordType == other.recordType &&
nonce == other.nonce && keyCheckByte == other.keyCheckByte &&
ciphertext.contentEquals(other.ciphertext)
}
override fun hashCode(): Int {
var result = productID
result = 31 * result + recordType
result = 31 * result + nonce
result = 31 * result + keyCheckByte
result = 31 * result + ciphertext.contentHashCode()
return result
}
}
fun envelope(manufacturerData: ByteArray): Envelope? {
if (manufacturerData.size < 11) return null
val company = manufacturerData.u(0) or (manufacturerData.u(1) shl 8)
if (company != COMPANY_IDENTIFIER || manufacturerData.u(2) != 0x10) return null
return Envelope(
productID = manufacturerData.u(4) or (manufacturerData.u(5) shl 8),
recordType = manufacturerData.u(6),
nonce = manufacturerData.u(7) or (manufacturerData.u(8) shl 8),
keyCheckByte = manufacturerData.u(9),
ciphertext = manufacturerData.copyOfRange(10, manufacturerData.size),
)
}
/** Entschlüsselt und interpretiert ein Advertisement. */
fun decode(
manufacturerData: ByteArray,
key: ByteArray,
deviceID: UUID,
rssi: Int?,
now: Long = System.currentTimeMillis(),
): DeviceSnapshot {
val envelope = envelope(manufacturerData) ?: throw DecodeError.NotVictron
if (key.size != 16) throw DecodeError.Malformed
if (key.u(0) != envelope.keyCheckByte) {
throw DecodeError.KeyMismatch(expected = envelope.keyCheckByte, got = key.u(0))
}
// Zählerblock: Nonce little-endian in den ersten zwei Bytes, Rest null.
val counter = ByteArray(16)
counter[0] = (envelope.nonce and 0xFF).toByte()
counter[1] = (envelope.nonce shr 8).toByte()
val plain = AesCounterMode.crypt(envelope.ciphertext, key, counter)
?: throw DecodeError.DecryptionFailed
val base = DeviceSnapshot(deviceID = deviceID, timestamp = now, rssi = rssi)
return when (envelope.knownRecord) {
RecordType.SOLAR_CHARGER -> solarCharger(plain, base)
RecordType.DCDC_CONVERTER -> dcdcConverter(plain, base)
RecordType.ORION_XS -> orionXS(plain, base)
RecordType.BATTERY_MONITOR -> batteryMonitor(plain, base)
RecordType.AC_CHARGER -> acCharger(plain, base)
else -> throw DecodeError.UnsupportedRecord(envelope.recordType)
}
}
// MARK: - Datensätze
/** 0x01 Solarladeregler (SmartSolar / BlueSolar MPPT). */
private fun solarCharger(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val batteryVoltage = r.scaledSigned(16, 0.01)
val batteryCurrent = r.scaledSigned(16, 0.1)
val yieldToday = r.scaled(16, 0.01)
val pvPower = r.scaled(16, 1.0)
val loadCurrent = r.scaled(9, 0.1)
val metrics = mutableListOf(
Metric("pv_power", "PV-Leistung", pvPower, "W", precision = 0, isPrimary = true),
Metric("battery_voltage", "Batteriespannung", batteryVoltage, "V", precision = 2),
Metric("battery_current", "Ladestrom", batteryCurrent, "A", precision = 1),
Metric("yield_today", "Ertrag heute", yieldToday, "kWh", precision = 2),
Metric("load_current", "Laststrom", loadCurrent, "A", precision = 1),
)
if (batteryVoltage != null && batteryCurrent != null) {
metrics.add(
1,
Metric("battery_power", "Ladeleistung", batteryVoltage * batteryCurrent, "W", precision = 0)
)
}
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
metrics = metrics,
)
}
/** 0x04 DC/DC-Wandler (Orion-TR Smart Ladebooster). */
private fun dcdcConverter(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val inputVoltage = r.scaled(16, 0.01)
val outputVoltage = r.scaledSigned(16, 0.01)
val offReason = r.readOptional(32)
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
offReasons = VictronCodes.offReasons(offReason),
metrics = listOf(
Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, "V",
precision = 2, isPrimary = true),
Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, "V", precision = 2),
),
)
}
/** 0x0F Orion XS. Sendet im Gegensatz zum Orion-TR auch Ströme. */
private fun orionXS(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val outputVoltage = r.scaled(16, 0.01)
val outputCurrent = r.scaledSigned(16, 0.1)
val inputVoltage = r.scaled(16, 0.01)
val inputCurrent = r.scaledSigned(16, 0.1)
val offReason = r.readOptional(32)
val metrics = mutableListOf<Metric>()
if (outputVoltage != null && outputCurrent != null) {
metrics.add(
Metric("output_power", "Ladeleistung", outputVoltage * outputCurrent, "W",
precision = 0, isPrimary = true)
)
}
metrics += listOf(
Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, "V",
precision = 2, isPrimary = outputCurrent == null),
Metric("output_current", "Ladestrom", outputCurrent, "A", precision = 1),
Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, "V", precision = 2),
Metric("input_current", "Eingangsstrom", inputCurrent, "A", precision = 1),
)
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
offReasons = VictronCodes.offReasons(offReason),
metrics = metrics,
)
}
/** 0x08 AC-Ladegerät (Blue Smart IP65/IP22), falls im Camper verbaut. */
private fun acCharger(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val voltage1 = r.scaled(13, 0.01)
val current1 = r.scaled(11, 0.1)
val voltage2 = r.scaled(13, 0.01)
val current2 = r.scaled(11, 0.1)
val voltage3 = r.scaled(13, 0.01)
val current3 = r.scaled(11, 0.1)
val temperature = r.scaled(7, 1.0)
val acCurrent = r.scaled(9, 0.1)
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
metrics = listOf(
Metric("out1_voltage", "Ausgang 1 Spannung", voltage1, "V", precision = 2, isPrimary = true),
Metric("out1_current", "Ausgang 1 Strom", current1, "A", precision = 1),
Metric("out2_voltage", "Ausgang 2 Spannung", voltage2, "V", precision = 2),
Metric("out2_current", "Ausgang 2 Strom", current2, "A", precision = 1),
Metric("out3_voltage", "Ausgang 3 Spannung", voltage3, "V", precision = 2),
Metric("out3_current", "Ausgang 3 Strom", current3, "A", precision = 1),
Metric("ac_current", "AC-Eingangsstrom", acCurrent, "A", precision = 1),
),
temperatures = temperature?.let { listOf(it - 40) } ?: emptyList(),
)
}
/** 0x02 Batteriewächter (SmartShunt, BMV). */
private fun batteryMonitor(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val timeToGo = r.scaled(16, 1.0) // Minuten
val voltage = r.scaledSigned(16, 0.01)
val alarm = r.readOptional(16)
val auxRaw = r.read(16)
val auxType = r.read(2)
val current = r.scaledSigned(22, 0.001)
val consumedAh = r.scaled(20, 0.1)
val soc = r.scaled(10, 0.1)
val alarms = VictronCodes.alarmReasons(alarm)
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 1, isPrimary = true),
Metric("voltage", "Spannung", voltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 2),
)
if (voltage != null && current != null) {
metrics.add(Metric("power", "Leistung", voltage * current, "W", precision = 0))
}
// Entnommene Kapazität wird positiv gesendet, ist aber eine Entnahme.
metrics.add(Metric("consumed", "Entnommen", consumedAh?.let { -it }, "Ah", precision = 1))
if (timeToGo != null && timeToGo < 65535) {
metrics.add(Metric("ttg", "Restlaufzeit", timeToGo / 60, "h", precision = 1))
}
// Der Hilfseingang ist je nach Konfiguration Starterbatterie,
// Mittenspannung oder Temperatur.
var temperatures = emptyList<Double>()
if (auxRaw != null && auxType != null && auxRaw != 0xFFFFL) {
when (auxType.toInt()) {
0 -> {
val starter = auxRaw.toShort().toInt() * 0.01
metrics.add(Metric("aux_starter", "Starterbatterie", starter, "V", precision = 2))
}
1 -> metrics.add(Metric("aux_mid", "Mittenspannung", auxRaw * 0.01, "V", precision = 2))
2 -> temperatures = listOf(auxRaw * 0.01 - 273.15)
}
}
return base.copy(
state = null,
fault = if (alarms.isEmpty()) null else alarms.joinToString(", "),
metrics = metrics,
temperatures = temperatures,
)
}
}
@@ -0,0 +1,104 @@
package de.fritob.campermonitor.protocol
/** Klartexte für die Zustands- und Fehlercodes aus den Victron-Werbedaten. */
object VictronCodes {
/** VE.Reg 0x0201 Betriebszustand des Laders. */
fun deviceState(code: Long?): String? {
if (code == null) return null
return when (code.toInt()) {
0 -> "Aus"
1 -> "Stromsparmodus"
2 -> "Störung"
3 -> "Konstantstrom (Bulk)"
4 -> "Konstantspannung (Absorption)"
5 -> "Erhaltung (Float)"
6 -> "Lagerung"
7 -> "Ausgleichsladung"
9 -> "Wechselrichten"
11 -> "Netzteilbetrieb"
245 -> "Startet"
246 -> "Wiederholte Absorption"
247 -> "Auto-Ausgleich"
248 -> "Battery Safe"
252 -> "Externe Steuerung"
else -> "Zustand $code"
}
}
/** VE.Reg 0xEDDA Ladefehler. 0 bedeutet "kein Fehler". */
fun chargerError(code: Long?): String? {
if (code == null || code == 0L) return null
return when (code.toInt()) {
1 -> "Batterietemperatur zu hoch"
2 -> "Batteriespannung zu hoch"
3 -> "Temperatursensor defekt"
4 -> "Temperatursensor Kurzschluss"
5 -> "Temperatursensor unplausibel"
6 -> "Spannungsmessung defekt"
7 -> "Spannungsmessung Kurzschluss"
8 -> "Spannungsmessung unplausibel"
11 -> "Zu hohe Restwelligkeit"
14 -> "Batterietemperatur zu niedrig"
17 -> "Lader überhitzt"
18 -> "Lader Überstrom"
19 -> "Stromrichtung verkehrt"
20 -> "Bulk-Zeit überschritten"
21 -> "Stromsensor defekt"
22 -> "Interner Temperatursensor defekt"
26 -> "Anschlussklemme überhitzt"
27 -> "Kurzschluss im Lader"
28 -> "Endstufenfehler"
29 -> "Überladeschutz"
33 -> "Eingangsspannung zu hoch (PV)"
34 -> "Eingangsstrom zu hoch (PV)"
38 -> "Eingang abgeschaltet (Batteriespannung)"
39 -> "Eingang abgeschaltet (Stromfluss)"
65 -> "Kommunikation verloren"
66 -> "Konfiguration synchronisierter Lader fehlerhaft"
67 -> "BMS-Verbindung verloren"
68 -> "Netzwerk fehlkonfiguriert"
116 -> "Kalibrierdaten verloren"
117 -> "Inkompatible Firmware"
119 -> "Einstellungen ungültig"
else -> "Fehler $code"
}
}
/** VE.Reg 0x0207 Bitmaske, warum das Gerät gerade nicht arbeitet. */
fun offReasons(mask: Long?): List<String> {
if (mask == null || mask == 0L) return emptyList()
return OFF_REASONS.filter { mask and it.first != 0L }.map { it.second }
}
private val OFF_REASONS = listOf(
0x0000_0001L to "Keine Eingangsspannung",
0x0000_0002L to "Per Schalter ausgeschaltet",
0x0000_0004L to "Per Einstellung ausgeschaltet",
0x0000_0008L to "Remote-Eingang",
0x0000_0010L to "Schutzfunktion aktiv",
0x0000_0020L to "Paygo",
0x0000_0040L to "BMS",
0x0000_0080L to "Motor-Abschalterkennung",
0x0000_0100L to "Eingangsspannung wird geprüft",
)
/** VE.Reg 0xEEB8 Alarmgründe des Batteriewächters. */
fun alarmReasons(mask: Long?): List<String> {
if (mask == null || mask == 0L) return emptyList()
return ALARM_REASONS.filter { mask and it.first != 0L }.map { it.second }
}
private val ALARM_REASONS = listOf(
0x0001L to "Unterspannung",
0x0002L to "Überspannung",
0x0004L to "Niedriger Ladezustand",
0x0008L to "Starterbatterie Unterspannung",
0x0010L to "Starterbatterie Überspannung",
0x0020L to "Temperatur zu niedrig",
0x0040L to "Temperatur zu hoch",
0x0080L to "Mittenspannung",
0x0100L to "Ladung überfällig",
)
}
@@ -0,0 +1,281 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* 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).
*/
object WattCycleProtocol {
const val FRAME_HEAD_REQUEST = 0x1E
const val FRAME_HEAD_RESPONSE = 0x7E
const val FRAME_TAIL = 0x0D
const val FUNCTION_READ = 0x03
const val FUNCTION_ERROR = 0x86
/** Der Freischalt-Text, der vor der ersten Abfrage geschrieben wird. */
val authPayload: ByteArray = "HiLink".toByteArray(Charsets.US_ASCII)
enum class Datapoint(val raw: Int) {
ANALOG(0x008C), // Messwerte
PRODUCT(0x0092); // Modell, Hersteller, Seriennummer
companion object {
fun of(raw: Int): Datapoint? = entries.firstOrNull { it.raw == raw }
}
}
fun requestFrame(datapoint: Datapoint): ByteArray {
val frame = byteArrayOf(
FRAME_HEAD_REQUEST.toByte(),
0x00, // Version
0x01, // Adresse
FUNCTION_READ.toByte(),
(datapoint.raw shr 8).toByte(),
(datapoint.raw and 0xFF).toByte(),
0x00, 0x00, // Anzahl: 0 liefert den ganzen Datensatz
)
val crc = DalyProtocol.crc16Modbus(frame)
return frame + byteArrayOf((crc shr 8).toByte(), (crc and 0xFF).toByte(), FRAME_TAIL.toByte())
}
class Frame(val function: Int, val datapoint: Int, val payload: ByteArray) {
val isError: Boolean get() = function == FUNCTION_ERROR
}
/** Sucht vollständige, prüfsummenkorrekte Antwortrahmen im Puffer. */
fun extractFrames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var consumed = 0
while (index + 11 <= buffer.size) {
if (buffer.u(index) != FRAME_HEAD_RESPONSE) {
index += 1
continue
}
val dataLength = (buffer.u(index + 6) shl 8) or buffer.u(index + 7)
val total = dataLength + 11
if (total > 512) {
index += 1
continue
}
if (index + total > buffer.size) break // Rest abwarten
val frame = buffer.copyOfRange(index, index + total)
if (frame.u(total - 1) != FRAME_TAIL) {
index += 1
continue
}
val expected = DalyProtocol.crc16Modbus(frame.copyOfRange(0, total - 3))
val actual = (frame.u(total - 3) shl 8) or frame.u(total - 2)
if (expected != actual) {
index += 1
continue
}
frames.add(
Frame(
function = frame.u(3),
datapoint = (frame.u(4) shl 8) or frame.u(5),
payload = frame.copyOfRange(8, 8 + dataLength),
)
)
index += total
consumed = index
}
val keepFrom = maxOf(consumed, maxOf(0, buffer.size - 256))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
/** Temperaturen kommen in Zehntel-Kelvin. */
fun temperature(raw: Int): Double = (raw - 2730) / 10.0
/**
* 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.
*/
fun current(high: Int, low: Int): Double {
val isNegative = high and 0x80 != 0
val hasDecimal = high and 0x40 != 0
val magnitude = (low or ((high and 0x3F) shl 8)).toDouble()
val value = if (hasDecimal) magnitude / 10 else magnitude
return if (isNegative) -value else value
}
}
/** Sammelt die Antworten eines WattCycle-Akkus. */
class WattCycleState {
var cellVolts: List<Double> = emptyList()
var mosTemperature: Double? = null
var pcbTemperature: Double? = null
var cellTemperatures: List<Double> = emptyList()
var current: Double? = null
var voltage: Double? = null
var remainingAh: Double? = null
var totalAh: Double? = null
var designAh: Double? = null
var cycles: Int? = null
var soc: Double? = null
var model: String? = null
var manufacturer: String? = null
var serial: String? = null
val hasUsableData: Boolean
get() = voltage != null || soc != null || cellVolts.isNotEmpty()
val hasProductInfo: Boolean
get() = model != null || manufacturer != null || serial != null
fun apply(frame: WattCycleProtocol.Frame) {
if (frame.isError) return
when (WattCycleProtocol.Datapoint.of(frame.datapoint)) {
WattCycleProtocol.Datapoint.ANALOG -> applyAnalog(frame.payload)
WattCycleProtocol.Datapoint.PRODUCT -> applyProduct(frame.payload)
null -> Unit
}
}
/**
* 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 fun applyAnalog(data: ByteArray) {
var offset = 0
fun readUInt16(): Int? {
if (offset + 1 >= data.size) return null
val value = (data.u(offset) shl 8) or data.u(offset + 1)
offset += 2
return value
}
fun readUInt8(): Int? {
if (offset >= data.size) return null
return data.u(offset).also { offset += 1 }
}
val cellCount = readUInt8() ?: return
val cells = mutableListOf<Double>()
repeat(cellCount) {
val millivolts = readUInt16() ?: return
cells.add(millivolts / 1000.0)
}
cellVolts = cells
// Die ersten beiden Fühler sind MOSFET und Platine, danach die Zellen.
val temperatureCount = readUInt8() ?: return
if (temperatureCount < 2) return
val mos = readUInt16() ?: return
val pcb = readUInt16() ?: return
mosTemperature = WattCycleProtocol.temperature(mos)
pcbTemperature = WattCycleProtocol.temperature(pcb)
val probes = mutableListOf<Double>()
repeat(temperatureCount - 2) {
val raw = readUInt16() ?: return
probes.add(WattCycleProtocol.temperature(raw))
}
cellTemperatures = probes
if (offset + 1 >= data.size) return
current = WattCycleProtocol.current(data.u(offset), data.u(offset + 1))
offset += 2
val voltageRaw = readUInt16() ?: return
voltage = voltageRaw / 100.0
val remaining = readUInt16() ?: return
val total = readUInt16() ?: return
val cycleCount = readUInt16() ?: return
val design = readUInt16() ?: return
val charge = readUInt16() ?: return
remainingAh = remaining / 10.0
totalAh = total / 10.0
cycles = cycleCount
designAh = design / 10.0
soc = charge.toDouble()
}
/** Drei ASCII-Felder à 20 Byte. */
private fun applyProduct(data: ByteArray) {
if (data.size < 60) return
fun text(from: Int, until: Int): String? =
String(data, from, until - from, Charsets.UTF_8)
.trim { it == '\u0000' || it == ' ' }
.takeIf { it.isNotEmpty() }
model = text(0, 20)
manufacturer = text(20, 40)
serial = text(40, 60)
}
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 0, isPrimary = true),
Metric("voltage", "Spannung", voltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 1),
)
val v = voltage
val a = current
if (v != null && a != null) {
metrics.add(Metric("power", "Leistung", v * a, "W", precision = 0))
}
metrics.add(Metric("capacity", "Restkapazität", remainingAh, "Ah", precision = 1))
totalAh?.let { metrics.add(Metric("capacity_total", "Kapazität geladen", it, "Ah", precision = 1)) }
designAh?.let { metrics.add(Metric("capacity_design", "Nennkapazität", it, "Ah", precision = 1)) }
val maxV = cellVolts.maxOrNull()
val minV = cellVolts.minOrNull()
if (maxV != null && minV != null) {
metrics.add(Metric("cell_delta", "Zell-Differenz", (maxV - minV) * 1000, "mV", precision = 0))
metrics.add(Metric("cell_max", "Höchste Zelle", maxV, "V", precision = 3))
metrics.add(Metric("cell_min", "Niedrigste Zelle", minV, "V", precision = 3))
}
mosTemperature?.let { metrics.add(Metric("temp_mos", "Temperatur MOSFET", it, "°C", precision = 1)) }
pcbTemperature?.let { metrics.add(Metric("temp_pcb", "Temperatur Platine", it, "°C", precision = 1)) }
cycles?.let { metrics.add(Metric("cycles", "Ladezyklen", it.toDouble(), "", precision = 0)) }
val info = mutableListOf<InfoItem>()
model?.let { info.add(InfoItem("Modell / Firmware", it)) }
manufacturer?.let { info.add(InfoItem("Hersteller", it)) }
serial?.let { info.add(InfoItem("Seriennummer", it)) }
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
cellVoltages = cellVolts,
temperatures = cellTemperatures,
info = info,
state = current?.let {
when {
it > 0.3 -> "Lädt"
it < -0.3 -> "Entlädt"
else -> "Ruhend"
}
},
)
}
}
@@ -0,0 +1,154 @@
package de.fritob.campermonitor.protocol
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Baut eine Antwort so, wie die Box sie schickt. */
private fun alpicoolResponse(command: Int, payload: ByteArray): ByteArray {
val head = byteArrayOf(0xFE.toByte(), 0xFE.toByte(), (payload.size + 3).toByte(), command.toByte()) + payload
val sum = AlpicoolProtocol.checksum(head)
return head + byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte())
}
private fun stateFrom(payload: ByteArray): AlpicoolState {
val state = AlpicoolState()
val (frames, _) = AlpicoolProtocol.extractFrames(alpicoolResponse(0x01, payload))
frames.forEach { state.apply(it) }
return state
}
class AlpicoolProtocolTest {
/**
* Anmeldung und Abfrage sind in der Referenzimplementierung fest verdrahtet.
* Unsere gerechneten Pakete müssen genau dasselbe ergeben.
*/
@Test
fun `Anmeldung und Abfrage stimmen mit der Referenz ueberein`() {
assertEquals(
"fefe03000 1ff".replace(" ", ""),
AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND).hex()
)
assertEquals(
"fefe030102 00".replace(" ", ""),
AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY).hex()
)
}
/**
* Ein echtes Paket der Maentum/Plug-in Festival IceCube Dual, mitgeschnitten
* beim Ausschalten (Gruni22/alpicool_ha_ble#20). Es muss byteweise
* herauskommen und in zwei Schreibvorgänge zerfallen, weil die Box
* längere nicht annimmt. Genau daran scheiterte das Ein- und Ausschalten.
*/
@Test
fun `Ausschaltbefehl einer echten Zweizonen-Box stimmt byteweise`() {
val payload = byteArrayOf(
0x00, // Bedienfeld frei
0x01, // eingeschaltet
0x01, // Eco
0x02, // Batteriewächter hoch
0x14, // Soll links 20
0x14, 0xEC.toByte(), // Grenzen 20 / -20
0x02, 0x00, 0x00, // Rückschaltdifferenz, Verzögerung, Celsius
0xFD.toByte(), 0xFD.toByte(), 0xFD.toByte(), 0x00,
0x0A, // Ist links 10
0x57, // 87 %
0x0C, 0x06, // 12,6 V
0x14, // Soll rechts 20
0x00, 0x00,
0x02, // Rückschaltdifferenz rechts
0xFD.toByte(), 0xFD.toByte(), 0xFD.toByte(), 0x00,
0x0A, // Ist rechts 10
0x01, // Kompressor läuft
)
val state = stateFrom(payload)
assertTrue(state.isDualZone, "zwei echte Fühler heisst zwei Zonen")
val expected = hexBytes(
"FE FE 1C 02 00 00 01 02 14 14 EC 02 00 00 FD FD FD 00 " +
"14 00 00 02 FD FD FD 00 00 00 00 09 37"
)
assertContentEquals(expected, state.settingsCommand(poweredOn = false))
assertEquals(
listOf(20, 11),
AlpicoolProtocol.chunks(expected, AlpicoolProtocol.MAX_WRITE_SIZE).map { it.size },
"er passt nicht in einen Schreibvorgang",
)
}
/**
* Die Box aus dem Fahrzeug: 30 Byte Nutzlast, aber nur ein Fühler. Sie füllt
* den zweiten Block mit Nullen auf und meldet für den fehlenden zweiten
* Fühler -128. Der Stellbefehl muss trotzdem der kurze sein der lange
* wurde von ihr wortlos verworfen.
*/
@Test
fun `fehlender zweiter Fuehler heisst kurzer Stellbefehl`() {
val payload = byteArrayOf(
0x00, 0x01, 0x01, 0x02,
0x09, // Soll 9
0x14, 0xEC.toByte(), // Grenzen 20 / -20
0x02, 0x00, 0x00,
0x00, 0x00, 0xFD.toByte(), 0x00, // Kompressordrehzahlen
0x17, // Ist 23
0x64, // 100 %
0x0E, 0x03, // 14,3 V
0x00, 0x00, 0x00, 0x00, // rechte Zone: nur Füllung
0x00, 0x00, 0x00, 0x00,
0x80.toByte(), // kein zweiter Fühler
0x00, // Kompressor aus
0x01, 0x00, // was darüber hinaus kommt
)
val state = stateFrom(payload)
assertEquals(23, state.leftCurrent, "30 Byte Nutzlast werden gelesen")
assertEquals(100, state.batteryPercent)
assertEquals(14.3, state.batteryVolts!!, 0.001)
assertFalse(state.isDualZone, "der Platzhalter -128 ist kein Messwert")
assertEquals(20, state.settingsCommand(poweredOn = false)?.size, "der kurze Stellbefehl")
assertEquals(
1,
AlpicoolProtocol.chunks(state.settingsCommand(poweredOn = false)!!,
AlpicoolProtocol.MAX_WRITE_SIZE).size,
"und der passt in einen Schreibvorgang",
)
// Von Hand lässt sich die Erkennung übersteuern, falls sie danebenliegt.
state.zoneMode = FridgeZoneMode.DUAL
assertEquals(31, state.settingsCommand(poweredOn = false)?.size)
}
@Test
fun `ohne Status gibt es keinen Stellbefehl`() {
assertNull(AlpicoolState().settingsCommand(poweredOn = true))
}
@Test
fun `die rechte Zone hat ein eigenes Kommando`() {
val packet = AlpicoolState.setTarget(FridgeZone.RIGHT, -5)
assertEquals(AlpicoolProtocol.Command.SET_RIGHT.raw, packet.u(3))
assertEquals(0xFB, packet.u(4), "negativ als vorzeichenloses Byte")
}
@Test
fun `Messwerte gelten nicht als Wirkung eines Stellbefehls`() {
val payload = ByteArray(30).also {
it[1] = 1; it[4] = 9; it[14] = 23; it[15] = 100; it[16] = 14; it[17] = 3
it[26] = 0x80.toByte()
}
val before = stateFrom(payload).settingsFingerprint
val drifted = payload.copyOf().also { it[14] = 8; it[16] = 11 }
assertEquals(before, stateFrom(drifted).settingsFingerprint,
"schwankende Temperatur und Spannung zählen nicht")
val switched = payload.copyOf().also { it[1] = 0 }
assertTrue(stateFrom(switched).settingsFingerprint != before,
"ein geänderter Schalter zählt")
}
}
@@ -0,0 +1,279 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
private fun DeviceSnapshot.value(key: String): Double? =
metrics.firstOrNull { it.key == key }?.value
class DalyClassicTest {
// 13,42 V · 25,0 A Ladung · 87,5 %
// 30000 + 250 = 30250 = 0x765A -> +25,0 A ; SOC 875 = 0x036B -> 87,5 %
private val socPayload = hexBytes("00 86 00 86 76 2A 03 6B")
private fun stream(): ByteArray =
dalyResponse(0x90, socPayload) +
dalyResponse(0x91, hexBytes("0D 12 03 0C FE 07 00 00")) +
dalyResponse(0x92, hexBytes("40 01 3E 02 00 00 00 00")) +
dalyResponse(0x95, hexBytes("01 0C FE 0D 00 0D 12 00"))
@Test
fun `Anfragerahmen ist 13 Byte lang mit korrekter Pruefsumme`() {
val request = DalyProtocol.requestFrame(DalyProtocol.Command.SOC)
assertEquals(13, request.size)
assertEquals(0xBD, request.u(12))
}
@Test
fun `wertet alle Rahmen eines Antwortstroms aus`() {
val (frames, remainder) = DalyProtocol.extractA5Frames(stream())
assertEquals(4, frames.size)
assertEquals(0, remainder.size, "nichts bleibt übrig")
val state = DalyState()
frames.forEach { state.apply(it) }
val bms = state.snapshot(UUID.randomUUID(), null)
assertEquals(13.4, bms.value("voltage")?.let(::round2))
assertEquals(25.0, bms.value("current")?.let(::round1), "Strom mit Offset 30000")
assertEquals(87.5, bms.value("soc")?.let(::round1))
assertEquals(20.0, bms.value("cell_delta"), "Zell-Differenz in mV")
assertEquals(3.346, bms.value("cell_max"))
assertEquals(24.0, bms.value("temp_max"))
assertEquals("Lädt", bms.state, "Zustand aus dem Strom abgeleitet")
assertEquals(3, bms.cellVoltages.size, "drei Zellspannungen aus Rahmen 0x95")
}
@Test
fun `hebt einen angefangenen Rahmen auf`() {
val partial = stream().copyOfRange(0, 13 + 6)
val (frames, rest) = DalyProtocol.extractA5Frames(partial)
assertEquals(1, frames.size, "der vollständige Rahmen wird ausgewertet")
assertTrue(rest.size >= 6, "der angefangene bleibt im Puffer")
}
@Test
fun `verwirft eine falsche Pruefsumme`() {
val corrupted = dalyResponse(0x90, socPayload)
corrupted[12] = (corrupted[12].toInt() xor 0xFF).toByte()
assertEquals(0, DalyProtocol.extractA5Frames(corrupted).first.size)
}
}
class DalyModbusTest {
private fun response(): ByteArray {
val registers = IntArray(62)
registers[0] = 3320; registers[1] = 3325; registers[2] = 3318; registers[3] = 3330
registers[48] = 64 // 24 °C
registers[56] = 133 // 13,3 V
registers[57] = 29750 // -25,0 A
registers[58] = 642 // 64,2 %
var body = byteArrayOf(0xD2.toByte(), 0x03, (registers.size * 2).toByte())
for (register in registers) {
body += byteArrayOf((register shr 8).toByte(), (register and 0xFF).toByte())
}
val crc = DalyProtocol.crc16Modbus(body)
return body + byteArrayOf((crc and 0xFF).toByte(), (crc shr 8).toByte())
}
@Test
fun `Anfrage beginnt korrekt`() {
val request = DalyProtocol.modbusReadFrame()
assertContentEquals(hexBytes("D2 03 00 00 00 3E"), request.copyOfRange(0, 6))
}
@Test
fun `wertet eine Registerantwort aus`() {
val parsed = DalyProtocol.parseModbusResponse(response())
assertEquals(62, parsed?.size)
val state = DalyState()
state.apply(parsed!!)
val snapshot = state.snapshot(UUID.randomUUID(), null)
assertEquals(4, snapshot.cellVoltages.size, "nur belegte Zellen zählen")
assertEquals(13.3, snapshot.value("voltage")?.let(::round1))
assertEquals(-25.0, snapshot.value("current")?.let(::round1), "Entladestrom ist negativ")
assertEquals(64.2, snapshot.value("soc")?.let(::round1))
assertEquals(12.0, snapshot.value("cell_delta"))
assertEquals("Entlädt", snapshot.state)
}
@Test
fun `weist unvollstaendige und verfaelschte Antworten ab`() {
val full = response()
assertNull(
DalyProtocol.parseModbusResponse(full.copyOfRange(0, full.size - 30)),
"unvollständig",
)
val badCRC = full.copyOf()
badCRC[badCRC.size - 1] = (badCRC[badCRC.size - 1].toInt() xor 0xFF).toByte()
assertNull(DalyProtocol.parseModbusResponse(badCRC), "falscher CRC")
}
}
class JbdTest {
private fun jbdResponse(command: Int, payload: ByteArray): ByteArray {
val body = byteArrayOf(0x00, payload.size.toByte()) + payload
val sum = JbdProtocol.checksum(body)
return byteArrayOf(0xDD.toByte(), command.toByte()) + body +
byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte(), 0x77)
}
// 13,25 V · -12,80 A (Entladung) · 88 % · 4 Zellen · 1 Fühler bei 23,8 °C
private val basic = hexBytes(
"05 2D" + // 1325 -> 13,25 V
"FB 00" + // -1280 -> -12,80 A
"44 C0" + // 17600 -> 176,00 Ah Rest
"4E 20" + // 20000 -> 200,00 Ah nominal
"00 2A" + // 42 Zyklen
"00 00" + // Produktionsdatum
"00 00 00 00" +// Balancer
"00 00" + // keine Schutzabschaltung
"16" + // Softwareversion
"58" + // 88 %
"03" + // beide MOSFET an
"04" + // 4 Zellen
"01" + // 1 Fühler
"0B 99" // 2969 -> (2969-2731)/10 = 23,8 °C
)
@Test
fun `baut die Anfragerahmen`() {
assertContentEquals(
hexBytes("DD A5 03 00 FF FD 77"),
JbdProtocol.requestFrame(JbdProtocol.Command.BASIC_INFO),
)
assertContentEquals(
hexBytes("DD A5 04 00 FF FC 77"),
JbdProtocol.requestFrame(JbdProtocol.Command.CELL_VOLTAGES),
)
}
@Test
fun `wertet Basisinfo und Zellspannungen aus`() {
val stream = jbdResponse(0x03, basic) +
jbdResponse(0x04, hexBytes("0C FE 0D 12 0D 00 0C F8"))
val (frames, rest) = JbdProtocol.extractFrames(stream)
assertEquals(2, frames.size)
assertEquals(0, rest.size, "nichts bleibt übrig")
val state = JbdState()
frames.forEach { state.apply(it) }
val snapshot = state.snapshot(UUID.randomUUID(), null)
assertEquals(13.25, snapshot.value("voltage")?.let(::round2))
assertEquals(-12.80, snapshot.value("current")?.let(::round2), "Entladestrom ist negativ")
assertEquals(88.0, snapshot.value("soc"))
assertEquals(176.0, snapshot.value("capacity")?.let(::round2))
assertEquals(200.0, snapshot.value("capacity_nominal")?.let(::round2))
assertEquals(42.0, snapshot.value("cycles"))
assertEquals(23.8, snapshot.temperatures.firstOrNull()?.let(::round2),
"Temperatur aus Zehntel-Kelvin")
assertEquals(4, snapshot.cellVoltages.size)
assertEquals(3.346, snapshot.value("cell_max"))
assertEquals(26.0, snapshot.value("cell_delta"))
}
}
class WattCycleTest {
private fun frame(datapoint: Int, payload: ByteArray): ByteArray {
val head = byteArrayOf(
0x7E, 0x00, 0x01, 0x03,
(datapoint shr 8).toByte(), (datapoint and 0xFF).toByte(),
(payload.size shr 8).toByte(), (payload.size and 0xFF).toByte(),
) + payload
val crc = DalyProtocol.crc16Modbus(head)
return head + byteArrayOf((crc shr 8).toByte(), (crc and 0xFF).toByte(), 0x0D)
}
@Test
fun `Anfragerahmen ist elf Byte lang und richtig eingerahmt`() {
val request = WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG)
assertEquals(11, request.size)
assertEquals(0x1E, request.u(0))
assertEquals(0x008C shr 8, request.u(4))
assertEquals(0x008C and 0xFF, request.u(5))
assertEquals(0x0D, request.u(10))
}
/** Ohne den Text `HiLink` auf FFFA bleibt der Akku stumm. */
@Test
fun `der Freischalttext ist HiLink`() {
assertEquals("HiLink", String(WattCycleProtocol.authPayload, Charsets.US_ASCII))
}
@Test
fun `liest das eigene Stromformat`() {
assertEquals(12.5, WattCycleProtocol.current(0x40, 0x7D), "Bit 14 heisst Zehntel")
assertEquals(-12.5, WattCycleProtocol.current(0xC0, 0x7D), "Bit 15 ist das Vorzeichen")
assertEquals(125.0, WattCycleProtocol.current(0x00, 0x7D), "ohne Bit 14 ganze Ampere")
}
@Test
fun `wertet den selbstbeschreibenden Messwertsatz aus`() {
val payload = byteArrayOf(4) + // vier Zellen
hexBytes("0CFE 0D12 0D00 0CF8") + // Zellspannungen in mV
byteArrayOf(3) + // drei Fühler
hexBytes("0BB8 0BC2 0B99") + // MOSFET, Platine, eine Zelle
hexBytes("C07D") + // -12,5 A
hexBytes("0531") + // 1329 -> 13,29 V
hexBytes("06E0") + // 1760 -> 176,0 Ah Rest
hexBytes("07D0") + // 2000 -> 200,0 Ah gesamt
hexBytes("002A") + // 42 Zyklen
hexBytes("07D0") + // 2000 -> 200,0 Ah Nenn
hexBytes("0058") // 88 %
val (frames, rest) = WattCycleProtocol.extractFrames(frame(0x008C, payload))
assertEquals(1, frames.size)
assertEquals(0, rest.size)
val state = WattCycleState()
frames.forEach { state.apply(it) }
val snapshot = state.snapshot(UUID.randomUUID(), null)
assertEquals(4, snapshot.cellVoltages.size)
assertEquals(13.29, snapshot.value("voltage")?.let(::round2))
assertEquals(-12.5, snapshot.value("current")?.let(::round1))
assertEquals(88.0, snapshot.value("soc"))
assertEquals(176.0, snapshot.value("capacity")?.let(::round1))
assertEquals(42.0, snapshot.value("cycles"))
assertEquals(27.0, state.mosTemperature?.let(::round1), "3000 Zehntel-Kelvin")
assertEquals(28.0, state.pcbTemperature?.let(::round1))
// WattCycle rechnet mit Offset 2730, JBD mit 2731 - daher 23,9 statt 23,8.
assertEquals(23.9, state.cellTemperatures.firstOrNull()?.let(::round1))
assertEquals("Entlädt", snapshot.state)
}
@Test
fun `liest die Produktangaben aus drei ASCII-Feldern`() {
val text = { s: String -> s.toByteArray(Charsets.US_ASCII).copyOf(20) }
val payload = text("WC-12V200") + text("WattCycle") + text("SN12345678")
val (frames, _) = WattCycleProtocol.extractFrames(frame(0x0092, payload))
val state = WattCycleState()
frames.forEach { state.apply(it) }
assertEquals("WC-12V200", state.model)
assertEquals("WattCycle", state.manufacturer)
assertEquals("SN12345678", state.serial)
assertTrue(state.hasProductInfo)
}
@Test
fun `verwirft einen verfaelschten Rahmen`() {
val good = frame(0x008C, byteArrayOf(0))
val bad = good.copyOf()
bad[bad.size - 2] = (bad[bad.size - 2].toInt() xor 0xFF).toByte()
assertEquals(0, WattCycleProtocol.extractFrames(bad).first.size)
}
}
@@ -0,0 +1,247 @@
package de.fritob.campermonitor.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class VanAlignProtocolTest {
/**
* Die Firmware legt den Float little-endian ab. Die Web-Oberfläche des
* Ursprungsprojekts rät die Reihenfolge das ist schädlich, denn ein
* vertauschter Float von 4,25° liest sich als etwa 0,0 und sieht damit
* plausibel aus.
*/
@Test
fun `liest Winkel fest little-endian`() {
val bytes = byteArrayOf(0x00, 0x00, 0x88.toByte(), 0x40) // 4.25f
assertEquals(4.25, VanAlignProtocol.angle(bytes)!!, 0.0001)
}
@Test
fun `weist NaN und unsinnige Winkel ab`() {
assertNull(VanAlignProtocol.angle(hexBytes("0000C07F")), "NaN")
assertNull(VanAlignProtocol.angle(hexBytes("00007A44")), "1000 Grad gibt es nicht")
assertNull(VanAlignProtocol.angle(byteArrayOf(1, 2)), "zu kurz")
}
@Test
fun `liest beide Offsets`() {
val data = byteArrayOf(0x00, 0x00, 0x88.toByte(), 0x40) +
byteArrayOf(0x00, 0x00, 0x00, 0xBF.toByte()) // -0.5f
val offsets = VanAlignProtocol.offsets(data)
assertNotNull(offsets)
assertEquals(4.25, offsets.first, 0.0001)
assertEquals(-0.5, offsets.second, 0.0001)
}
}
class LevelStateTest {
@Test
fun `sagt in Worten was zu tun ist`() {
assertEquals("Steht eben", LevelState(pitch = 0.2, roll = -0.1).instruction)
assertEquals(
"Heck steht höher, rechts steht höher",
LevelState(pitch = 1.8, roll = 0.9).instruction,
)
assertEquals("Front steht höher", LevelState(pitch = -1.8, roll = 0.1).instruction)
}
/**
* Fehlen die Offsets, heisst das "unbekannt" und nicht "nicht kalibriert"
* eine Warnung, die sich nie abstellen lässt, ist schlimmer als keine.
*/
@Test
fun `unterscheidet unbekannt von nicht kalibriert`() {
val unknown = LevelState(pitch = 1.0, roll = 0.0)
assertNull(unknown.calibrationState)
assertFalse(unknown.isKnownUncalibrated)
assertFalse(unknown.isCalibrated)
val uncalibrated = LevelState(pitch = 1.0, roll = 0.0, pitchOffset = 0.0, rollOffset = 0.0)
assertTrue(uncalibrated.isKnownUncalibrated)
val calibrated = LevelState(pitch = 1.0, roll = 0.0, pitchOffset = 0.4, rollOffset = -0.2)
assertTrue(calibrated.isCalibrated)
}
}
class LevelingWedgeTest {
@Test
fun `rechnet die Keilhoehe geometrisch`() {
// tan(2°) × 2,0 m ≈ 0,0699 m
val wedge = LevelingWedge.across(roll = 2.0, trackWidth = 2.0)
assertNotNull(wedge)
assertEquals(7.0, wedge.heightInCentimetres, 0.1)
}
@Test
fun `legt den Keil unter die tiefere Seite`() {
assertEquals(LevelingWedge.Side.LEFT, LevelingWedge.across(2.0, 2.0)?.side,
"rechts steht höher, also links unterlegen")
assertEquals(LevelingWedge.Side.RIGHT, LevelingWedge.across(-2.0, 2.0)?.side)
assertEquals(LevelingWedge.Side.FRONT, LevelingWedge.along(1.5, 3.5)?.side,
"Heck steht höher, also vorne unterlegen")
assertEquals(LevelingWedge.Side.REAR, LevelingWedge.along(-1.5, 3.5)?.side)
}
@Test
fun `verzichtet auf einen Keil wenn es sich nicht lohnt`() {
assertNull(LevelingWedge.across(0.3, 2.0), "innerhalb der Toleranz")
assertNull(LevelingWedge.across(3.0, 0.0), "ohne Maß")
assertNull(LevelingWedge.across(3.0, null), "ohne hinterlegte Spurweite")
}
}
class AlignmentAssistantTest {
private fun assistantWith(vararg deviations: Double): AlignmentAssistant {
val assistant = AlignmentAssistant()
deviations.forEachIndexed { index, value ->
assistant.add(pitch = value, roll = 0.0, at = index * 1000L)
}
return assistant
}
@Test
fun `erkennt Verbesserung und Verschlechterung`() {
assertEquals(AlignmentAssistant.Trend.IMPROVING,
assistantWith(4.0, 4.0, 4.0, 1.0, 1.0, 1.0).trend)
assertEquals(AlignmentAssistant.Trend.WORSENING,
assistantWith(1.0, 1.0, 1.0, 4.0, 4.0, 4.0).trend)
assertEquals(AlignmentAssistant.Trend.STEADY,
assistantWith(2.0, 2.0, 2.0, 2.0, 2.0, 2.0).trend)
assertEquals(AlignmentAssistant.Trend.UNKNOWN,
assistantWith(2.0, 2.0).trend, "zu wenige Messwerte")
}
@Test
fun `merkt sich den besten Punkt und meldet ihn`() {
val assistant = assistantWith(3.0, 0.6, 1.0, 2.0)
assertEquals(0.6, assistant.best?.deviation)
assertEquals(1.4, assistant.improvementAtBest!!, 0.0001)
assertEquals(2.0, assistant.secondsSinceBest!!, 0.0001)
assertTrue(assistant.advice.startsWith("Vor 2 s stand es besser"))
}
@Test
fun `schweigt wenn die Rueckkehr nichts braechte`() {
val assistant = assistantWith(1.05, 1.0)
assertNull(assistant.improvementAtBest, "0,05° zurückzufahren lohnt nicht")
}
@Test
fun `meldet das Ziel wenn es eben steht`() {
val assistant = AlignmentAssistant()
assistant.add(pitch = 0.2, roll = 0.2, at = 0)
assertTrue(assistant.hasReachedTarget)
assertEquals("Steht eben anhalten", assistant.advice)
}
@Test
fun `vergisst was zu lange her ist`() {
val assistant = AlignmentAssistant()
assistant.add(pitch = 5.0, roll = 0.0, at = 0)
assistant.add(pitch = 1.0, roll = 0.0, at = AlignmentAssistant.MEMORY_MS + 1000)
assertEquals(1, assistant.samples.size)
}
}
class SensorOrientationTest {
private fun detect(nose: OrientationDetection.Reading, side: OrientationDetection.Reading) =
OrientationDetection.orientation(nose, side)
private fun reading(pitch: Double, roll: Double) = OrientationDetection.Reading(pitch, roll)
private fun success(result: OrientationDetection.Result): SensorOrientation {
assertTrue(result is OrientationDetection.Result.Success, "erwartet: erkannt, ist: $result")
return result.orientation
}
@Test
fun `erkennt den geraden Einbau`() {
val o = success(detect(reading(12.0, 0.0), reading(0.0, 9.0)))
assertEquals(SensorOrientation.Source.PITCH, o.longitudinalSource)
assertFalse(o.invertLongitudinal)
assertFalse(o.invertLateral)
assertTrue(o.isIdentity)
assertEquals("Achsen unverändert", o.summary)
}
@Test
fun `erkennt den um 180 Grad gedrehten Einbau`() {
val o = success(detect(reading(-12.0, 0.0), reading(0.0, -9.0)))
assertEquals(SensorOrientation.Source.PITCH, o.longitudinalSource)
assertTrue(o.invertLongitudinal)
assertTrue(o.invertLateral)
}
@Test
fun `erkennt den quer eingebauten Sensor`() {
val o = success(detect(reading(0.0, 11.0), reading(-8.0, 0.0)))
assertEquals(SensorOrientation.Source.ROLL, o.longitudinalSource)
assertFalse(o.invertLongitudinal)
assertTrue(o.invertLateral)
assertTrue(o.summary.contains("getauscht"))
}
/**
* Von Hand gekippt geht die andere Achse ein Stück mit. Einzeln betrachtet
* wäre das nicht zuzuordnen, im Paar schon genau daran scheiterte der
* Assistent zuerst.
*/
@Test
fun `verkraftet eine mitlaufende Achse`() {
val o = success(detect(reading(14.0, 6.0), reading(-7.0, 16.0)))
assertEquals(SensorOrientation.Source.PITCH, o.longitudinalSource)
assertFalse(o.invertLongitudinal)
assertFalse(o.invertLateral)
val sideways = success(detect(reading(6.0, 14.0), reading(-16.0, 7.0)))
assertEquals(SensorOrientation.Source.ROLL, sideways.longitudinalSource)
assertTrue(sideways.invertLateral)
}
@Test
fun `meldet zu wenig Bewegung`() {
val result = detect(reading(2.0, 0.0), reading(0.0, 9.0))
assertEquals(
OrientationDetection.Failure.TOO_LITTLE_MOVEMENT,
(result as OrientationDetection.Result.Error).failure,
)
}
@Test
fun `meldet zweimal dieselbe Achse`() {
val result = detect(reading(10.0, 0.0), reading(9.0, 0.0))
assertEquals(
OrientationDetection.Failure.AMBIGUOUS,
(result as OrientationDetection.Result.Error).failure,
)
}
@Test
fun `rechnet Sensorwerte in Fahrzeugwerte um`() {
val swapped = SensorOrientation(
longitudinalSource = SensorOrientation.Source.ROLL,
invertLateral = true,
)
val (pitch, roll) = swapped.apply(pitch = 3.0, roll = -2.0)
assertEquals(-2.0, pitch, "längs kommt aus der zweiten Achse")
assertEquals(-3.0, roll, "quer ist umgekehrt")
val untouched = SensorOrientation.IDENTITY.apply(pitch = 1.5, roll = -0.5)
assertEquals(1.5, untouched.first)
assertEquals(-0.5, untouched.second)
val missing = SensorOrientation.IDENTITY.apply(pitch = null, roll = 2.0)
assertNull(missing.first)
assertEquals(2.0, missing.second)
}
}
@@ -0,0 +1,45 @@
package de.fritob.campermonitor.protocol
import kotlin.math.roundToLong
/** Hex-Text zu Bytes. Leerzeichen dürfen drin stehen, das hilft beim Lesen. */
fun hexBytes(text: String): ByteArray {
val cleaned = text.filter { !it.isWhitespace() }
require(cleaned.length % 2 == 0) { "ungerade Anzahl Hex-Zeichen" }
return ByteArray(cleaned.length / 2) {
cleaned.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
}
fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
fun round2(value: Double): Double = (value * 100).roundToLong() / 100.0
fun round1(value: Double): Double = (value * 10).roundToLong() / 10.0
/** Packt Felder so, wie Victron sie sendet: LSB zuerst, ohne Byte-Ausrichtung. */
class BitWriter {
private val bits = mutableListOf<Int>()
fun write(value: Long, width: Int) {
for (i in 0 until width) bits.add(((value shr i) and 1).toInt())
}
fun write(value: Int, width: Int) = write(value.toLong(), width)
val bytes: ByteArray
get() {
val out = ByteArray((bits.size + 7) / 8)
bits.forEachIndexed { index, bit ->
if (bit == 1) out[index / 8] = (out[index / 8].toInt() or (1 shl (index % 8))).toByte()
}
return out
}
}
/** Baut eine Daly-Antwort so, wie das BMS sie schickt. */
fun dalyResponse(command: Int, payload: ByteArray): ByteArray {
val frame = byteArrayOf(0xA5.toByte(), 0x01, command.toByte(), 0x08) + payload
var sum = 0
for (b in frame) sum = (sum + (b.toInt() and 0xFF)) and 0xFF
return frame + sum.toByte()
}
@@ -0,0 +1,169 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
import kotlin.math.roundToLong
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class AesCounterModeTest {
@Test
fun `stimmt mit dem Referenzvektor NIST SP 800-38A F 5 1 ueberein`() {
val cipher = AesCounterMode.crypt(
data = hexBytes("6bc1bee22e409f96e93d7e117393172a"),
key = hexBytes("2b7e151628aed2a6abf7158809cf4f3c"),
nonce = hexBytes("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"),
)
assertEquals("874d6191b620e3261bef6864990db6ce", cipher?.hex())
}
}
class BitReaderTest {
@Test
fun `liest die untersten Bits zuerst und setzt bitgenau fort`() {
val reader = BitReader(byteArrayOf(0xB5.toByte(), 0x03))
assertEquals(5L, reader.read(3))
assertEquals(22L, reader.read(5))
assertEquals(3L, reader.read(8))
assertNull(reader.read(1), "hinter dem Ende gibt es nichts mehr")
}
@Test
fun `erkennt den NA-Wert`() {
val reader = BitReader(byteArrayOf(0xFF.toByte(), 0xFF.toByte()))
assertNull(reader.readOptional(16))
}
@Test
fun `dekodiert Zweierkomplement und erkennt dessen NA-Wert`() {
val reader = BitReader(
byteArrayOf(0xFF.toByte(), 0x7F, 0x9C.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
)
assertNull(reader.readOptionalSigned(16), "0x7FFF ist NA")
assertEquals(-100L, reader.readOptionalSigned(16))
assertEquals(-1L, reader.readOptionalSigned(16))
}
}
/** Der Geräteschlüssel aus den iOS-Prüfungen, damit dieselben Vektoren gelten. */
private val deviceKey = hexBytes("aa112233445566778899aabbccddeeff")
private const val NONCE = 0x1234
private fun counterBlock(nonce: Int = NONCE) = ByteArray(16).also {
it[0] = (nonce and 0xFF).toByte()
it[1] = (nonce shr 8).toByte()
}
private fun advertisement(productID: Int, record: Int, plain: ByteArray): ByteArray {
val encrypted = AesCounterMode.crypt(plain, deviceKey, counterBlock())!!
return byteArrayOf(
0xE1.toByte(), 0x02, 0x10, 0x00,
(productID and 0xFF).toByte(), (productID shr 8).toByte(),
record.toByte(),
(NONCE and 0xFF).toByte(), (NONCE shr 8).toByte(),
deviceKey[0],
) + encrypted
}
private fun DeviceSnapshot.value(key: String): Double? =
metrics.firstOrNull { it.key == key }?.value
class VictronSolarChargerTest {
private fun decoded(): DeviceSnapshot {
val w = BitWriter()
w.write(3, 8) // Zustand: Bulk
w.write(0, 8) // kein Fehler
w.write(1345, 16) // 13,45 V
w.write(152, 16) // 15,2 A
w.write(234, 16) // 2,34 kWh
w.write(210, 16) // 210 W
w.write(0x1FF, 9) // Laststrom nicht verfügbar
return VictronAdvertisement.decode(
advertisement(0xA04C, 0x01, w.bytes), deviceKey, UUID.randomUUID(), -55
)
}
@Test
fun `dekodiert einen Solarladeregler vollstaendig`() {
val solar = decoded()
assertEquals("Konstantstrom (Bulk)", solar.state)
assertNull(solar.fault, "kein Fehler gemeldet")
assertEquals(13.45, solar.value("battery_voltage")?.let(::round2))
assertEquals(15.2, solar.value("battery_current")?.let(::round1))
assertEquals(2.34, solar.value("yield_today"))
assertEquals(210.0, solar.value("pv_power"))
assertNull(solar.value("load_current"), "Laststrom bleibt leer (NA)")
assertEquals("pv_power", solar.primaryMetric?.key)
}
@Test
fun `weist einen falschen Schluessel ab`() {
val w = BitWriter()
repeat(9) { w.write(0, 8) }
val frame = advertisement(0xA04C, 0x01, w.bytes)
val wrongKey = deviceKey.copyOf().also { it[0] = 0x00 }
val error = runCatching {
VictronAdvertisement.decode(frame, wrongKey, UUID.randomUUID(), null)
}.exceptionOrNull()
assertTrue(error is VictronAdvertisement.DecodeError.KeyMismatch)
}
}
class VictronOrionXsTest {
private fun frame(): ByteArray {
val w = BitWriter()
w.write(3, 8) // Bulk
w.write(0, 8)
w.write(1420, 16) // Ausgang 14,20 V
w.write(180, 16) // 18,0 A
w.write(1310, 16) // Eingang 13,10 V
w.write(210, 16) // 21,0 A
w.write(0x00000002L, 32) // "Per Schalter ausgeschaltet"
return advertisement(0xA3F0, 0x0F, w.bytes)
}
@Test
fun `liest den Rahmen`() {
val envelope = VictronAdvertisement.envelope(frame())
assertNotNull(envelope)
assertEquals(0xA3F0, envelope.productID)
assertEquals(0x0F, envelope.recordType)
assertEquals(NONCE, envelope.nonce)
assertEquals(deviceKey.u(0), envelope.keyCheckByte)
assertEquals(14, envelope.ciphertext.size, "Nutzdaten sind 14 Byte lang")
}
@Test
fun `dekodiert die Werte`() {
val xs = VictronAdvertisement.decode(frame(), deviceKey, UUID.randomUUID(), null)
assertEquals(14.20, xs.value("output_voltage")?.let(::round2))
assertEquals(18.0, xs.value("output_current")?.let(::round1))
assertEquals(13.10, xs.value("input_voltage")?.let(::round2))
assertEquals(256L, xs.value("output_power")?.roundToLong(), "Ladeleistung wird gerechnet")
assertEquals("Per Schalter ausgeschaltet", xs.offReasons.firstOrNull())
}
/**
* Aus der Diagnoseansicht der App abgelesen. Ohne Schlüssel lässt sich der
* Inhalt nicht prüfen, wohl aber der Rahmen und genau dort war der Fehler,
* der unter iOS die Meldung "Schlüssel passt nicht" ausgelöst hat.
*/
@Test
fun `liest einen echten Rahmen vom Geraet`() {
val envelope = VictronAdvertisement.envelope(
hexBytes("E1 02 10 00 F0 A3 0F 17 28 3C 81 9C 8F 42 FC 94 5F 4D 59 C9 F8 73 DB 48")
)
assertNotNull(envelope, "wird als Victron erkannt")
assertEquals(0x0F, envelope.recordType, "Datensatztyp ist Orion XS")
assertEquals(0xA3F0, envelope.productID)
assertEquals(0x2817, envelope.nonce)
assertEquals(0x3C, envelope.keyCheckByte)
assertEquals(14, envelope.ciphertext.size)
}
}