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:
BiasF
2026-08-31 10:20:01 +02:00
co-authored by Claude Opus 5
parent 4bc2f5d0f3
commit 0cf8448f34
28 changed files with 3587 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Was Gradle und das Android SDK erzeugen. Nichts davon gehört ins Repository.
.gradle/
build/
local.properties
*.iml
.idea/
captures/
.cxx/
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.jvm") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}
+6
View File
@@ -0,0 +1,6 @@
# Werkzeugkette für dieses Projekt. Vor Gradle-Aufrufen einlesen:
# source Android/env.sh
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
export ANDROID_HOME="$HOME/Library/Android/sdk"
export ANDROID_SDK_ROOT="$ANDROID_HOME"
export PATH="$JAVA_HOME/bin:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
android.useAndroidX=true
kotlin.code.style=official
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+19
View File
@@ -0,0 +1,19 @@
plugins {
id("org.jetbrains.kotlin.jvm")
}
kotlin {
jvmToolchain(17)
}
dependencies {
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "failed")
showStandardStreams = true
}
}
@@ -0,0 +1,34 @@
package de.fritob.campermonitor.protocol
import java.security.GeneralSecurityException
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* AES-128 im Counter-Modus.
*
* Anders als unter iOS, wo CryptoKit CTR nicht anbietet und CommonCrypto
* herhalten muss, bringt die JVM den Modus mit. Der Zähler wird dort wie bei
* Victron big-endian hochgezählt.
*/
object AesCounterMode {
/**
* @param data Der verschlüsselte Nutzteil des Advertisements.
* @param key 16 Byte Geräteschlüssel aus VictronConnect.
* @param nonce Der 16-Byte-Zählerblock (Victron: Nonce little-endian in den
* ersten beiden Bytes, Rest 0).
*/
fun crypt(data: ByteArray, key: ByteArray, nonce: ByteArray): ByteArray? {
if (key.size != 16 || nonce.size != 16) return null
return try {
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
// CTR ist symmetrisch; entschlüsselt wird mit derselben Operation.
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(nonce))
cipher.doFinal(data)
} catch (_: GeneralSecurityException) {
null
}
}
}
@@ -0,0 +1,186 @@
package de.fritob.campermonitor.protocol
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToLong
import kotlin.math.sqrt
import kotlin.math.tan
/**
* Hilft beim Ausrichten des Fahrzeugs während des Rangierens.
*
* Bewusst **ohne** Positionsbestimmung: Aus einem MEMS-Beschleunigungssensor
* lässt sich keine brauchbare Strecke ableiten, weil der Fehler beim
* zweifachen Integrieren quadratisch mit der Zeit wächst. Beim Rangieren im
* Schritttempo gehen die tatsächlichen Beschleunigungen ohnehin im Rauschen
* unter.
*
* Gebraucht wird das auch gar nicht. Die Frage beim Einparken lautet nie „wo
* stehe ich", sondern „wird es besser oder schlechter, und wo war es am
* besten". Beides steckt bereits im zeitlichen Verlauf der Neigung ganz
* ohne Annahmen über das Gelände.
*
* Die Zeit kommt in Millisekunden von aussen herein, damit sich der Verlauf in
* Prüfungen vorgeben lässt, statt von der Uhr abzuhängen.
*/
class AlignmentAssistant {
data class Sample(val time: Long, val pitch: Double, val roll: Double) {
/** Gesamtabweichung von der Waagerechten. */
val deviation: Double get() = sqrt(pitch * pitch + roll * roll)
}
enum class Trend(val text: String) {
IMPROVING("Wird besser"),
WORSENING("Wird schlechter"),
STEADY("Bleibt gleich"),
UNKNOWN("Messe…"),
}
private val _samples = mutableListOf<Sample>()
val samples: List<Sample> get() = _samples
fun add(pitch: Double, roll: Double, at: Long = System.currentTimeMillis()) {
_samples.add(Sample(at, pitch, roll))
val cutoff = at - MEMORY_MS
_samples.removeAll { it.time < cutoff }
}
fun reset() = _samples.clear()
val current: Sample? get() = _samples.lastOrNull()
/** Der flachste Punkt, den wir gesehen haben. */
val best: Sample? get() = _samples.minByOrNull { it.deviation }
/**
* Ob und wie stark sich die Lage gerade ändert.
*
* Verglichen wird das jüngste Drittel mit dem davorliegenden. Einzelne
* Messwerte wären zu unruhig; das Fahrzeug wippt beim Rangieren.
*/
val trend: Trend
get() {
if (_samples.size < 6) return Trend.UNKNOWN
val recent = _samples.takeLast(3)
val previous = _samples.dropLast(3).takeLast(3)
if (previous.isEmpty()) return Trend.UNKNOWN
val now = recent.sumOf { it.deviation } / recent.size
val before = previous.sumOf { it.deviation } / previous.size
val change = now - before
if (abs(change) < TREND_THRESHOLD) return Trend.STEADY
return if (change < 0) Trend.IMPROVING else Trend.WORSENING
}
/**
* Wieviel besser der beste Punkt gegenüber jetzt war null, wenn es sich
* nicht lohnt oder wir gerade selbst am besten Punkt stehen.
*/
val improvementAtBest: Double?
get() {
val now = current ?: return null
val best = best ?: return null
if (best.time >= now.time) return null
val gain = now.deviation - best.deviation
return if (gain >= WORTH_GOING_BACK) gain else null
}
/** Wie lange der beste Punkt zurückliegt, in Sekunden. */
val secondsSinceBest: Double?
get() {
if (improvementAtBest == null) return null
val now = current ?: return null
val best = best ?: return null
return (now.time - best.time) / 1000.0
}
/** Was der Fahrer jetzt tun soll. */
val advice: String
get() {
val now = current ?: return "Warte auf Messwerte…"
if (now.deviation <= LevelState.LEVEL_TOLERANCE) return "Steht eben anhalten"
secondsSinceBest?.let {
return String.format(
Locale.GERMANY, "Vor %d s stand es besser ein Stück zurück", it.roundToLong()
)
}
return when (trend) {
Trend.IMPROVING -> "Wird besser weiter so"
Trend.WORSENING -> "Wird schlechter andere Richtung"
Trend.STEADY -> "Ändert sich kaum andere Richtung versuchen"
Trend.UNKNOWN -> "Langsam weiterfahren"
}
}
val hasReachedTarget: Boolean
get() = (current?.deviation ?: Double.POSITIVE_INFINITY) <= LevelState.LEVEL_TOLERANCE
companion object {
/** Wie lange zurückgeschaut wird. */
const val MEMORY_MS = 90_000L
/** Ab dieser Verbesserung lohnt der Hinweis auf einen früheren Punkt. */
const val WORTH_GOING_BACK = 0.2
/** Unterhalb dieser Änderung gilt die Lage als unverändert. */
const val TREND_THRESHOLD = 0.08
}
}
/**
* Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
*
* Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
* beziehungsweise der Räder.
*/
data class LevelingWedge(
/** Wo der Keil hin muss. */
val side: Side,
/** Höhe in Metern. */
val height: Double,
/** Der zugrundeliegende Winkel in Grad. */
val angle: Double,
) {
enum class Side(val text: String) {
FRONT("vorne"),
REAR("hinten"),
LEFT("links"),
RIGHT("rechts"),
}
val heightInCentimetres: Double get() = height * 100
companion object {
/**
* Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
* heisst, dass rechts höher steht der Keil gehört also nach links.
*/
fun across(roll: Double, trackWidth: Double?): LevelingWedge? =
wedge(roll, trackWidth, Side.LEFT, Side.RIGHT)
/**
* Längs: positiver Pitch heisst, dass das Heck höher steht der Keil
* gehört unter die Vorderräder.
*/
fun along(pitch: Double, wheelbase: Double?): LevelingWedge? =
wedge(pitch, wheelbase, Side.FRONT, Side.REAR)
private fun wedge(
angle: Double,
distance: Double?,
whenPositive: Side,
whenNegative: Side,
): LevelingWedge? {
if (distance == null || distance <= 0) return null
if (abs(angle) <= LevelState.LEVEL_TOLERANCE) return null
val height = tan(abs(angle) * Math.PI / 180) * distance
return LevelingWedge(
side = if (angle > 0) whenPositive else whenNegative,
height = height,
angle = abs(angle),
)
}
}
}
@@ -0,0 +1,361 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/** Übersteuerung der Zonenerkennung aus den Geräteeinstellungen. */
enum class FridgeZoneMode(val title: String) {
AUTOMATIC("Automatisch"),
SINGLE("Eine Zone"),
DUAL("Zwei Zonen"),
}
/**
* Protokoll der Alpicool-Kompressorkühlboxen. Dieselbe Elektronik steckt unter
* anderem in den IceCube-Boxen von Plug-in Festivals sowie in Modellen von
* BrassMonkey und Ocean Comfort.
*
* Gesprochen wird über zwei Charakteristiken: geschrieben auf `00001235-…`,
* Antworten kommen über `00001236-…`.
*
* Rahmenaufbau in beide Richtungen:
* ```
* FE FE <Länge> <Kommando> <Daten…> <Prüfsumme 2 Byte>
* ```
*
* `Länge` zählt Kommando, Daten und Prüfsumme, die Gesamtlänge ist also
* `3 + Länge`. Die Prüfsumme ist die Summe aller vorangehenden Bytes,
* höherwertiges Byte zuerst.
*
* Vor der ersten Abfrage muss einmal `BIND` geschickt werden. Steht „APP" im
* Display der Box, verlangt sie dabei einen Tastendruck am Gerät.
*
* Feldbelegung nach Gruni22/alpicool_ha_ble.
*/
object AlpicoolProtocol {
enum class Command(val raw: Int) {
BIND(0x00),
QUERY(0x01),
SET(0x02),
RESET(0x04),
SET_LEFT(0x05),
SET_RIGHT(0x06),
}
/** Summe aller Bytes, auf 16 Bit beschnitten. */
fun checksum(bytes: ByteArray): Int {
var sum = 0
for (b in bytes) sum += b.toInt() and 0xFF
return sum and 0xFFFF
}
fun packet(command: Command, data: ByteArray = ByteArray(0)): ByteArray {
val head = byteArrayOf(
0xFE.toByte(), 0xFE.toByte(),
(data.size + 3).toByte(), // Kommando + Daten + Prüfsumme
command.raw.toByte(),
) + data
val sum = checksum(head)
return head + byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte())
}
class Frame(val command: Int, val payload: ByteArray)
/**
* Sucht vollständige Rahmen im Puffer.
*
* Auf Stellbefehle antwortet die Box mit zwei Paketen in einer einzigen
* Benachrichtigung: erst ein Echo des Befehls, dann der volle Status.
* Deshalb wird in der Schleife weitergesucht, statt nach dem ersten
* Treffer abzubrechen.
*/
fun extractFrames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var consumed = 0
while (index + 3 <= buffer.size) {
if (buffer.u(index) != 0xFE || buffer.u(index + 1) != 0xFE) {
index += 1
continue
}
val total = 3 + buffer.u(index + 2)
if (total < 6 || total > 128) {
index += 1
continue
}
if (index + total > buffer.size) break // Rest abwarten
val packet = buffer.copyOfRange(index, index + total)
val expected = checksum(packet.copyOfRange(0, total - 2))
val actual = (packet.u(total - 2) shl 8) or packet.u(total - 1)
if (expected != actual) {
index += 1
continue
}
frames.add(Frame(packet.u(3), packet.copyOfRange(4, total - 2)))
index += total
consumed = index
}
val keepFrom = maxOf(consumed, maxOf(0, buffer.size - 128))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
fun signed(byte: Int): Int = byte.toByte().toInt()
/**
* Pause zwischen den Teilstücken eines aufgeteilten Pakets, damit das
* Gerät sie wieder zusammensetzen kann.
*/
const val CHUNK_DELAY_MS = 150L
/**
* Wieviel die Box je Schreibvorgang annimmt.
*
* Das sind die 20 Nutzbytes der Standard-MTU unabhängig davon, was auf
* der Verbindung ausgehandelt wurde. Ein längerer Schreibvorgang wird von
* diesen Boxen abgelehnt; belegt an einer Maentum/Plug-in Festival
* IceCube Dual, bei der genau deshalb das Ein- und Ausschalten scheiterte,
* während der kurze Temperaturbefehl durchging
* (Gruni22/alpicool_ha_ble#20).
*/
const val MAX_WRITE_SIZE = 20
/** Womit diese Boxen einen nicht vorhandenen Fühler melden. */
const val MISSING_SENSOR_READING = -128
/** Zerlegt ein Paket in schreibbare Stücke. */
fun chunks(data: ByteArray, limit: Int): List<ByteArray> {
if (limit <= 0 || data.size <= limit) return listOf(data)
return (data.indices step limit).map {
data.copyOfRange(it, minOf(it + limit, data.size))
}
}
}
/** Zonen einer Kühlbox. */
enum class FridgeZone { LEFT, RIGHT }
/** Zustand einer Kühlbox Messwerte und die Einstellungen, die sich ändern lassen. */
class AlpicoolState {
var isLocked = false
var isPoweredOn = true
/** 0 = Max, 1 = Eco. */
var runMode = 0
var batterySaver = 0
var leftTarget: Int? = null
var leftCurrent: Int? = null
var rightTarget: Int? = null
var rightCurrent: Int? = null
var temperatureMin: Int? = null
var temperatureMax: Int? = null
var startDelayMinutes: Int? = null
var returnDifference: Int? = null
/** 0 = °C, 1 = °F. */
var unit = 0
var runningStatus: Int? = null
var batteryPercent: Int? = null
var batteryVolts: Double? = null
/**
* Die vollständige Nutzlast der letzten Statusantwort. Stellbefehle für
* Ein/Aus und Betriebsart schicken den gesamten Einstellungsblock zurück,
* deshalb wird er aufgehoben.
*/
var lastPayload: ByteArray = ByteArray(0)
var zoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC
/**
* Die Einstellungsbytes der rechten Zone, für die Erkennung und die
* Diagnose. Ohne den Messwert der wird getrennt beurteilt.
*/
var rightZoneBytes: ByteArray = ByteArray(0)
/**
* Ob die Box wirklich eine zweite Zone hat.
*
* Die Nutzlastlänge allein taugt nicht: Einzonen-Boxen senden den langen
* Datensatz teils mit und füllen den zweiten Block auf. Zwei Anzeichen
* verraten das. Erstens meldet die Box für den fehlenden zweiten Fühler
* -128, den üblichen Platzhalter. Zweitens stehen die Einstellungen der
* rechten Zone dann auf lauter Nullen oder lauter 0xFF.
*
* Das ist keine Frage der Anzeige allein: der Stellbefehl fällt für eine
* Box mit zwei Zonen länger aus, und die falsche Länge wird verworfen.
*/
val detectedDualZone: Boolean
get() {
val current = rightCurrent ?: return false
if (current == AlpicoolProtocol.MISSING_SENSOR_READING) return false
if (rightZoneBytes.isEmpty()) return false
return rightZoneBytes.any { it.toInt() != 0x00 } &&
rightZoneBytes.any { (it.toInt() and 0xFF) != 0xFF }
}
val isDualZone: Boolean
get() = when (zoneMode) {
FridgeZoneMode.AUTOMATIC -> detectedDualZone
FridgeZoneMode.SINGLE -> false
FridgeZoneMode.DUAL -> rightCurrent != null
}
val isEco: Boolean get() = runMode == 1
val usesFahrenheit: Boolean get() = unit == 1
val hasStatus: Boolean get() = lastPayload.isNotEmpty()
val unitSymbol: String get() = if (usesFahrenheit) "°F" else "°C"
/**
* Grenzen für den Sollwert. Meldet die Box keine brauchbaren, gelten die
* üblichen Werte der Baureihe.
*/
val targetRange: IntRange
get() {
val low = temperatureMin ?: if (usesFahrenheit) -22 else -30
val high = temperatureMax ?: if (usesFahrenheit) 68 else 20
return if (low < high) low..high else if (usesFahrenheit) -22..68 else -30..20
}
fun apply(frame: AlpicoolProtocol.Frame) {
// Nur Statusantworten auswerten; das Echo eines Stellbefehls ist kurz.
if (frame.command != AlpicoolProtocol.Command.QUERY.raw || frame.payload.size < 18) return
val p = frame.payload
lastPayload = p
isLocked = p.u(0) != 0
isPoweredOn = p.u(1) != 0
runMode = p.u(2)
batterySaver = p.u(3)
leftTarget = AlpicoolProtocol.signed(p.u(4))
temperatureMax = AlpicoolProtocol.signed(p.u(5))
temperatureMin = AlpicoolProtocol.signed(p.u(6))
returnDifference = AlpicoolProtocol.signed(p.u(7))
startDelayMinutes = p.u(8)
unit = p.u(9)
leftCurrent = AlpicoolProtocol.signed(p.u(14))
batteryPercent = p.u(15)
batteryVolts = p.u(16) + p.u(17) / 10.0
if (p.size >= 28) {
rightTarget = AlpicoolProtocol.signed(p.u(18))
rightCurrent = AlpicoolProtocol.signed(p.u(26))
runningStatus = p.u(27)
rightZoneBytes = p.copyOfRange(18, 26)
} else {
rightTarget = null
rightCurrent = null
rightZoneBytes = ByteArray(0)
}
}
// MARK: - Stellbefehle
/**
* Die Bytes, die ein Stellbefehl ändert.
*
* Messwerte gehören nicht dazu: Temperatur und Spannung schwanken ohnehin,
* an ihnen liesse sich nicht ablesen, ob ein Befehl gewirkt hat.
*/
val settingsFingerprint: List<Int>
get() {
if (lastPayload.size < 18) return emptyList()
val bytes = mutableListOf(
lastPayload.u(0), lastPayload.u(1), lastPayload.u(2), lastPayload.u(4)
)
if (lastPayload.size >= 28) bytes.add(lastPayload.u(18))
return bytes
}
/**
* Baut den Einstellungsblock neu auf und ändert darin einzelne Bytes.
* Ein Teil-Update gibt es bei diesem Kommando nicht die Box erwartet den
* kompletten Block, sonst überschreibt sie Einstellungen mit Nullen.
*/
fun settingsCommand(
poweredOn: Boolean? = null,
eco: Boolean? = null,
locked: Boolean? = null,
): ByteArray? {
if (lastPayload.size < 18) return null
val p = lastPayload
val data = mutableListOf(
locked?.let { if (it) 1 else 0 } ?: p.u(0),
poweredOn?.let { if (it) 1 else 0 } ?: p.u(1),
eco?.let { if (it) 1 else 0 } ?: p.u(2),
p.u(3), // Batteriewächter
p.u(4), // Sollwert links
p.u(5), p.u(6), // Grenzen
p.u(7), // Rückschaltdifferenz
p.u(8), // Anlaufverzögerung
p.u(9), // Einheit
p.u(10), p.u(11), p.u(12), p.u(13), // Kompressordrehzahlen
)
// Der zweite Block gehört nur an den Befehl, wenn die Box wirklich
// zwei Zonen hat. Eine Einzonen-Box sendet den langen Datensatz teils
// trotzdem nimmt aber nur den kurzen Befehl an.
if (isDualZone && p.size >= 28) {
data += listOf(
p.u(18), // Sollwert rechts
0, 0,
p.u(21), // Rückschaltdifferenz rechts
p.u(22), p.u(23), p.u(24), p.u(25),
0, 0, 0,
)
}
return AlpicoolProtocol.packet(
AlpicoolProtocol.Command.SET,
ByteArray(data.size) { data[it].toByte() },
)
}
companion object {
fun setTarget(zone: FridgeZone, value: Int): ByteArray = AlpicoolProtocol.packet(
if (zone == FridgeZone.LEFT) AlpicoolProtocol.Command.SET_LEFT
else AlpicoolProtocol.Command.SET_RIGHT,
byteArrayOf(value.coerceIn(-128, 127).toByte()),
)
}
// MARK: - Anzeige
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val unitText = unitSymbol
val metrics = mutableListOf(
Metric("temp_left", if (isDualZone) "Temperatur links" else "Temperatur",
leftCurrent?.toDouble(), unitText, precision = 0, isPrimary = true),
Metric("target_left", if (isDualZone) "Soll links" else "Solltemperatur",
leftTarget?.toDouble(), unitText, precision = 0),
)
if (isDualZone) {
metrics.add(Metric("temp_right", "Temperatur rechts",
rightCurrent?.toDouble(), unitText, precision = 0))
metrics.add(Metric("target_right", "Soll rechts",
rightTarget?.toDouble(), unitText, precision = 0))
}
metrics.add(Metric("supply_voltage", "Bordspannung", batteryVolts, "V", precision = 1))
metrics.add(Metric("battery_percent", "Batterieanzeige",
batteryPercent?.toDouble(), "%", precision = 0))
val state = when {
!isPoweredOn -> "Aus"
runningStatus == 1 -> if (isEco) "Kühlt (Eco)" else "Kühlt (Max)"
else -> if (isEco) "Eco" else "Max"
}
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
state = state,
offReasons = if (isLocked) listOf("Bedienfeld gesperrt") else emptyList(),
)
}
}
@@ -0,0 +1,62 @@
package de.fritob.campermonitor.protocol
/** Der Wert eines Bytes ohne Vorzeichen. Kotlins `Byte` ist vorzeichenbehaftet. */
internal fun ByteArray.u(index: Int): Int = this[index].toInt() and 0xFF
/**
* Liest Felder beliebiger Bitbreite aus einem Byte-Array.
*
* Victron packt die Felder seiner Werbedaten little-endian und bitweise ohne
* Byte-Ausrichtung: das erste Feld beginnt am niederwertigsten Bit von Byte 0,
* jedes weitere schliesst direkt an.
*
* Gerechnet wird in `Long`, weil ein 32-Bit-Feld ohne Vorzeichen nicht in
* Kotlins `Int` passt.
*/
class BitReader(private val bytes: ByteArray) {
private var bitOffset = 0
val bitsRemaining: Int get() = bytes.size * 8 - bitOffset
/** Liest [width] Bits als vorzeichenlose Zahl, oder null bei zu kurzen Daten. */
fun read(width: Int): Long? {
if (width <= 0 || width > 32 || bitsRemaining < width) return null
var result = 0L
for (i in 0 until width) {
val absolute = bitOffset + i
val bit = (bytes.u(absolute / 8) shr (absolute % 8)) and 1
result = result or (bit.toLong() shl i)
}
bitOffset += width
return result
}
/**
* Wie [read], liefert aber null wenn alle Bits gesetzt sind so markiert
* Victron "Wert nicht verfügbar".
*/
fun readOptional(width: Int): Long? {
val raw = read(width) ?: return null
val notAvailable = if (width >= 32) 0xFFFF_FFFFL else (1L shl width) - 1
return if (raw == notAvailable) null else raw
}
/** Zweierkomplement-Feld. Der NA-Wert 0x7F..F wird zu null. */
fun readOptionalSigned(width: Int): Long? {
if (width <= 1) return null
val raw = read(width) ?: return null
val notAvailable = (1L shl (width - 1)) - 1
if (raw == notAvailable) return null
val signBit = 1L shl (width - 1)
return if (raw and signBit != 0L) raw - (1L shl width) else raw
}
/** Skaliertes, optionales Feld ohne Vorzeichen. */
fun scaled(width: Int, factor: Double): Double? =
readOptional(width)?.let { it.toDouble() * factor }
/** Skaliertes, optionales Feld mit Vorzeichen. */
fun scaledSigned(width: Int, factor: Double): Double? =
readOptionalSigned(width)?.let { it.toDouble() * factor }
}
@@ -0,0 +1,128 @@
package de.fritob.campermonitor.protocol
/**
* Reine Protokoll-Logik für Daly-BMS ohne Bluetooth, damit sie sich isoliert
* prüfen lässt.
*
* Daly hat zwei Generationen im Umlauf:
*
* * **Klassisch (`A5`)** 13-Byte-Rahmen `A5 <adr> <cmd> 08 <8 Datenbytes> <Prüfsumme>`.
* Verbreitet bei den Smart-BMS mit dem blauen BLE-Stick, wie sie in vielen
* Bulltron-Akkus stecken.
* * **Neu (`D2`)** Modbus-RTU über BLE, `D2 03 <Startregister> <Anzahl> <CRC16>`.
*
* Welche Generation verbaut ist, erkennt die Sitzung anhand der Antwort.
*/
object DalyProtocol {
// MARK: - Klassisches A5-Protokoll
enum class Command(val raw: Int) {
SOC(0x90), // Spannung, Strom, Ladezustand
CELL_VOLTAGE_MIN_MAX(0x91),
TEMPERATURE_MIN_MAX(0x92),
MOSFET_STATUS(0x93),
STATUS_INFO(0x94),
CELL_VOLTAGES(0x95),
CELL_TEMPERATURES(0x96),
}
/** Adresse des Anfragenden. 0x80 = Bluetooth-Modul. */
const val HOST_ADDRESS = 0x80
fun requestFrame(command: Command): ByteArray {
val frame = ByteArray(13)
frame[0] = 0xA5.toByte()
frame[1] = HOST_ADDRESS.toByte()
frame[2] = command.raw.toByte()
frame[3] = 0x08
// Bytes 4..11 bleiben null.
var sum = 0
for (i in 0 until 12) sum = (sum + (frame[i].toInt() and 0xFF)) and 0xFF
frame[12] = sum.toByte()
return frame
}
class Frame(val address: Int, val command: Int, val payload: ByteArray)
/**
* Sucht vollständige, prüfsummenkorrekte A5-Rahmen im Puffer und gibt sie
* zusammen mit dem unverbrauchten Rest zurück.
*/
fun extractA5Frames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var lastConsumed = 0
while (index + 13 <= buffer.size) {
if (buffer.u(index) != 0xA5 || buffer.u(index + 3) != 0x08) {
index += 1
continue
}
var checksum = 0
for (i in index until index + 12) checksum = (checksum + buffer.u(i)) and 0xFF
if (checksum != buffer.u(index + 12)) {
index += 1
continue
}
frames.add(
Frame(
address = buffer.u(index + 1),
command = buffer.u(index + 2),
payload = buffer.copyOfRange(index + 4, index + 12),
)
)
index += 13
lastConsumed = index
}
// Angefangene Rahmen aufheben BLE liefert Antworten oft gestückelt.
val keepFrom = maxOf(lastConsumed, maxOf(0, buffer.size - 64))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
// MARK: - Modbus (D2)
/** Ein Lesekommando über alle interessanten Register. */
fun modbusReadFrame(start: Int = 0, count: Int = 62): ByteArray {
val frame = byteArrayOf(
0xD2.toByte(), 0x03,
(start shr 8).toByte(), (start and 0xFF).toByte(),
(count shr 8).toByte(), (count and 0xFF).toByte(),
)
val crc = crc16Modbus(frame)
return frame + byteArrayOf((crc and 0xFF).toByte(), (crc shr 8).toByte())
}
fun crc16Modbus(bytes: ByteArray): Int {
var crc = 0xFFFF
for (byte in bytes) {
crc = crc xor (byte.toInt() and 0xFF)
repeat(8) {
crc = if (crc and 1 != 0) (crc shr 1) xor 0xA001 else crc shr 1
}
}
return crc and 0xFFFF
}
/**
* Prüft einen vollständigen Modbus-Antwortrahmen und liefert die
* Registerwerte. Gibt null zurück, solange der Rahmen unvollständig ist.
*/
fun parseModbusResponse(buffer: ByteArray): IntArray? {
if (buffer.size < 5 || buffer.u(0) != 0xD2 || buffer.u(1) != 0x03) return null
val byteCount = buffer.u(2)
val total = 3 + byteCount + 2
if (buffer.size < total) return null
val body = buffer.copyOfRange(0, 3 + byteCount)
val expected = crc16Modbus(body)
val actual = buffer.u(3 + byteCount) or (buffer.u(4 + byteCount) shl 8)
if (expected != actual) return null
val registers = IntArray(byteCount / 2)
for (i in registers.indices) {
registers[i] = (body.u(3 + i * 2) shl 8) or body.u(3 + i * 2 + 1)
}
return registers
}
}
@@ -0,0 +1,223 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* Sammelt die Antworten eines Daly-BMS. Das klassische Protokoll verteilt die
* Werte auf mehrere Rahmen, deshalb wird hier über Abfragerunden hinweg
* akkumuliert und erst am Ende ein Snapshot gebaut.
*/
class DalyState {
var totalVoltage: Double? = null
var current: Double? = null
var soc: Double? = null
var maxCellMillivolts: Int? = null
var maxCellNumber: Int? = null
var minCellMillivolts: Int? = null
var minCellNumber: Int? = null
var maxTemperature: Double? = null
var minTemperature: Double? = null
var chargeMOSOn: Boolean? = null
var dischargeMOSOn: Boolean? = null
var chargeDischargeStatus: Int? = null
var remainingCapacityAh: Double? = null
var cellCount: Int? = null
var temperatureSensorCount: Int? = null
var cycles: Int? = null
/** Zellnummer (1-basiert) → Spannung in Millivolt. */
val cellMillivolts = sortedMapOf<Int, Int>()
/** Sensornummer (1-basiert) → Temperatur in °C. */
val sensorTemperatures = sortedMapOf<Int, Double>()
var usesModbus = false
// MARK: - Klassisches Protokoll
fun apply(frame: DalyProtocol.Frame) {
val d = frame.payload
fun u16(i: Int) = (d.u(i) shl 8) or d.u(i + 1)
when (frame.command) {
0x90 -> {
totalVoltage = u16(0) * 0.1
// Strom mit Offset 30000, damit Entladung negativ dargestellt wird.
current = (u16(4) - 30000) * 0.1
soc = u16(6) * 0.1
}
0x91 -> {
maxCellMillivolts = u16(0)
maxCellNumber = d.u(2)
minCellMillivolts = u16(3)
minCellNumber = d.u(5)
}
0x92 -> {
maxTemperature = (d.u(0) - 40).toDouble()
minTemperature = (d.u(2) - 40).toDouble()
}
0x93 -> {
chargeDischargeStatus = d.u(0)
chargeMOSOn = d.u(1) == 1
dischargeMOSOn = d.u(2) == 1
val capacityMilliAh = (d.u(4).toLong() shl 24) or (d.u(5).toLong() shl 16) or
(d.u(6).toLong() shl 8) or d.u(7).toLong()
remainingCapacityAh = capacityMilliAh / 1000.0
}
0x94 -> {
cellCount = d.u(0)
temperatureSensorCount = d.u(1)
cycles = u16(6)
}
0x95 -> {
// d[0] = Rahmennummer (1-basiert), danach drei Zellen à 2 Byte.
val frameNumber = d.u(0)
if (frameNumber > 0) {
for (slot in 0 until 3) {
val cell = (frameNumber - 1) * 3 + slot + 1
val millivolts = u16(1 + slot * 2)
if (millivolts in 1..5999) cellMillivolts[cell] = millivolts
}
}
}
0x96 -> {
val frameNumber = d.u(0)
if (frameNumber > 0) {
for (slot in 0 until 7) {
val sensor = (frameNumber - 1) * 7 + slot + 1
val raw = d.u(1 + slot)
if (raw != 0) sensorTemperatures[sensor] = (raw - 40).toDouble()
}
}
}
}
}
// MARK: - Modbus-Protokoll
/**
* Registerbelegung der neueren Daly-BMS.
*
* Achtung: Dieses Mapping variiert zwischen Firmwareständen. Die
* Detailansicht zeigt deshalb die Rohantwort an, damit sich die Belegung
* am realen Gerät nachprüfen lässt.
*/
fun apply(registers: IntArray) {
usesModbus = true
fun reg(i: Int): Int? = if (i < registers.size) registers[i] else null
// Register 047: Zellspannungen in mV, unbenutzte Plätze sind 0.
cellMillivolts.clear()
for (i in 0 until minOf(48, registers.size)) {
val millivolts = registers[i]
if (millivolts in 501..4999) cellMillivolts[i + 1] = millivolts
}
// Register 4855: Temperaturfühler mit Offset 40.
sensorTemperatures.clear()
for (i in 48 until minOf(56, registers.size)) {
val raw = registers[i]
if (raw in 1..199) sensorTemperatures[i - 47] = (raw - 40).toDouble()
}
reg(56)?.let { if (it > 0) totalVoltage = it * 0.1 }
reg(57)?.let { current = (it - 30000) * 0.1 }
reg(58)?.let { if (it <= 1000) soc = it * 0.1 }
maxCellMillivolts = cellMillivolts.values.maxOrNull()
minCellMillivolts = cellMillivolts.values.minOrNull()
maxCellNumber = cellMillivolts.maxByOrNull { it.value }?.key
minCellNumber = cellMillivolts.minByOrNull { it.value }?.key
maxTemperature = sensorTemperatures.values.maxOrNull()
minTemperature = sensorTemperatures.values.minOrNull()
cellCount = cellMillivolts.size.takeIf { it > 0 }
temperatureSensorCount = sensorTemperatures.size.takeIf { it > 0 }
}
// MARK: - Ausgabe
val hasUsableData: Boolean
get() = totalVoltage != null || soc != null || cellMillivolts.isNotEmpty()
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 1, isPrimary = true),
Metric("voltage", "Spannung", totalVoltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 1),
)
val v = totalVoltage
val a = current
if (v != null && a != null) {
metrics.add(Metric("power", "Leistung", v * a, "W", precision = 0))
}
remainingCapacityAh?.let {
metrics.add(Metric("capacity", "Restkapazität", it, "Ah", precision = 1))
}
val maxV = maxCellMillivolts
val minV = minCellMillivolts
if (maxV != null && minV != null) {
metrics.add(Metric("cell_delta", "Zell-Differenz", (maxV - minV).toDouble(), "mV", precision = 0))
metrics.add(
Metric("cell_max", "Höchste Zelle" + numberSuffix(maxCellNumber),
maxV / 1000.0, "V", precision = 3)
)
metrics.add(
Metric("cell_min", "Niedrigste Zelle" + numberSuffix(minCellNumber),
minV / 1000.0, "V", precision = 3)
)
}
maxTemperature?.let {
metrics.add(Metric("temp_max", "Temperatur", it, "°C", precision = 0))
}
minTemperature?.let {
if (it != maxTemperature) {
metrics.add(Metric("temp_min", "Temperatur min.", it, "°C", precision = 0))
}
}
cycles?.let {
metrics.add(Metric("cycles", "Ladezyklen", it.toDouble(), "", precision = 0))
}
val warnings = mutableListOf<String>()
if (chargeMOSOn == false) warnings.add("Lade-MOSFET aus")
if (dischargeMOSOn == false) warnings.add("Entlade-MOSFET aus")
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
state = stateText,
cellVoltages = cellMillivolts.values.map { it / 1000.0 },
temperatures = sensorTemperatures.values.toList(),
offReasons = warnings,
)
}
private fun numberSuffix(number: Int?): String = number?.let { " (Zelle $it)" } ?: ""
private val stateText: String?
get() {
when (chargeDischargeStatus) {
0 -> return "Ruhend"
1 -> return "Lädt"
2 -> return "Entlädt"
}
val c = current ?: return null
return when {
c > 0.3 -> "Lädt"
c < -0.3 -> "Entlädt"
else -> "Ruhend"
}
}
}
@@ -0,0 +1,86 @@
package de.fritob.campermonitor.protocol
import java.util.Locale
import java.util.UUID
/** Eine einzelne Messgrösse in einer bereits formatierten Form. */
data class Metric(
val key: String,
val label: String,
val value: Double?,
val unit: String,
/** Nachkommastellen für die Anzeige. */
val precision: Int = 2,
/** Wird auf der Kachel gross dargestellt. */
val isPrimary: Boolean = false,
) {
val formatted: String
get() = value?.let { String.format(Locale.GERMANY, "%.${precision}f", it) } ?: ""
val formattedWithUnit: String
get() = when {
value == null -> ""
unit.isEmpty() -> formatted
else -> "$formatted $unit"
}
}
/** Feste Angaben des Geräts, etwa Modell oder Seriennummer. */
data class InfoItem(val label: String, val value: String)
/**
* Der komplette, zuletzt empfangene Zustand eines Geräts.
*
* Der Zeitstempel ist die Zeit in Millisekunden seit 1970 statt eines
* `java.time`-Typs: das läuft ohne Rücksicht auf die Android-Version und lässt
* sich in Prüfungen vorgeben, statt von der Uhr abzuhängen.
*/
data class DeviceSnapshot(
val deviceID: UUID,
val timestamp: Long,
val metrics: List<Metric> = emptyList(),
/** z.B. "Bulk", "Float", "Aus" */
val state: String? = null,
/** Klartext einer aktiven Störung, sonst null. */
val fault: String? = null,
/** Grund, warum das Gerät gerade nicht lädt (Victron Off-Reason). */
val offReasons: List<String> = emptyList(),
val rssi: Int? = null,
/** Einzelzellspannungen in Volt (nur BMS). */
val cellVoltages: List<Double> = emptyList(),
/** Temperaturfühler in °C (nur BMS). */
val temperatures: List<Double> = emptyList(),
val info: List<InfoItem> = emptyList(),
) {
val primaryMetric: Metric?
get() = metrics.firstOrNull { it.isPrimary } ?: metrics.firstOrNull()
fun ageInSeconds(now: Long = System.currentTimeMillis()): Double =
(now - timestamp) / 1000.0
/**
* Werte älter als eine Minute gelten als veraltet Victron sendet etwa
* jede Sekunde, das BMS wird alle paar Sekunden abgefragt.
*/
fun isStale(now: Long = System.currentTimeMillis()): Boolean = ageInSeconds(now) > 60
}
/** Verbindungszustand für die Oberfläche. */
sealed class DeviceLinkState {
data object Idle : DeviceLinkState()
data object Searching : DeviceLinkState()
data object Connecting : DeviceLinkState()
data object Live : DeviceLinkState()
data object NeedsKey : DeviceLinkState()
data class Failed(val message: String) : DeviceLinkState()
val label: String
get() = when (this) {
Idle -> "Inaktiv"
Searching -> "Suche…"
Connecting -> "Verbinde…"
Live -> "Live"
NeedsKey -> "Schlüssel fehlt"
is Failed -> message
}
}
@@ -0,0 +1,212 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* Protokoll der JBD-/Xiaoxiang-BMS, wie sie unter anderem in WattCycle-Akkus
* verbaut sind. Bekannt auch als „Smart BMS" oder Overkill-Solar-Protokoll.
*
* Rahmenaufbau:
* ```
* Anfrage: DD A5 <Kommando> <Länge=00> <Prüfsumme 2 Byte> 77
* Antwort: DD <Kommando> <Status> <Länge> <Daten…> <Prüfsumme 2 Byte> 77
* ```
*
* Die Prüfsumme ist `0x10000 (Status + Länge + Daten)`, big-endian; in der
* Anfrage entsprechend über Kommando und Länge.
*/
object JbdProtocol {
enum class Command(val raw: Int) {
BASIC_INFO(0x03),
CELL_VOLTAGES(0x04),
}
fun requestFrame(command: Command): ByteArray {
val checksum = checksum(byteArrayOf(command.raw.toByte(), 0x00))
return byteArrayOf(
0xDD.toByte(), 0xA5.toByte(), command.raw.toByte(), 0x00,
(checksum shr 8).toByte(), (checksum and 0xFF).toByte(), 0x77,
)
}
fun checksum(bytes: ByteArray): Int {
var sum = 0
for (b in bytes) sum += b.toInt() and 0xFF
return (0x1_0000 - sum) and 0xFFFF
}
class Frame(val command: Int, val payload: ByteArray)
/** Sucht vollständige, prüfsummenkorrekte Rahmen im Puffer. */
fun extractFrames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var consumed = 0
while (index + 7 <= buffer.size) {
if (buffer.u(index) != 0xDD) {
index += 1
continue
}
val length = buffer.u(index + 3)
val total = 4 + length + 3 // Kopf + Daten + Prüfsumme + 0x77
if (index + total > buffer.size) break // Rest abwarten
val frame = buffer.copyOfRange(index, index + total)
if (frame.u(total - 1) != 0x77) {
index += 1
continue
}
val expected = checksum(frame.copyOfRange(2, 4 + length))
val actual = (frame.u(4 + length) shl 8) or frame.u(5 + length)
if (expected != actual) {
index += 1
continue
}
// Status ≠ 0 meldet einen Fehler; der Rahmen ist dann leer.
if (frame.u(2) == 0x00) {
frames.add(Frame(frame.u(1), frame.copyOfRange(4, 4 + length)))
}
index += total
consumed = index
}
val keepFrom = maxOf(consumed, maxOf(0, buffer.size - 128))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
/** Klartext der Schutzabschaltungen aus der 16-Bit-Maske. */
fun protectionReasons(mask: Int): List<String> {
if (mask == 0) return emptyList()
return PROTECTIONS.filter { mask and it.first != 0 }.map { it.second }
}
private val PROTECTIONS = listOf(
(1 shl 0) to "Zellüberspannung",
(1 shl 1) to "Zellunterspannung",
(1 shl 2) to "Batterie Überspannung",
(1 shl 3) to "Batterie Unterspannung",
(1 shl 4) to "Ladetemperatur zu hoch",
(1 shl 5) to "Ladetemperatur zu niedrig",
(1 shl 6) to "Entladetemperatur zu hoch",
(1 shl 7) to "Entladetemperatur zu niedrig",
(1 shl 8) to "Ladestrom zu hoch",
(1 shl 9) to "Entladestrom zu hoch",
(1 shl 10) to "Kurzschluss",
(1 shl 11) to "Fehler im Messkreis",
(1 shl 12) to "MOSFET gesperrt",
)
}
/** Sammelt die Antworten eines JBD-BMS. */
class JbdState {
var totalVoltage: Double? = null
var current: Double? = null
var remainingCapacityAh: Double? = null
var nominalCapacityAh: Double? = null
var cycles: Int? = null
var soc: Double? = null
var chargeMOSOn: Boolean? = null
var dischargeMOSOn: Boolean? = null
var protections: List<String> = emptyList()
var cellMillivolts: List<Int> = emptyList()
var temperatures: List<Double> = emptyList()
val hasUsableData: Boolean
get() = totalVoltage != null || soc != null || cellMillivolts.isNotEmpty()
fun apply(frame: JbdProtocol.Frame) {
val d = frame.payload
fun u16(i: Int) = (d.u(i) shl 8) or d.u(i + 1)
fun i16(i: Int) = u16(i).toShort().toInt()
when (frame.command) {
0x03 -> {
if (d.size < 23) return
totalVoltage = u16(0) * 0.01 // 10 mV je Schritt
current = i16(2) * 0.01 // 10 mA, negativ = Entladung
remainingCapacityAh = u16(4) * 0.01
nominalCapacityAh = u16(6) * 0.01
cycles = u16(8)
protections = JbdProtocol.protectionReasons(u16(16))
soc = d.u(19).toDouble()
chargeMOSOn = d.u(20) and 0x01 != 0
dischargeMOSOn = d.u(20) and 0x02 != 0
// Ab Byte 23 folgen die NTC-Fühler, je zwei Byte in Zehntel-Kelvin.
val sensorCount = d.u(22)
val readings = mutableListOf<Double>()
for (sensor in 0 until sensorCount) {
val offset = 23 + sensor * 2
if (offset + 1 >= d.size) break
readings.add((u16(offset) - 2731) / 10.0)
}
temperatures = readings
}
0x04 -> {
val millivolts = mutableListOf<Int>()
var offset = 0
while (offset + 1 < d.size) {
millivolts.add(u16(offset))
offset += 2
}
cellMillivolts = millivolts
}
}
}
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 0, isPrimary = true),
Metric("voltage", "Spannung", totalVoltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 1),
)
val v = totalVoltage
val a = current
if (v != null && a != null) {
metrics.add(Metric("power", "Leistung", v * a, "W", precision = 0))
}
remainingCapacityAh?.let {
metrics.add(Metric("capacity", "Restkapazität", it, "Ah", precision = 1))
}
nominalCapacityAh?.let {
metrics.add(Metric("capacity_nominal", "Nennkapazität", it, "Ah", precision = 1))
}
val maxV = cellMillivolts.maxOrNull()
val minV = cellMillivolts.minOrNull()
if (maxV != null && minV != null) {
metrics.add(Metric("cell_delta", "Zell-Differenz", (maxV - minV).toDouble(), "mV", precision = 0))
metrics.add(Metric("cell_max", "Höchste Zelle", maxV / 1000.0, "V", precision = 3))
metrics.add(Metric("cell_min", "Niedrigste Zelle", minV / 1000.0, "V", precision = 3))
}
temperatures.maxOrNull()?.let {
metrics.add(Metric("temp_max", "Temperatur", it, "°C", precision = 0))
}
cycles?.let {
metrics.add(Metric("cycles", "Ladezyklen", it.toDouble(), "", precision = 0))
}
val notes = mutableListOf<String>()
if (chargeMOSOn == false) notes.add("Laden gesperrt")
if (dischargeMOSOn == false) notes.add("Entladen gesperrt")
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
cellVoltages = cellMillivolts.map { it / 1000.0 },
temperatures = temperatures,
fault = protections.takeIf { it.isNotEmpty() }?.joinToString(", "),
offReasons = notes,
state = current?.let {
when {
it > 0.3 -> "Lädt"
it < -0.3 -> "Entlädt"
else -> "Ruhend"
}
},
)
}
}
@@ -0,0 +1,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,
)
)
}
}
@@ -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
}
}
@@ -0,0 +1,311 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* Dekodiert die "Instant Readout"-Werbedaten von Victron-Geräten.
*
* Aufbau der Herstellerdaten (inkl. der zwei Bytes Company-ID):
* ```
* [0..1] E1 02 Company-ID 0x02E1 (Victron Energy)
* [2..3] 10 00 Record-Typ "Product Advertisement", 16 Bit
* [4..5] ll hh Produkt-ID, little-endian
* [6] rr Art des Datensatzes (Solarlader, DC/DC, …)
* [7..8] ll hh Nonce / Zähler, little-endian
* [9] kk Erstes Byte des Geräteschlüssels (Prüfbyte)
* [10..] Mit AES-128-CTR verschlüsselte Nutzdaten
* ```
*
* Die Aufteilung ist an einem Orion XS belegt: Datensatztyp 0x0F passt zum
* Gerät, das Prüfbyte zum hinterlegten Schlüssel, und die verbleibenden
* 14 Byte entsprechen genau der Länge eines Orion-XS-Datensatzes.
*
* **Achtung, Unterschied zu iOS:** Android liefert die Herstellerdaten ohne
* die zwei Bytes der Company-ID die steht dort im Schlüssel der
* `manufacturerSpecificData`-Tabelle. Die Bluetooth-Schicht setzt sie wieder
* davor, damit hier derselbe Rahmen ankommt wie unter iOS und dieselben
* Prüfungen gelten.
*/
object VictronAdvertisement {
const val COMPANY_IDENTIFIER = 0x02E1
enum class RecordType(val raw: Int) {
SOLAR_CHARGER(0x01),
BATTERY_MONITOR(0x02),
INVERTER(0x03),
DCDC_CONVERTER(0x04),
SMART_LITHIUM(0x05),
INVERTER_RS(0x06),
GX_DEVICE(0x07),
AC_CHARGER(0x08),
SMART_BATTERY_PROTECT(0x09),
LYNX_SMART_BMS(0x0A),
MULTI_RS(0x0B),
VE_BUS(0x0C),
DC_ENERGY_METER(0x0D),
ORION_XS(0x0F);
companion object {
fun of(raw: Int): RecordType? = entries.firstOrNull { it.raw == raw }
}
}
sealed class DecodeError(val describe: String) : Exception(describe) {
data object NotVictron : DecodeError("Kein Victron-Advertisement")
data object Malformed : DecodeError("Advertisement zu kurz")
class KeyMismatch(val expected: Int, val got: Int) : DecodeError(
"Schlüssel passt nicht: Das Gerät sendet 0x%02X als erstes Byte, ".format(expected) +
"der eingetragene Schlüssel beginnt mit 0x%02X.".format(got)
)
data object DecryptionFailed : DecodeError("Entschlüsselung fehlgeschlagen")
class UnsupportedRecord(val record: Int) :
DecodeError("Datensatz-Typ 0x%02X wird nicht unterstützt".format(record))
}
/**
* Der unverschlüsselte Rahmen lässt sich auch ohne Schlüssel lesen und
* wird beim Einrichten benutzt, um Victron-Geräte zu erkennen.
*/
data class Envelope(
val productID: Int,
val recordType: Int,
val nonce: Int,
val keyCheckByte: Int,
val ciphertext: ByteArray,
) {
val knownRecord: RecordType? get() = RecordType.of(recordType)
val productIDText: String get() = "0x%04X".format(productID)
// ByteArray hat keine sinnvolle Gleichheit; die data class braucht sie.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Envelope) return false
return productID == other.productID && recordType == other.recordType &&
nonce == other.nonce && keyCheckByte == other.keyCheckByte &&
ciphertext.contentEquals(other.ciphertext)
}
override fun hashCode(): Int {
var result = productID
result = 31 * result + recordType
result = 31 * result + nonce
result = 31 * result + keyCheckByte
result = 31 * result + ciphertext.contentHashCode()
return result
}
}
fun envelope(manufacturerData: ByteArray): Envelope? {
if (manufacturerData.size < 11) return null
val company = manufacturerData.u(0) or (manufacturerData.u(1) shl 8)
if (company != COMPANY_IDENTIFIER || manufacturerData.u(2) != 0x10) return null
return Envelope(
productID = manufacturerData.u(4) or (manufacturerData.u(5) shl 8),
recordType = manufacturerData.u(6),
nonce = manufacturerData.u(7) or (manufacturerData.u(8) shl 8),
keyCheckByte = manufacturerData.u(9),
ciphertext = manufacturerData.copyOfRange(10, manufacturerData.size),
)
}
/** Entschlüsselt und interpretiert ein Advertisement. */
fun decode(
manufacturerData: ByteArray,
key: ByteArray,
deviceID: UUID,
rssi: Int?,
now: Long = System.currentTimeMillis(),
): DeviceSnapshot {
val envelope = envelope(manufacturerData) ?: throw DecodeError.NotVictron
if (key.size != 16) throw DecodeError.Malformed
if (key.u(0) != envelope.keyCheckByte) {
throw DecodeError.KeyMismatch(expected = envelope.keyCheckByte, got = key.u(0))
}
// Zählerblock: Nonce little-endian in den ersten zwei Bytes, Rest null.
val counter = ByteArray(16)
counter[0] = (envelope.nonce and 0xFF).toByte()
counter[1] = (envelope.nonce shr 8).toByte()
val plain = AesCounterMode.crypt(envelope.ciphertext, key, counter)
?: throw DecodeError.DecryptionFailed
val base = DeviceSnapshot(deviceID = deviceID, timestamp = now, rssi = rssi)
return when (envelope.knownRecord) {
RecordType.SOLAR_CHARGER -> solarCharger(plain, base)
RecordType.DCDC_CONVERTER -> dcdcConverter(plain, base)
RecordType.ORION_XS -> orionXS(plain, base)
RecordType.BATTERY_MONITOR -> batteryMonitor(plain, base)
RecordType.AC_CHARGER -> acCharger(plain, base)
else -> throw DecodeError.UnsupportedRecord(envelope.recordType)
}
}
// MARK: - Datensätze
/** 0x01 Solarladeregler (SmartSolar / BlueSolar MPPT). */
private fun solarCharger(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val batteryVoltage = r.scaledSigned(16, 0.01)
val batteryCurrent = r.scaledSigned(16, 0.1)
val yieldToday = r.scaled(16, 0.01)
val pvPower = r.scaled(16, 1.0)
val loadCurrent = r.scaled(9, 0.1)
val metrics = mutableListOf(
Metric("pv_power", "PV-Leistung", pvPower, "W", precision = 0, isPrimary = true),
Metric("battery_voltage", "Batteriespannung", batteryVoltage, "V", precision = 2),
Metric("battery_current", "Ladestrom", batteryCurrent, "A", precision = 1),
Metric("yield_today", "Ertrag heute", yieldToday, "kWh", precision = 2),
Metric("load_current", "Laststrom", loadCurrent, "A", precision = 1),
)
if (batteryVoltage != null && batteryCurrent != null) {
metrics.add(
1,
Metric("battery_power", "Ladeleistung", batteryVoltage * batteryCurrent, "W", precision = 0)
)
}
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
metrics = metrics,
)
}
/** 0x04 DC/DC-Wandler (Orion-TR Smart Ladebooster). */
private fun dcdcConverter(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val inputVoltage = r.scaled(16, 0.01)
val outputVoltage = r.scaledSigned(16, 0.01)
val offReason = r.readOptional(32)
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
offReasons = VictronCodes.offReasons(offReason),
metrics = listOf(
Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, "V",
precision = 2, isPrimary = true),
Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, "V", precision = 2),
),
)
}
/** 0x0F Orion XS. Sendet im Gegensatz zum Orion-TR auch Ströme. */
private fun orionXS(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val outputVoltage = r.scaled(16, 0.01)
val outputCurrent = r.scaledSigned(16, 0.1)
val inputVoltage = r.scaled(16, 0.01)
val inputCurrent = r.scaledSigned(16, 0.1)
val offReason = r.readOptional(32)
val metrics = mutableListOf<Metric>()
if (outputVoltage != null && outputCurrent != null) {
metrics.add(
Metric("output_power", "Ladeleistung", outputVoltage * outputCurrent, "W",
precision = 0, isPrimary = true)
)
}
metrics += listOf(
Metric("output_voltage", "Ausgang (Aufbaubatterie)", outputVoltage, "V",
precision = 2, isPrimary = outputCurrent == null),
Metric("output_current", "Ladestrom", outputCurrent, "A", precision = 1),
Metric("input_voltage", "Eingang (Starterbatterie)", inputVoltage, "V", precision = 2),
Metric("input_current", "Eingangsstrom", inputCurrent, "A", precision = 1),
)
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
offReasons = VictronCodes.offReasons(offReason),
metrics = metrics,
)
}
/** 0x08 AC-Ladegerät (Blue Smart IP65/IP22), falls im Camper verbaut. */
private fun acCharger(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val state = r.readOptional(8)
val error = r.readOptional(8)
val voltage1 = r.scaled(13, 0.01)
val current1 = r.scaled(11, 0.1)
val voltage2 = r.scaled(13, 0.01)
val current2 = r.scaled(11, 0.1)
val voltage3 = r.scaled(13, 0.01)
val current3 = r.scaled(11, 0.1)
val temperature = r.scaled(7, 1.0)
val acCurrent = r.scaled(9, 0.1)
return base.copy(
state = VictronCodes.deviceState(state),
fault = VictronCodes.chargerError(error),
metrics = listOf(
Metric("out1_voltage", "Ausgang 1 Spannung", voltage1, "V", precision = 2, isPrimary = true),
Metric("out1_current", "Ausgang 1 Strom", current1, "A", precision = 1),
Metric("out2_voltage", "Ausgang 2 Spannung", voltage2, "V", precision = 2),
Metric("out2_current", "Ausgang 2 Strom", current2, "A", precision = 1),
Metric("out3_voltage", "Ausgang 3 Spannung", voltage3, "V", precision = 2),
Metric("out3_current", "Ausgang 3 Strom", current3, "A", precision = 1),
Metric("ac_current", "AC-Eingangsstrom", acCurrent, "A", precision = 1),
),
temperatures = temperature?.let { listOf(it - 40) } ?: emptyList(),
)
}
/** 0x02 Batteriewächter (SmartShunt, BMV). */
private fun batteryMonitor(bytes: ByteArray, base: DeviceSnapshot): DeviceSnapshot {
val r = BitReader(bytes)
val timeToGo = r.scaled(16, 1.0) // Minuten
val voltage = r.scaledSigned(16, 0.01)
val alarm = r.readOptional(16)
val auxRaw = r.read(16)
val auxType = r.read(2)
val current = r.scaledSigned(22, 0.001)
val consumedAh = r.scaled(20, 0.1)
val soc = r.scaled(10, 0.1)
val alarms = VictronCodes.alarmReasons(alarm)
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 1, isPrimary = true),
Metric("voltage", "Spannung", voltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 2),
)
if (voltage != null && current != null) {
metrics.add(Metric("power", "Leistung", voltage * current, "W", precision = 0))
}
// Entnommene Kapazität wird positiv gesendet, ist aber eine Entnahme.
metrics.add(Metric("consumed", "Entnommen", consumedAh?.let { -it }, "Ah", precision = 1))
if (timeToGo != null && timeToGo < 65535) {
metrics.add(Metric("ttg", "Restlaufzeit", timeToGo / 60, "h", precision = 1))
}
// Der Hilfseingang ist je nach Konfiguration Starterbatterie,
// Mittenspannung oder Temperatur.
var temperatures = emptyList<Double>()
if (auxRaw != null && auxType != null && auxRaw != 0xFFFFL) {
when (auxType.toInt()) {
0 -> {
val starter = auxRaw.toShort().toInt() * 0.01
metrics.add(Metric("aux_starter", "Starterbatterie", starter, "V", precision = 2))
}
1 -> metrics.add(Metric("aux_mid", "Mittenspannung", auxRaw * 0.01, "V", precision = 2))
2 -> temperatures = listOf(auxRaw * 0.01 - 273.15)
}
}
return base.copy(
state = null,
fault = if (alarms.isEmpty()) null else alarms.joinToString(", "),
metrics = metrics,
temperatures = temperatures,
)
}
}
@@ -0,0 +1,104 @@
package de.fritob.campermonitor.protocol
/** Klartexte für die Zustands- und Fehlercodes aus den Victron-Werbedaten. */
object VictronCodes {
/** VE.Reg 0x0201 Betriebszustand des Laders. */
fun deviceState(code: Long?): String? {
if (code == null) return null
return when (code.toInt()) {
0 -> "Aus"
1 -> "Stromsparmodus"
2 -> "Störung"
3 -> "Konstantstrom (Bulk)"
4 -> "Konstantspannung (Absorption)"
5 -> "Erhaltung (Float)"
6 -> "Lagerung"
7 -> "Ausgleichsladung"
9 -> "Wechselrichten"
11 -> "Netzteilbetrieb"
245 -> "Startet"
246 -> "Wiederholte Absorption"
247 -> "Auto-Ausgleich"
248 -> "Battery Safe"
252 -> "Externe Steuerung"
else -> "Zustand $code"
}
}
/** VE.Reg 0xEDDA Ladefehler. 0 bedeutet "kein Fehler". */
fun chargerError(code: Long?): String? {
if (code == null || code == 0L) return null
return when (code.toInt()) {
1 -> "Batterietemperatur zu hoch"
2 -> "Batteriespannung zu hoch"
3 -> "Temperatursensor defekt"
4 -> "Temperatursensor Kurzschluss"
5 -> "Temperatursensor unplausibel"
6 -> "Spannungsmessung defekt"
7 -> "Spannungsmessung Kurzschluss"
8 -> "Spannungsmessung unplausibel"
11 -> "Zu hohe Restwelligkeit"
14 -> "Batterietemperatur zu niedrig"
17 -> "Lader überhitzt"
18 -> "Lader Überstrom"
19 -> "Stromrichtung verkehrt"
20 -> "Bulk-Zeit überschritten"
21 -> "Stromsensor defekt"
22 -> "Interner Temperatursensor defekt"
26 -> "Anschlussklemme überhitzt"
27 -> "Kurzschluss im Lader"
28 -> "Endstufenfehler"
29 -> "Überladeschutz"
33 -> "Eingangsspannung zu hoch (PV)"
34 -> "Eingangsstrom zu hoch (PV)"
38 -> "Eingang abgeschaltet (Batteriespannung)"
39 -> "Eingang abgeschaltet (Stromfluss)"
65 -> "Kommunikation verloren"
66 -> "Konfiguration synchronisierter Lader fehlerhaft"
67 -> "BMS-Verbindung verloren"
68 -> "Netzwerk fehlkonfiguriert"
116 -> "Kalibrierdaten verloren"
117 -> "Inkompatible Firmware"
119 -> "Einstellungen ungültig"
else -> "Fehler $code"
}
}
/** VE.Reg 0x0207 Bitmaske, warum das Gerät gerade nicht arbeitet. */
fun offReasons(mask: Long?): List<String> {
if (mask == null || mask == 0L) return emptyList()
return OFF_REASONS.filter { mask and it.first != 0L }.map { it.second }
}
private val OFF_REASONS = listOf(
0x0000_0001L to "Keine Eingangsspannung",
0x0000_0002L to "Per Schalter ausgeschaltet",
0x0000_0004L to "Per Einstellung ausgeschaltet",
0x0000_0008L to "Remote-Eingang",
0x0000_0010L to "Schutzfunktion aktiv",
0x0000_0020L to "Paygo",
0x0000_0040L to "BMS",
0x0000_0080L to "Motor-Abschalterkennung",
0x0000_0100L to "Eingangsspannung wird geprüft",
)
/** VE.Reg 0xEEB8 Alarmgründe des Batteriewächters. */
fun alarmReasons(mask: Long?): List<String> {
if (mask == null || mask == 0L) return emptyList()
return ALARM_REASONS.filter { mask and it.first != 0L }.map { it.second }
}
private val ALARM_REASONS = listOf(
0x0001L to "Unterspannung",
0x0002L to "Überspannung",
0x0004L to "Niedriger Ladezustand",
0x0008L to "Starterbatterie Unterspannung",
0x0010L to "Starterbatterie Überspannung",
0x0020L to "Temperatur zu niedrig",
0x0040L to "Temperatur zu hoch",
0x0080L to "Mittenspannung",
0x0100L to "Ladung überfällig",
)
}
@@ -0,0 +1,281 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
/**
* Protokoll der WattCycle-BLE-Akkus.
*
* Weder Daly noch JBD, sondern ein eigenes Modbus-artiges Format. Zwei
* Besonderheiten:
*
* * Vor der ersten Abfrage muss der ASCII-Text `HiLink` auf eine eigene
* Freischalt-Charakteristik (`FFFA`) geschrieben werden. Ohne das bleibt
* der Akku auf jede Anfrage stumm.
* * Anfragen gehen auf `FFF2`, Antworten kommen über `FFF1`.
*
* Rahmenaufbau:
* ```
* Anfrage (11 Byte):
* 1E 00 01 03 <Datenpunkt 2 Byte> 00 00 <CRC16 2 Byte> 0D
* Antwort:
* 7E <Ver> <Adr> <Funktion> <Datenpunkt 2 Byte> <Länge 2 Byte>
* <Daten…> <CRC16 2 Byte> 0D
* ```
*
* Die Prüfsumme ist der übliche Modbus-CRC16 über alles vor der Prüfsumme,
* höherwertiges Byte zuerst. Nachgerechnet gegen die Tabellenvariante der
* Referenzimplementierung (frabnet/esphome-wattcycle-ble).
*/
object WattCycleProtocol {
const val FRAME_HEAD_REQUEST = 0x1E
const val FRAME_HEAD_RESPONSE = 0x7E
const val FRAME_TAIL = 0x0D
const val FUNCTION_READ = 0x03
const val FUNCTION_ERROR = 0x86
/** Der Freischalt-Text, der vor der ersten Abfrage geschrieben wird. */
val authPayload: ByteArray = "HiLink".toByteArray(Charsets.US_ASCII)
enum class Datapoint(val raw: Int) {
ANALOG(0x008C), // Messwerte
PRODUCT(0x0092); // Modell, Hersteller, Seriennummer
companion object {
fun of(raw: Int): Datapoint? = entries.firstOrNull { it.raw == raw }
}
}
fun requestFrame(datapoint: Datapoint): ByteArray {
val frame = byteArrayOf(
FRAME_HEAD_REQUEST.toByte(),
0x00, // Version
0x01, // Adresse
FUNCTION_READ.toByte(),
(datapoint.raw shr 8).toByte(),
(datapoint.raw and 0xFF).toByte(),
0x00, 0x00, // Anzahl: 0 liefert den ganzen Datensatz
)
val crc = DalyProtocol.crc16Modbus(frame)
return frame + byteArrayOf((crc shr 8).toByte(), (crc and 0xFF).toByte(), FRAME_TAIL.toByte())
}
class Frame(val function: Int, val datapoint: Int, val payload: ByteArray) {
val isError: Boolean get() = function == FUNCTION_ERROR
}
/** Sucht vollständige, prüfsummenkorrekte Antwortrahmen im Puffer. */
fun extractFrames(buffer: ByteArray): Pair<List<Frame>, ByteArray> {
val frames = mutableListOf<Frame>()
var index = 0
var consumed = 0
while (index + 11 <= buffer.size) {
if (buffer.u(index) != FRAME_HEAD_RESPONSE) {
index += 1
continue
}
val dataLength = (buffer.u(index + 6) shl 8) or buffer.u(index + 7)
val total = dataLength + 11
if (total > 512) {
index += 1
continue
}
if (index + total > buffer.size) break // Rest abwarten
val frame = buffer.copyOfRange(index, index + total)
if (frame.u(total - 1) != FRAME_TAIL) {
index += 1
continue
}
val expected = DalyProtocol.crc16Modbus(frame.copyOfRange(0, total - 3))
val actual = (frame.u(total - 3) shl 8) or frame.u(total - 2)
if (expected != actual) {
index += 1
continue
}
frames.add(
Frame(
function = frame.u(3),
datapoint = (frame.u(4) shl 8) or frame.u(5),
payload = frame.copyOfRange(8, 8 + dataLength),
)
)
index += total
consumed = index
}
val keepFrom = maxOf(consumed, maxOf(0, buffer.size - 256))
return frames to buffer.copyOfRange(keepFrom, buffer.size)
}
/** Temperaturen kommen in Zehntel-Kelvin. */
fun temperature(raw: Int): Double = (raw - 2730) / 10.0
/**
* Der Strom hat ein eigenes Format: Bit 15 ist das Vorzeichen, Bit 14 gibt
* an, ob der Rest in Zehntel-Ampere zu lesen ist, der Rest ist der Betrag.
*/
fun current(high: Int, low: Int): Double {
val isNegative = high and 0x80 != 0
val hasDecimal = high and 0x40 != 0
val magnitude = (low or ((high and 0x3F) shl 8)).toDouble()
val value = if (hasDecimal) magnitude / 10 else magnitude
return if (isNegative) -value else value
}
}
/** Sammelt die Antworten eines WattCycle-Akkus. */
class WattCycleState {
var cellVolts: List<Double> = emptyList()
var mosTemperature: Double? = null
var pcbTemperature: Double? = null
var cellTemperatures: List<Double> = emptyList()
var current: Double? = null
var voltage: Double? = null
var remainingAh: Double? = null
var totalAh: Double? = null
var designAh: Double? = null
var cycles: Int? = null
var soc: Double? = null
var model: String? = null
var manufacturer: String? = null
var serial: String? = null
val hasUsableData: Boolean
get() = voltage != null || soc != null || cellVolts.isNotEmpty()
val hasProductInfo: Boolean
get() = model != null || manufacturer != null || serial != null
fun apply(frame: WattCycleProtocol.Frame) {
if (frame.isError) return
when (WattCycleProtocol.Datapoint.of(frame.datapoint)) {
WattCycleProtocol.Datapoint.ANALOG -> applyAnalog(frame.payload)
WattCycleProtocol.Datapoint.PRODUCT -> applyProduct(frame.payload)
null -> Unit
}
}
/**
* Der Messwert-Datensatz ist selbstbeschreibend: erst die Zellenanzahl,
* dann die Zellspannungen, dann die Fühleranzahl und so weiter. Die
* Feldlängen stehen also nicht fest und werden mitgelesen.
*/
private fun applyAnalog(data: ByteArray) {
var offset = 0
fun readUInt16(): Int? {
if (offset + 1 >= data.size) return null
val value = (data.u(offset) shl 8) or data.u(offset + 1)
offset += 2
return value
}
fun readUInt8(): Int? {
if (offset >= data.size) return null
return data.u(offset).also { offset += 1 }
}
val cellCount = readUInt8() ?: return
val cells = mutableListOf<Double>()
repeat(cellCount) {
val millivolts = readUInt16() ?: return
cells.add(millivolts / 1000.0)
}
cellVolts = cells
// Die ersten beiden Fühler sind MOSFET und Platine, danach die Zellen.
val temperatureCount = readUInt8() ?: return
if (temperatureCount < 2) return
val mos = readUInt16() ?: return
val pcb = readUInt16() ?: return
mosTemperature = WattCycleProtocol.temperature(mos)
pcbTemperature = WattCycleProtocol.temperature(pcb)
val probes = mutableListOf<Double>()
repeat(temperatureCount - 2) {
val raw = readUInt16() ?: return
probes.add(WattCycleProtocol.temperature(raw))
}
cellTemperatures = probes
if (offset + 1 >= data.size) return
current = WattCycleProtocol.current(data.u(offset), data.u(offset + 1))
offset += 2
val voltageRaw = readUInt16() ?: return
voltage = voltageRaw / 100.0
val remaining = readUInt16() ?: return
val total = readUInt16() ?: return
val cycleCount = readUInt16() ?: return
val design = readUInt16() ?: return
val charge = readUInt16() ?: return
remainingAh = remaining / 10.0
totalAh = total / 10.0
cycles = cycleCount
designAh = design / 10.0
soc = charge.toDouble()
}
/** Drei ASCII-Felder à 20 Byte. */
private fun applyProduct(data: ByteArray) {
if (data.size < 60) return
fun text(from: Int, until: Int): String? =
String(data, from, until - from, Charsets.UTF_8)
.trim { it == '\u0000' || it == ' ' }
.takeIf { it.isNotEmpty() }
model = text(0, 20)
manufacturer = text(20, 40)
serial = text(40, 60)
}
fun snapshot(deviceID: UUID, rssi: Int?, now: Long = System.currentTimeMillis()): DeviceSnapshot {
val metrics = mutableListOf(
Metric("soc", "Ladezustand", soc, "%", precision = 0, isPrimary = true),
Metric("voltage", "Spannung", voltage, "V", precision = 2),
Metric("current", "Strom", current, "A", precision = 1),
)
val v = voltage
val a = current
if (v != null && a != null) {
metrics.add(Metric("power", "Leistung", v * a, "W", precision = 0))
}
metrics.add(Metric("capacity", "Restkapazität", remainingAh, "Ah", precision = 1))
totalAh?.let { metrics.add(Metric("capacity_total", "Kapazität geladen", it, "Ah", precision = 1)) }
designAh?.let { metrics.add(Metric("capacity_design", "Nennkapazität", it, "Ah", precision = 1)) }
val maxV = cellVolts.maxOrNull()
val minV = cellVolts.minOrNull()
if (maxV != null && minV != null) {
metrics.add(Metric("cell_delta", "Zell-Differenz", (maxV - minV) * 1000, "mV", precision = 0))
metrics.add(Metric("cell_max", "Höchste Zelle", maxV, "V", precision = 3))
metrics.add(Metric("cell_min", "Niedrigste Zelle", minV, "V", precision = 3))
}
mosTemperature?.let { metrics.add(Metric("temp_mos", "Temperatur MOSFET", it, "°C", precision = 1)) }
pcbTemperature?.let { metrics.add(Metric("temp_pcb", "Temperatur Platine", it, "°C", precision = 1)) }
cycles?.let { metrics.add(Metric("cycles", "Ladezyklen", it.toDouble(), "", precision = 0)) }
val info = mutableListOf<InfoItem>()
model?.let { info.add(InfoItem("Modell / Firmware", it)) }
manufacturer?.let { info.add(InfoItem("Hersteller", it)) }
serial?.let { info.add(InfoItem("Seriennummer", it)) }
return DeviceSnapshot(
deviceID = deviceID,
timestamp = now,
rssi = rssi,
metrics = metrics,
cellVoltages = cellVolts,
temperatures = cellTemperatures,
info = info,
state = current?.let {
when {
it > 0.3 -> "Lädt"
it < -0.3 -> "Entlädt"
else -> "Ruhend"
}
},
)
}
}
@@ -0,0 +1,154 @@
package de.fritob.campermonitor.protocol
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Baut eine Antwort so, wie die Box sie schickt. */
private fun alpicoolResponse(command: Int, payload: ByteArray): ByteArray {
val head = byteArrayOf(0xFE.toByte(), 0xFE.toByte(), (payload.size + 3).toByte(), command.toByte()) + payload
val sum = AlpicoolProtocol.checksum(head)
return head + byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte())
}
private fun stateFrom(payload: ByteArray): AlpicoolState {
val state = AlpicoolState()
val (frames, _) = AlpicoolProtocol.extractFrames(alpicoolResponse(0x01, payload))
frames.forEach { state.apply(it) }
return state
}
class AlpicoolProtocolTest {
/**
* Anmeldung und Abfrage sind in der Referenzimplementierung fest verdrahtet.
* Unsere gerechneten Pakete müssen genau dasselbe ergeben.
*/
@Test
fun `Anmeldung und Abfrage stimmen mit der Referenz ueberein`() {
assertEquals(
"fefe03000 1ff".replace(" ", ""),
AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND).hex()
)
assertEquals(
"fefe030102 00".replace(" ", ""),
AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY).hex()
)
}
/**
* Ein echtes Paket der Maentum/Plug-in Festival IceCube Dual, mitgeschnitten
* beim Ausschalten (Gruni22/alpicool_ha_ble#20). Es muss byteweise
* herauskommen und in zwei Schreibvorgänge zerfallen, weil die Box
* längere nicht annimmt. Genau daran scheiterte das Ein- und Ausschalten.
*/
@Test
fun `Ausschaltbefehl einer echten Zweizonen-Box stimmt byteweise`() {
val payload = byteArrayOf(
0x00, // Bedienfeld frei
0x01, // eingeschaltet
0x01, // Eco
0x02, // Batteriewächter hoch
0x14, // Soll links 20
0x14, 0xEC.toByte(), // Grenzen 20 / -20
0x02, 0x00, 0x00, // Rückschaltdifferenz, Verzögerung, Celsius
0xFD.toByte(), 0xFD.toByte(), 0xFD.toByte(), 0x00,
0x0A, // Ist links 10
0x57, // 87 %
0x0C, 0x06, // 12,6 V
0x14, // Soll rechts 20
0x00, 0x00,
0x02, // Rückschaltdifferenz rechts
0xFD.toByte(), 0xFD.toByte(), 0xFD.toByte(), 0x00,
0x0A, // Ist rechts 10
0x01, // Kompressor läuft
)
val state = stateFrom(payload)
assertTrue(state.isDualZone, "zwei echte Fühler heisst zwei Zonen")
val expected = hexBytes(
"FE FE 1C 02 00 00 01 02 14 14 EC 02 00 00 FD FD FD 00 " +
"14 00 00 02 FD FD FD 00 00 00 00 09 37"
)
assertContentEquals(expected, state.settingsCommand(poweredOn = false))
assertEquals(
listOf(20, 11),
AlpicoolProtocol.chunks(expected, AlpicoolProtocol.MAX_WRITE_SIZE).map { it.size },
"er passt nicht in einen Schreibvorgang",
)
}
/**
* Die Box aus dem Fahrzeug: 30 Byte Nutzlast, aber nur ein Fühler. Sie füllt
* den zweiten Block mit Nullen auf und meldet für den fehlenden zweiten
* Fühler -128. Der Stellbefehl muss trotzdem der kurze sein der lange
* wurde von ihr wortlos verworfen.
*/
@Test
fun `fehlender zweiter Fuehler heisst kurzer Stellbefehl`() {
val payload = byteArrayOf(
0x00, 0x01, 0x01, 0x02,
0x09, // Soll 9
0x14, 0xEC.toByte(), // Grenzen 20 / -20
0x02, 0x00, 0x00,
0x00, 0x00, 0xFD.toByte(), 0x00, // Kompressordrehzahlen
0x17, // Ist 23
0x64, // 100 %
0x0E, 0x03, // 14,3 V
0x00, 0x00, 0x00, 0x00, // rechte Zone: nur Füllung
0x00, 0x00, 0x00, 0x00,
0x80.toByte(), // kein zweiter Fühler
0x00, // Kompressor aus
0x01, 0x00, // was darüber hinaus kommt
)
val state = stateFrom(payload)
assertEquals(23, state.leftCurrent, "30 Byte Nutzlast werden gelesen")
assertEquals(100, state.batteryPercent)
assertEquals(14.3, state.batteryVolts!!, 0.001)
assertFalse(state.isDualZone, "der Platzhalter -128 ist kein Messwert")
assertEquals(20, state.settingsCommand(poweredOn = false)?.size, "der kurze Stellbefehl")
assertEquals(
1,
AlpicoolProtocol.chunks(state.settingsCommand(poweredOn = false)!!,
AlpicoolProtocol.MAX_WRITE_SIZE).size,
"und der passt in einen Schreibvorgang",
)
// Von Hand lässt sich die Erkennung übersteuern, falls sie danebenliegt.
state.zoneMode = FridgeZoneMode.DUAL
assertEquals(31, state.settingsCommand(poweredOn = false)?.size)
}
@Test
fun `ohne Status gibt es keinen Stellbefehl`() {
assertNull(AlpicoolState().settingsCommand(poweredOn = true))
}
@Test
fun `die rechte Zone hat ein eigenes Kommando`() {
val packet = AlpicoolState.setTarget(FridgeZone.RIGHT, -5)
assertEquals(AlpicoolProtocol.Command.SET_RIGHT.raw, packet.u(3))
assertEquals(0xFB, packet.u(4), "negativ als vorzeichenloses Byte")
}
@Test
fun `Messwerte gelten nicht als Wirkung eines Stellbefehls`() {
val payload = ByteArray(30).also {
it[1] = 1; it[4] = 9; it[14] = 23; it[15] = 100; it[16] = 14; it[17] = 3
it[26] = 0x80.toByte()
}
val before = stateFrom(payload).settingsFingerprint
val drifted = payload.copyOf().also { it[14] = 8; it[16] = 11 }
assertEquals(before, stateFrom(drifted).settingsFingerprint,
"schwankende Temperatur und Spannung zählen nicht")
val switched = payload.copyOf().also { it[1] = 0 }
assertTrue(stateFrom(switched).settingsFingerprint != before,
"ein geänderter Schalter zählt")
}
}
@@ -0,0 +1,279 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
private fun DeviceSnapshot.value(key: String): Double? =
metrics.firstOrNull { it.key == key }?.value
class DalyClassicTest {
// 13,42 V · 25,0 A Ladung · 87,5 %
// 30000 + 250 = 30250 = 0x765A -> +25,0 A ; SOC 875 = 0x036B -> 87,5 %
private val socPayload = hexBytes("00 86 00 86 76 2A 03 6B")
private fun stream(): ByteArray =
dalyResponse(0x90, socPayload) +
dalyResponse(0x91, hexBytes("0D 12 03 0C FE 07 00 00")) +
dalyResponse(0x92, hexBytes("40 01 3E 02 00 00 00 00")) +
dalyResponse(0x95, hexBytes("01 0C FE 0D 00 0D 12 00"))
@Test
fun `Anfragerahmen ist 13 Byte lang mit korrekter Pruefsumme`() {
val request = DalyProtocol.requestFrame(DalyProtocol.Command.SOC)
assertEquals(13, request.size)
assertEquals(0xBD, request.u(12))
}
@Test
fun `wertet alle Rahmen eines Antwortstroms aus`() {
val (frames, remainder) = DalyProtocol.extractA5Frames(stream())
assertEquals(4, frames.size)
assertEquals(0, remainder.size, "nichts bleibt übrig")
val state = DalyState()
frames.forEach { state.apply(it) }
val bms = state.snapshot(UUID.randomUUID(), null)
assertEquals(13.4, bms.value("voltage")?.let(::round2))
assertEquals(25.0, bms.value("current")?.let(::round1), "Strom mit Offset 30000")
assertEquals(87.5, bms.value("soc")?.let(::round1))
assertEquals(20.0, bms.value("cell_delta"), "Zell-Differenz in mV")
assertEquals(3.346, bms.value("cell_max"))
assertEquals(24.0, bms.value("temp_max"))
assertEquals("Lädt", bms.state, "Zustand aus dem Strom abgeleitet")
assertEquals(3, bms.cellVoltages.size, "drei Zellspannungen aus Rahmen 0x95")
}
@Test
fun `hebt einen angefangenen Rahmen auf`() {
val partial = stream().copyOfRange(0, 13 + 6)
val (frames, rest) = DalyProtocol.extractA5Frames(partial)
assertEquals(1, frames.size, "der vollständige Rahmen wird ausgewertet")
assertTrue(rest.size >= 6, "der angefangene bleibt im Puffer")
}
@Test
fun `verwirft eine falsche Pruefsumme`() {
val corrupted = dalyResponse(0x90, socPayload)
corrupted[12] = (corrupted[12].toInt() xor 0xFF).toByte()
assertEquals(0, DalyProtocol.extractA5Frames(corrupted).first.size)
}
}
class DalyModbusTest {
private fun response(): ByteArray {
val registers = IntArray(62)
registers[0] = 3320; registers[1] = 3325; registers[2] = 3318; registers[3] = 3330
registers[48] = 64 // 24 °C
registers[56] = 133 // 13,3 V
registers[57] = 29750 // -25,0 A
registers[58] = 642 // 64,2 %
var body = byteArrayOf(0xD2.toByte(), 0x03, (registers.size * 2).toByte())
for (register in registers) {
body += byteArrayOf((register shr 8).toByte(), (register and 0xFF).toByte())
}
val crc = DalyProtocol.crc16Modbus(body)
return body + byteArrayOf((crc and 0xFF).toByte(), (crc shr 8).toByte())
}
@Test
fun `Anfrage beginnt korrekt`() {
val request = DalyProtocol.modbusReadFrame()
assertContentEquals(hexBytes("D2 03 00 00 00 3E"), request.copyOfRange(0, 6))
}
@Test
fun `wertet eine Registerantwort aus`() {
val parsed = DalyProtocol.parseModbusResponse(response())
assertEquals(62, parsed?.size)
val state = DalyState()
state.apply(parsed!!)
val snapshot = state.snapshot(UUID.randomUUID(), null)
assertEquals(4, snapshot.cellVoltages.size, "nur belegte Zellen zählen")
assertEquals(13.3, snapshot.value("voltage")?.let(::round1))
assertEquals(-25.0, snapshot.value("current")?.let(::round1), "Entladestrom ist negativ")
assertEquals(64.2, snapshot.value("soc")?.let(::round1))
assertEquals(12.0, snapshot.value("cell_delta"))
assertEquals("Entlädt", snapshot.state)
}
@Test
fun `weist unvollstaendige und verfaelschte Antworten ab`() {
val full = response()
assertNull(
DalyProtocol.parseModbusResponse(full.copyOfRange(0, full.size - 30)),
"unvollständig",
)
val badCRC = full.copyOf()
badCRC[badCRC.size - 1] = (badCRC[badCRC.size - 1].toInt() xor 0xFF).toByte()
assertNull(DalyProtocol.parseModbusResponse(badCRC), "falscher CRC")
}
}
class JbdTest {
private fun jbdResponse(command: Int, payload: ByteArray): ByteArray {
val body = byteArrayOf(0x00, payload.size.toByte()) + payload
val sum = JbdProtocol.checksum(body)
return byteArrayOf(0xDD.toByte(), command.toByte()) + body +
byteArrayOf((sum shr 8).toByte(), (sum and 0xFF).toByte(), 0x77)
}
// 13,25 V · -12,80 A (Entladung) · 88 % · 4 Zellen · 1 Fühler bei 23,8 °C
private val basic = hexBytes(
"05 2D" + // 1325 -> 13,25 V
"FB 00" + // -1280 -> -12,80 A
"44 C0" + // 17600 -> 176,00 Ah Rest
"4E 20" + // 20000 -> 200,00 Ah nominal
"00 2A" + // 42 Zyklen
"00 00" + // Produktionsdatum
"00 00 00 00" +// Balancer
"00 00" + // keine Schutzabschaltung
"16" + // Softwareversion
"58" + // 88 %
"03" + // beide MOSFET an
"04" + // 4 Zellen
"01" + // 1 Fühler
"0B 99" // 2969 -> (2969-2731)/10 = 23,8 °C
)
@Test
fun `baut die Anfragerahmen`() {
assertContentEquals(
hexBytes("DD A5 03 00 FF FD 77"),
JbdProtocol.requestFrame(JbdProtocol.Command.BASIC_INFO),
)
assertContentEquals(
hexBytes("DD A5 04 00 FF FC 77"),
JbdProtocol.requestFrame(JbdProtocol.Command.CELL_VOLTAGES),
)
}
@Test
fun `wertet Basisinfo und Zellspannungen aus`() {
val stream = jbdResponse(0x03, basic) +
jbdResponse(0x04, hexBytes("0C FE 0D 12 0D 00 0C F8"))
val (frames, rest) = JbdProtocol.extractFrames(stream)
assertEquals(2, frames.size)
assertEquals(0, rest.size, "nichts bleibt übrig")
val state = JbdState()
frames.forEach { state.apply(it) }
val snapshot = state.snapshot(UUID.randomUUID(), null)
assertEquals(13.25, snapshot.value("voltage")?.let(::round2))
assertEquals(-12.80, snapshot.value("current")?.let(::round2), "Entladestrom ist negativ")
assertEquals(88.0, snapshot.value("soc"))
assertEquals(176.0, snapshot.value("capacity")?.let(::round2))
assertEquals(200.0, snapshot.value("capacity_nominal")?.let(::round2))
assertEquals(42.0, snapshot.value("cycles"))
assertEquals(23.8, snapshot.temperatures.firstOrNull()?.let(::round2),
"Temperatur aus Zehntel-Kelvin")
assertEquals(4, snapshot.cellVoltages.size)
assertEquals(3.346, snapshot.value("cell_max"))
assertEquals(26.0, snapshot.value("cell_delta"))
}
}
class WattCycleTest {
private fun frame(datapoint: Int, payload: ByteArray): ByteArray {
val head = byteArrayOf(
0x7E, 0x00, 0x01, 0x03,
(datapoint shr 8).toByte(), (datapoint and 0xFF).toByte(),
(payload.size shr 8).toByte(), (payload.size and 0xFF).toByte(),
) + payload
val crc = DalyProtocol.crc16Modbus(head)
return head + byteArrayOf((crc shr 8).toByte(), (crc and 0xFF).toByte(), 0x0D)
}
@Test
fun `Anfragerahmen ist elf Byte lang und richtig eingerahmt`() {
val request = WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG)
assertEquals(11, request.size)
assertEquals(0x1E, request.u(0))
assertEquals(0x008C shr 8, request.u(4))
assertEquals(0x008C and 0xFF, request.u(5))
assertEquals(0x0D, request.u(10))
}
/** Ohne den Text `HiLink` auf FFFA bleibt der Akku stumm. */
@Test
fun `der Freischalttext ist HiLink`() {
assertEquals("HiLink", String(WattCycleProtocol.authPayload, Charsets.US_ASCII))
}
@Test
fun `liest das eigene Stromformat`() {
assertEquals(12.5, WattCycleProtocol.current(0x40, 0x7D), "Bit 14 heisst Zehntel")
assertEquals(-12.5, WattCycleProtocol.current(0xC0, 0x7D), "Bit 15 ist das Vorzeichen")
assertEquals(125.0, WattCycleProtocol.current(0x00, 0x7D), "ohne Bit 14 ganze Ampere")
}
@Test
fun `wertet den selbstbeschreibenden Messwertsatz aus`() {
val payload = byteArrayOf(4) + // vier Zellen
hexBytes("0CFE 0D12 0D00 0CF8") + // Zellspannungen in mV
byteArrayOf(3) + // drei Fühler
hexBytes("0BB8 0BC2 0B99") + // MOSFET, Platine, eine Zelle
hexBytes("C07D") + // -12,5 A
hexBytes("0531") + // 1329 -> 13,29 V
hexBytes("06E0") + // 1760 -> 176,0 Ah Rest
hexBytes("07D0") + // 2000 -> 200,0 Ah gesamt
hexBytes("002A") + // 42 Zyklen
hexBytes("07D0") + // 2000 -> 200,0 Ah Nenn
hexBytes("0058") // 88 %
val (frames, rest) = WattCycleProtocol.extractFrames(frame(0x008C, payload))
assertEquals(1, frames.size)
assertEquals(0, rest.size)
val state = WattCycleState()
frames.forEach { state.apply(it) }
val snapshot = state.snapshot(UUID.randomUUID(), null)
assertEquals(4, snapshot.cellVoltages.size)
assertEquals(13.29, snapshot.value("voltage")?.let(::round2))
assertEquals(-12.5, snapshot.value("current")?.let(::round1))
assertEquals(88.0, snapshot.value("soc"))
assertEquals(176.0, snapshot.value("capacity")?.let(::round1))
assertEquals(42.0, snapshot.value("cycles"))
assertEquals(27.0, state.mosTemperature?.let(::round1), "3000 Zehntel-Kelvin")
assertEquals(28.0, state.pcbTemperature?.let(::round1))
// WattCycle rechnet mit Offset 2730, JBD mit 2731 - daher 23,9 statt 23,8.
assertEquals(23.9, state.cellTemperatures.firstOrNull()?.let(::round1))
assertEquals("Entlädt", snapshot.state)
}
@Test
fun `liest die Produktangaben aus drei ASCII-Feldern`() {
val text = { s: String -> s.toByteArray(Charsets.US_ASCII).copyOf(20) }
val payload = text("WC-12V200") + text("WattCycle") + text("SN12345678")
val (frames, _) = WattCycleProtocol.extractFrames(frame(0x0092, payload))
val state = WattCycleState()
frames.forEach { state.apply(it) }
assertEquals("WC-12V200", state.model)
assertEquals("WattCycle", state.manufacturer)
assertEquals("SN12345678", state.serial)
assertTrue(state.hasProductInfo)
}
@Test
fun `verwirft einen verfaelschten Rahmen`() {
val good = frame(0x008C, byteArrayOf(0))
val bad = good.copyOf()
bad[bad.size - 2] = (bad[bad.size - 2].toInt() xor 0xFF).toByte()
assertEquals(0, WattCycleProtocol.extractFrames(bad).first.size)
}
}
@@ -0,0 +1,247 @@
package de.fritob.campermonitor.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class VanAlignProtocolTest {
/**
* Die Firmware legt den Float little-endian ab. Die Web-Oberfläche des
* Ursprungsprojekts rät die Reihenfolge das ist schädlich, denn ein
* vertauschter Float von 4,25° liest sich als etwa 0,0 und sieht damit
* plausibel aus.
*/
@Test
fun `liest Winkel fest little-endian`() {
val bytes = byteArrayOf(0x00, 0x00, 0x88.toByte(), 0x40) // 4.25f
assertEquals(4.25, VanAlignProtocol.angle(bytes)!!, 0.0001)
}
@Test
fun `weist NaN und unsinnige Winkel ab`() {
assertNull(VanAlignProtocol.angle(hexBytes("0000C07F")), "NaN")
assertNull(VanAlignProtocol.angle(hexBytes("00007A44")), "1000 Grad gibt es nicht")
assertNull(VanAlignProtocol.angle(byteArrayOf(1, 2)), "zu kurz")
}
@Test
fun `liest beide Offsets`() {
val data = byteArrayOf(0x00, 0x00, 0x88.toByte(), 0x40) +
byteArrayOf(0x00, 0x00, 0x00, 0xBF.toByte()) // -0.5f
val offsets = VanAlignProtocol.offsets(data)
assertNotNull(offsets)
assertEquals(4.25, offsets.first, 0.0001)
assertEquals(-0.5, offsets.second, 0.0001)
}
}
class LevelStateTest {
@Test
fun `sagt in Worten was zu tun ist`() {
assertEquals("Steht eben", LevelState(pitch = 0.2, roll = -0.1).instruction)
assertEquals(
"Heck steht höher, rechts steht höher",
LevelState(pitch = 1.8, roll = 0.9).instruction,
)
assertEquals("Front steht höher", LevelState(pitch = -1.8, roll = 0.1).instruction)
}
/**
* Fehlen die Offsets, heisst das "unbekannt" und nicht "nicht kalibriert"
* eine Warnung, die sich nie abstellen lässt, ist schlimmer als keine.
*/
@Test
fun `unterscheidet unbekannt von nicht kalibriert`() {
val unknown = LevelState(pitch = 1.0, roll = 0.0)
assertNull(unknown.calibrationState)
assertFalse(unknown.isKnownUncalibrated)
assertFalse(unknown.isCalibrated)
val uncalibrated = LevelState(pitch = 1.0, roll = 0.0, pitchOffset = 0.0, rollOffset = 0.0)
assertTrue(uncalibrated.isKnownUncalibrated)
val calibrated = LevelState(pitch = 1.0, roll = 0.0, pitchOffset = 0.4, rollOffset = -0.2)
assertTrue(calibrated.isCalibrated)
}
}
class LevelingWedgeTest {
@Test
fun `rechnet die Keilhoehe geometrisch`() {
// tan(2°) × 2,0 m ≈ 0,0699 m
val wedge = LevelingWedge.across(roll = 2.0, trackWidth = 2.0)
assertNotNull(wedge)
assertEquals(7.0, wedge.heightInCentimetres, 0.1)
}
@Test
fun `legt den Keil unter die tiefere Seite`() {
assertEquals(LevelingWedge.Side.LEFT, LevelingWedge.across(2.0, 2.0)?.side,
"rechts steht höher, also links unterlegen")
assertEquals(LevelingWedge.Side.RIGHT, LevelingWedge.across(-2.0, 2.0)?.side)
assertEquals(LevelingWedge.Side.FRONT, LevelingWedge.along(1.5, 3.5)?.side,
"Heck steht höher, also vorne unterlegen")
assertEquals(LevelingWedge.Side.REAR, LevelingWedge.along(-1.5, 3.5)?.side)
}
@Test
fun `verzichtet auf einen Keil wenn es sich nicht lohnt`() {
assertNull(LevelingWedge.across(0.3, 2.0), "innerhalb der Toleranz")
assertNull(LevelingWedge.across(3.0, 0.0), "ohne Maß")
assertNull(LevelingWedge.across(3.0, null), "ohne hinterlegte Spurweite")
}
}
class AlignmentAssistantTest {
private fun assistantWith(vararg deviations: Double): AlignmentAssistant {
val assistant = AlignmentAssistant()
deviations.forEachIndexed { index, value ->
assistant.add(pitch = value, roll = 0.0, at = index * 1000L)
}
return assistant
}
@Test
fun `erkennt Verbesserung und Verschlechterung`() {
assertEquals(AlignmentAssistant.Trend.IMPROVING,
assistantWith(4.0, 4.0, 4.0, 1.0, 1.0, 1.0).trend)
assertEquals(AlignmentAssistant.Trend.WORSENING,
assistantWith(1.0, 1.0, 1.0, 4.0, 4.0, 4.0).trend)
assertEquals(AlignmentAssistant.Trend.STEADY,
assistantWith(2.0, 2.0, 2.0, 2.0, 2.0, 2.0).trend)
assertEquals(AlignmentAssistant.Trend.UNKNOWN,
assistantWith(2.0, 2.0).trend, "zu wenige Messwerte")
}
@Test
fun `merkt sich den besten Punkt und meldet ihn`() {
val assistant = assistantWith(3.0, 0.6, 1.0, 2.0)
assertEquals(0.6, assistant.best?.deviation)
assertEquals(1.4, assistant.improvementAtBest!!, 0.0001)
assertEquals(2.0, assistant.secondsSinceBest!!, 0.0001)
assertTrue(assistant.advice.startsWith("Vor 2 s stand es besser"))
}
@Test
fun `schweigt wenn die Rueckkehr nichts braechte`() {
val assistant = assistantWith(1.05, 1.0)
assertNull(assistant.improvementAtBest, "0,05° zurückzufahren lohnt nicht")
}
@Test
fun `meldet das Ziel wenn es eben steht`() {
val assistant = AlignmentAssistant()
assistant.add(pitch = 0.2, roll = 0.2, at = 0)
assertTrue(assistant.hasReachedTarget)
assertEquals("Steht eben anhalten", assistant.advice)
}
@Test
fun `vergisst was zu lange her ist`() {
val assistant = AlignmentAssistant()
assistant.add(pitch = 5.0, roll = 0.0, at = 0)
assistant.add(pitch = 1.0, roll = 0.0, at = AlignmentAssistant.MEMORY_MS + 1000)
assertEquals(1, assistant.samples.size)
}
}
class SensorOrientationTest {
private fun detect(nose: OrientationDetection.Reading, side: OrientationDetection.Reading) =
OrientationDetection.orientation(nose, side)
private fun reading(pitch: Double, roll: Double) = OrientationDetection.Reading(pitch, roll)
private fun success(result: OrientationDetection.Result): SensorOrientation {
assertTrue(result is OrientationDetection.Result.Success, "erwartet: erkannt, ist: $result")
return result.orientation
}
@Test
fun `erkennt den geraden Einbau`() {
val o = success(detect(reading(12.0, 0.0), reading(0.0, 9.0)))
assertEquals(SensorOrientation.Source.PITCH, o.longitudinalSource)
assertFalse(o.invertLongitudinal)
assertFalse(o.invertLateral)
assertTrue(o.isIdentity)
assertEquals("Achsen unverändert", o.summary)
}
@Test
fun `erkennt den um 180 Grad gedrehten Einbau`() {
val o = success(detect(reading(-12.0, 0.0), reading(0.0, -9.0)))
assertEquals(SensorOrientation.Source.PITCH, o.longitudinalSource)
assertTrue(o.invertLongitudinal)
assertTrue(o.invertLateral)
}
@Test
fun `erkennt den quer eingebauten Sensor`() {
val o = success(detect(reading(0.0, 11.0), reading(-8.0, 0.0)))
assertEquals(SensorOrientation.Source.ROLL, o.longitudinalSource)
assertFalse(o.invertLongitudinal)
assertTrue(o.invertLateral)
assertTrue(o.summary.contains("getauscht"))
}
/**
* Von Hand gekippt geht die andere Achse ein Stück mit. Einzeln betrachtet
* wäre das nicht zuzuordnen, im Paar schon genau daran scheiterte der
* Assistent zuerst.
*/
@Test
fun `verkraftet eine mitlaufende Achse`() {
val o = success(detect(reading(14.0, 6.0), reading(-7.0, 16.0)))
assertEquals(SensorOrientation.Source.PITCH, o.longitudinalSource)
assertFalse(o.invertLongitudinal)
assertFalse(o.invertLateral)
val sideways = success(detect(reading(6.0, 14.0), reading(-16.0, 7.0)))
assertEquals(SensorOrientation.Source.ROLL, sideways.longitudinalSource)
assertTrue(sideways.invertLateral)
}
@Test
fun `meldet zu wenig Bewegung`() {
val result = detect(reading(2.0, 0.0), reading(0.0, 9.0))
assertEquals(
OrientationDetection.Failure.TOO_LITTLE_MOVEMENT,
(result as OrientationDetection.Result.Error).failure,
)
}
@Test
fun `meldet zweimal dieselbe Achse`() {
val result = detect(reading(10.0, 0.0), reading(9.0, 0.0))
assertEquals(
OrientationDetection.Failure.AMBIGUOUS,
(result as OrientationDetection.Result.Error).failure,
)
}
@Test
fun `rechnet Sensorwerte in Fahrzeugwerte um`() {
val swapped = SensorOrientation(
longitudinalSource = SensorOrientation.Source.ROLL,
invertLateral = true,
)
val (pitch, roll) = swapped.apply(pitch = 3.0, roll = -2.0)
assertEquals(-2.0, pitch, "längs kommt aus der zweiten Achse")
assertEquals(-3.0, roll, "quer ist umgekehrt")
val untouched = SensorOrientation.IDENTITY.apply(pitch = 1.5, roll = -0.5)
assertEquals(1.5, untouched.first)
assertEquals(-0.5, untouched.second)
val missing = SensorOrientation.IDENTITY.apply(pitch = null, roll = 2.0)
assertNull(missing.first)
assertEquals(2.0, missing.second)
}
}
@@ -0,0 +1,45 @@
package de.fritob.campermonitor.protocol
import kotlin.math.roundToLong
/** Hex-Text zu Bytes. Leerzeichen dürfen drin stehen, das hilft beim Lesen. */
fun hexBytes(text: String): ByteArray {
val cleaned = text.filter { !it.isWhitespace() }
require(cleaned.length % 2 == 0) { "ungerade Anzahl Hex-Zeichen" }
return ByteArray(cleaned.length / 2) {
cleaned.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
}
fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) }
fun round2(value: Double): Double = (value * 100).roundToLong() / 100.0
fun round1(value: Double): Double = (value * 10).roundToLong() / 10.0
/** Packt Felder so, wie Victron sie sendet: LSB zuerst, ohne Byte-Ausrichtung. */
class BitWriter {
private val bits = mutableListOf<Int>()
fun write(value: Long, width: Int) {
for (i in 0 until width) bits.add(((value shr i) and 1).toInt())
}
fun write(value: Int, width: Int) = write(value.toLong(), width)
val bytes: ByteArray
get() {
val out = ByteArray((bits.size + 7) / 8)
bits.forEachIndexed { index, bit ->
if (bit == 1) out[index / 8] = (out[index / 8].toInt() or (1 shl (index % 8))).toByte()
}
return out
}
}
/** Baut eine Daly-Antwort so, wie das BMS sie schickt. */
fun dalyResponse(command: Int, payload: ByteArray): ByteArray {
val frame = byteArrayOf(0xA5.toByte(), 0x01, command.toByte(), 0x08) + payload
var sum = 0
for (b in frame) sum = (sum + (b.toInt() and 0xFF)) and 0xFF
return frame + sum.toByte()
}
@@ -0,0 +1,169 @@
package de.fritob.campermonitor.protocol
import java.util.UUID
import kotlin.math.roundToLong
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class AesCounterModeTest {
@Test
fun `stimmt mit dem Referenzvektor NIST SP 800-38A F 5 1 ueberein`() {
val cipher = AesCounterMode.crypt(
data = hexBytes("6bc1bee22e409f96e93d7e117393172a"),
key = hexBytes("2b7e151628aed2a6abf7158809cf4f3c"),
nonce = hexBytes("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"),
)
assertEquals("874d6191b620e3261bef6864990db6ce", cipher?.hex())
}
}
class BitReaderTest {
@Test
fun `liest die untersten Bits zuerst und setzt bitgenau fort`() {
val reader = BitReader(byteArrayOf(0xB5.toByte(), 0x03))
assertEquals(5L, reader.read(3))
assertEquals(22L, reader.read(5))
assertEquals(3L, reader.read(8))
assertNull(reader.read(1), "hinter dem Ende gibt es nichts mehr")
}
@Test
fun `erkennt den NA-Wert`() {
val reader = BitReader(byteArrayOf(0xFF.toByte(), 0xFF.toByte()))
assertNull(reader.readOptional(16))
}
@Test
fun `dekodiert Zweierkomplement und erkennt dessen NA-Wert`() {
val reader = BitReader(
byteArrayOf(0xFF.toByte(), 0x7F, 0x9C.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
)
assertNull(reader.readOptionalSigned(16), "0x7FFF ist NA")
assertEquals(-100L, reader.readOptionalSigned(16))
assertEquals(-1L, reader.readOptionalSigned(16))
}
}
/** Der Geräteschlüssel aus den iOS-Prüfungen, damit dieselben Vektoren gelten. */
private val deviceKey = hexBytes("aa112233445566778899aabbccddeeff")
private const val NONCE = 0x1234
private fun counterBlock(nonce: Int = NONCE) = ByteArray(16).also {
it[0] = (nonce and 0xFF).toByte()
it[1] = (nonce shr 8).toByte()
}
private fun advertisement(productID: Int, record: Int, plain: ByteArray): ByteArray {
val encrypted = AesCounterMode.crypt(plain, deviceKey, counterBlock())!!
return byteArrayOf(
0xE1.toByte(), 0x02, 0x10, 0x00,
(productID and 0xFF).toByte(), (productID shr 8).toByte(),
record.toByte(),
(NONCE and 0xFF).toByte(), (NONCE shr 8).toByte(),
deviceKey[0],
) + encrypted
}
private fun DeviceSnapshot.value(key: String): Double? =
metrics.firstOrNull { it.key == key }?.value
class VictronSolarChargerTest {
private fun decoded(): DeviceSnapshot {
val w = BitWriter()
w.write(3, 8) // Zustand: Bulk
w.write(0, 8) // kein Fehler
w.write(1345, 16) // 13,45 V
w.write(152, 16) // 15,2 A
w.write(234, 16) // 2,34 kWh
w.write(210, 16) // 210 W
w.write(0x1FF, 9) // Laststrom nicht verfügbar
return VictronAdvertisement.decode(
advertisement(0xA04C, 0x01, w.bytes), deviceKey, UUID.randomUUID(), -55
)
}
@Test
fun `dekodiert einen Solarladeregler vollstaendig`() {
val solar = decoded()
assertEquals("Konstantstrom (Bulk)", solar.state)
assertNull(solar.fault, "kein Fehler gemeldet")
assertEquals(13.45, solar.value("battery_voltage")?.let(::round2))
assertEquals(15.2, solar.value("battery_current")?.let(::round1))
assertEquals(2.34, solar.value("yield_today"))
assertEquals(210.0, solar.value("pv_power"))
assertNull(solar.value("load_current"), "Laststrom bleibt leer (NA)")
assertEquals("pv_power", solar.primaryMetric?.key)
}
@Test
fun `weist einen falschen Schluessel ab`() {
val w = BitWriter()
repeat(9) { w.write(0, 8) }
val frame = advertisement(0xA04C, 0x01, w.bytes)
val wrongKey = deviceKey.copyOf().also { it[0] = 0x00 }
val error = runCatching {
VictronAdvertisement.decode(frame, wrongKey, UUID.randomUUID(), null)
}.exceptionOrNull()
assertTrue(error is VictronAdvertisement.DecodeError.KeyMismatch)
}
}
class VictronOrionXsTest {
private fun frame(): ByteArray {
val w = BitWriter()
w.write(3, 8) // Bulk
w.write(0, 8)
w.write(1420, 16) // Ausgang 14,20 V
w.write(180, 16) // 18,0 A
w.write(1310, 16) // Eingang 13,10 V
w.write(210, 16) // 21,0 A
w.write(0x00000002L, 32) // "Per Schalter ausgeschaltet"
return advertisement(0xA3F0, 0x0F, w.bytes)
}
@Test
fun `liest den Rahmen`() {
val envelope = VictronAdvertisement.envelope(frame())
assertNotNull(envelope)
assertEquals(0xA3F0, envelope.productID)
assertEquals(0x0F, envelope.recordType)
assertEquals(NONCE, envelope.nonce)
assertEquals(deviceKey.u(0), envelope.keyCheckByte)
assertEquals(14, envelope.ciphertext.size, "Nutzdaten sind 14 Byte lang")
}
@Test
fun `dekodiert die Werte`() {
val xs = VictronAdvertisement.decode(frame(), deviceKey, UUID.randomUUID(), null)
assertEquals(14.20, xs.value("output_voltage")?.let(::round2))
assertEquals(18.0, xs.value("output_current")?.let(::round1))
assertEquals(13.10, xs.value("input_voltage")?.let(::round2))
assertEquals(256L, xs.value("output_power")?.roundToLong(), "Ladeleistung wird gerechnet")
assertEquals("Per Schalter ausgeschaltet", xs.offReasons.firstOrNull())
}
/**
* Aus der Diagnoseansicht der App abgelesen. Ohne Schlüssel lässt sich der
* Inhalt nicht prüfen, wohl aber der Rahmen und genau dort war der Fehler,
* der unter iOS die Meldung "Schlüssel passt nicht" ausgelöst hat.
*/
@Test
fun `liest einen echten Rahmen vom Geraet`() {
val envelope = VictronAdvertisement.envelope(
hexBytes("E1 02 10 00 F0 A3 0F 17 28 3C 81 9C 8F 42 FC 94 5F 4D 59 C9 F8 73 DB 48")
)
assertNotNull(envelope, "wird als Victron erkannt")
assertEquals(0x0F, envelope.recordType, "Datensatztyp ist Orion XS")
assertEquals(0xA3F0, envelope.productID)
assertEquals(0x2817, envelope.nonce)
assertEquals(0x3C, envelope.keyCheckByte)
assertEquals(14, envelope.ciphertext.size)
}
}
+23
View File
@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "CamperMonitor"
// Die Protokollschicht ist reines Kotlin, ohne Android. Nur so lassen sich
// ihre Prüfungen auf der Kommandozeile laufen lassen, ohne Emulator und ohne
// Android SDK - genau wie das `run-tests.sh` der iOS-Fassung.
include(":protocol")
include(":app")