forked from fritob/Camper-Monitor
Android: Protokollschicht portiert und geprüft
Erster Teil der Android-Fassung. Portiert ist die Schicht, in der die ganze Erfahrung aus unseren Fehlersuchen steckt: Victron-Entschlüsselung, Daly klassisch und Modbus, JBD, WattCycle, Alpicool, der Neigungsmesser samt Einbaulage, Ausrichtungs-Assistent und Keilrechner. Sie liegt in einem eigenen Gradle-Modul aus reinem Kotlin, ohne Android-Abhängigkeiten. Das ist keine Förmlichkeit: nur so laufen ihre Prüfungen auf der Kommandozeile, ohne Emulator und ohne Android SDK - genau wie das run-tests.sh der iOS-Fassung. 50 Prüfungen, alle grün. Dieselben Vektoren wie unter iOS, darunter die, die uns Tage gekostet haben: der AES-Vektor aus NIST SP 800-38A, der echte Orion-XS-Rahmen aus der Diagnoseansicht, das mitgeschnittene Ausschaltpaket der IceCube Dual und die 30-Byte-Antwort der Box im Fahrzeug, an der sich zeigt, dass ein Fühlerplatzhalter von -128 keine zweite Zone ist. Zwei Dinge weichen bewusst ab. AES-CTR kommt aus der JVM statt aus CommonCrypto - der Zähler wird dort ebenso big-endian gezählt. Und Zeitstempel sind Millisekunden statt Date-Objekten: das läuft ohne Rücksicht auf die Android-Version und lässt sich in Prüfungen vorgeben, statt von der Uhr abzuhängen. Die Werkzeugkette steht in Android/env.sh: JDK 17, Gradle 8.11.1 über den Wrapper, Android SDK 35. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+361
@@ -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,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 0–47: 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 48–55: 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"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package de.fritob.campermonitor.protocol
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 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,
|
||||
) {
|
||||
/** 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. */
|
||||
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
|
||||
return longitudinal?.let { if (invertLongitudinal) -it else it } to
|
||||
lateral?.let { if (invertLateral) -it else it }
|
||||
}
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
return Result.Success(
|
||||
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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
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"
|
||||
|
||||
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 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
|
||||
}
|
||||
}
|
||||
+311
@@ -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",
|
||||
)
|
||||
|
||||
}
|
||||
+281
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user