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