Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go
T
tajniak81andClaude Opus 5 4ff6242c8f The last message in the map, and it reboots the charger
0108 was the one thing in the MQTT inventory nobody had wired: the device
power mode, whose single documented value restarts the charger. It is the
only way to reboot a charger that is on neither a CSMS nor the local
network — which is most of them — so the cloud transport sends it now,
and "reset" reaches it too, since that is what the OCPP path has always
called the same act.

Nothing waits for a confirmation: the device that would send it is the
device rebooting, so the command answers at once and says the charger
drops off the cloud for about a minute. The gate is unchanged and now
covers both spellings — an explicit confirm plus a password step-up,
audited either way. Modbus still refuses, because no register does this,
but its refusal now names both transports that can rather than only the
CSMS.

Both clients already had the reset button and its password prompt; they
were hidden in every mode that reads the device, which is why the cloud
never showed one. Modbus is now the only mode without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 23:15:12 +02:00

369 lines
14 KiB
Go

package ankersolix
import (
"encoding/binary"
"strings"
"testing"
"time"
)
// The realtime trigger frame is the one example the reference implementation
// documents byte for byte, so it is the anchor for the whole encoder: marker,
// little-endian length counting the checksum, send pattern, message type, then
// the fields in order.
func TestEncodeFrameMatchesTheDocumentedTrigger(t *testing.T) {
at := time.Unix(1756813256, 0)
got, err := encodeFrame(msgRealtimeTrigger, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, 1),
varField(0xa3, 300),
timestampField(at),
})
if err != nil {
t.Fatalf("encodeFrame: %v", err)
}
want := "ff091f0003000f0057" + // header: marker, length 31, send pattern, type 0057
"a10122" + // a1: one byte, no value type
"a2020101" + // a2: ui 1 — updates on
"a305032c010000" + // a3: var 300 — the window in seconds
"fe0503c8d7b668" // fe: var — the sender's clock
wantWithSum := want + "21"
if h := encodeHex(got); h != wantWithSum {
// Recompute the checksum in the message so a mismatch says which half broke.
body, _ := decodeHex(want)
t.Fatalf("frame = %s\nwant %s (checksum over the body is %02x)", h, wantWithSum, xorChecksum(body))
}
if len(got) != 31 {
t.Errorf("frame is %d bytes, want 31", len(got))
}
if binary.LittleEndian.Uint16(got[2:4]) != uint16(len(got)) {
t.Errorf("header length %d does not match the frame's %d bytes", binary.LittleEndian.Uint16(got[2:4]), len(got))
}
}
// The status request is the one command whose timestamp travels without a value
// type — the app sends it that way, and the charger answers what the app sends.
func TestStatusRequestSendsItsClockWithoutAValueType(t *testing.T) {
got, err := encodeFrame(msgEVStatusReq, []cmdField{bareTimestampField(time.Unix(1756813256, 0))})
if err != nil {
t.Fatalf("encodeFrame: %v", err)
}
want := "ff09100003000f0040" + // header: marker, length 16, send pattern, type 0040
"fe04c8d7b668" // fe: four clock bytes, no value type between the length and them
if h := encodeHex(got); h[:len(want)] != want {
t.Fatalf("frame = %s\nwant %s + checksum", h, want)
}
if len(got) != 16 {
t.Errorf("frame is %d bytes, want 16", len(got))
}
// The field's length byte counts the value alone, since there is no type byte
// to count — the whole point of the oddity.
if got[10] != 4 {
t.Errorf("fe length byte is %d, want 4", got[10])
}
var sum byte
for _, b := range got {
sum ^= b
}
if sum != 0 {
t.Errorf("checksum does not close the frame: %02x", sum)
}
}
// The restart carries the one value the map documents for the power-mode
// command, opened and closed like every other command.
func TestRestartFrameCarriesThePowerModeValue(t *testing.T) {
got, err := encodeFrame(msgEVPowerMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, powerModeRestart),
timestampField(time.Unix(1756813256, 0)),
})
if err != nil {
t.Fatalf("encodeFrame: %v", err)
}
want := "ff09180003000f0108" + // header: marker, length 24, send pattern, type 0108
"a10122" + // a1: the opener, no value type
"a2020105" + // a2: ui 5 — restart
"fe0503c8d7b668" // fe: var — the sender's clock
if h := encodeHex(got); h[:len(want)] != want {
t.Fatalf("frame = %s / want %s + checksum", h, want)
}
var sum byte
for _, b := range got {
sum ^= b
}
if sum != 0 {
t.Errorf("checksum does not close the frame: %02x", sum)
}
}
// Both spellings reach the restart; nothing else does.
func TestIsRestartTakesEitherName(t *testing.T) {
for _, name := range []string{"restart", "reset"} {
if !isRestart(name) {
t.Errorf("%q should ask for a restart", name)
}
}
for _, name := range []string{"reboot", "start", "stop", "trigger", ""} {
if isRestart(name) {
t.Errorf("%q should not ask for a restart", name)
}
}
}
// A frame is only self-consistent if XORing every byte, checksum included,
// comes to zero — which is exactly what the decoder checks.
func TestEncodeFrameChecksumClosesToZero(t *testing.T) {
frame, err := encodeFrame(msgEVMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, mqttModeValues[modeStartCharge]),
timestampField(time.Unix(1756813256, 0)),
})
if err != nil {
t.Fatalf("encodeFrame: %v", err)
}
if sum := xorChecksum(frame); sum != 0 {
t.Errorf("XOR over the whole frame = %02x, want 00", sum)
}
}
func TestEncodeFrameRejectsBadMessageType(t *testing.T) {
for _, mt := range []string{"", "01", "zz01", "01020304"} {
if _, err := encodeFrame(mt, []cmdField{rawField(0xa1, 0x22)}); err == nil {
t.Errorf("encodeFrame(%q) accepted a message type it should not", mt)
}
}
}
// buildInbound assembles a device-style frame the way the charger sends one:
// the receive pattern, and no counter byte before the first field.
func buildInbound(t *testing.T, msgType string, body []byte) []byte {
t.Helper()
mt, err := decodeHex(msgType)
if err != nil {
t.Fatalf("bad message type %q: %v", msgType, err)
}
out := append([]byte{}, frameMarker...)
out = binary.LittleEndian.AppendUint16(out, uint16(frameHeaderLen+len(body)+1))
out = append(out, 0x03, 0x01, 0x0f)
out = append(out, mt...)
out = append(out, body...)
return append(out, xorChecksum(out))
}
func field(name, typ byte, value ...byte) []byte {
return append([]byte{name, byte(len(value) + 1), typ}, value...)
}
func TestDecodeFrameReadsTelemetry(t *testing.T) {
var body []byte
body = append(body, field(0xa2, typeInt16LE, 0xfd, 0x08)...) // 2301 -> 230.1 V
body = append(body, field(0xa5, typeInt16LE, 0xa0, 0x00)...) // 160 -> 16.0 A
body = append(body, field(0xa8, typeInt32LE, 0x60, 0x0e, 0x00, 0x00)...)
body = append(body, field(0xa9, typeInt32LE, 0x8d, 0x0e, 0x00, 0x00)...)
body = append(body, field(0xaa, typeInt32LE, 0xd4, 0x30, 0x00, 0x00)...)
body = append(body, field(0xae, typeInt32LE, 0x2d, 0x00, 0x00, 0x00)...)
body = append(body, []byte{0xbb, 0x01, 0x02}...) // single byte, no value type
body = append(body, field(0xb8, typeUint8, 0x02)...)
msgType, values, err := decodeFrame(buildInbound(t, msgEVTelemetry, body))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if msgType != msgEVTelemetry {
t.Errorf("message type = %s, want %s", msgType, msgEVTelemetry)
}
want := map[string]float64{
"voltageL1": 230.1,
"currentL1": 16,
"powerTotal": 3680,
"sessionSeconds": 3725,
"sessionWh": 12500,
"startCountdownSeconds": 45,
"status": 2,
"ocppStatus": 2,
}
for k, v := range want {
got, ok := values[k].(float64)
if !ok {
t.Errorf("%s missing from the decoded values (%v)", k, values[k])
continue
}
if got != v {
t.Errorf("%s = %v, want %v", k, got, v)
}
}
}
// The settings message carries the charger's own view of its LAN side and its
// schedule, which are two- and four-byte fields read differently from the
// telemetry's: a clock field is a minute and an hour, not a number.
func TestDecodeFrameReadsSettings(t *testing.T) {
var body []byte
body = append(body, field(0xa8, typeInt16LE, 0x40, 0x01)...) // 320 -> 32.0 A
body = append(body, field(0xb7, typeUint8, 0x01)...) // Modbus TCP on
body = append(body, field(0xcf, typeInt16LE, 0xf6, 0x01)...) // port 502
body = append(body, field(0xd0, typeString, []byte("192.168.1.44")...)...)
body = append(body, field(0xe7, typeInt16LE, 0x00, 0x16)...) // 22:00
body = append(body, field(0xdf, typeUint8, 0x01)...) // boost running
_, values, err := decodeFrame(buildInbound(t, msgEVParams, body))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if v, _ := values["maxCurrentSetA"].(float64); v != 32 {
t.Errorf("maxCurrentSetA = %v, want 32", values["maxCurrentSetA"])
}
if v, _ := values["modbusPort"].(float64); v != 502 {
t.Errorf("modbusPort = %v, want 502", values["modbusPort"])
}
if v, _ := values["ipAddress"].(string); v != "192.168.1.44" {
t.Errorf("ipAddress = %q, want 192.168.1.44", values["ipAddress"])
}
// The two bytes are minute then hour, so reading them as a plain little-endian
// number would give 5632 rather than a time of day.
if v, _ := values["weekStart"].(string); v != "22:00" {
t.Errorf("weekStart = %q, want 22:00", values["weekStart"])
}
if v, _ := values["boostMode"].(float64); v != 1 {
t.Errorf("boostMode = %v, want 1", values["boostMode"])
}
}
// A frame reaches us over a cloud connection we do not control, so a truncated
// or corrupted one must be refused rather than read as a charger reporting
// zeros — which would silently show a charging car as idle.
func TestDecodeFrameRejectsDamagedFrames(t *testing.T) {
good := buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02))
corrupt := append([]byte{}, good...)
corrupt[len(corrupt)-2] ^= 0xff
if _, _, err := decodeFrame(corrupt); err == nil {
t.Error("a frame with a flipped value byte passed the checksum")
}
truncated := append([]byte{}, good[:len(good)-3]...)
if _, _, err := decodeFrame(truncated); err == nil {
t.Error("a truncated frame was accepted")
}
wrongMarker := append([]byte{}, good...)
wrongMarker[0] = 0xfe
if _, _, err := decodeFrame(wrongMarker); err == nil {
t.Error("a frame without the Anker marker was accepted")
}
if _, _, err := decodeFrame([]byte{0xff, 0x09}); err == nil {
t.Error("a frame too short to hold a header was accepted")
}
}
// Some inbound frames carry a counter byte between the header and the first
// field; skipping it wrongly would shift every field name by one.
func TestDecodeFrameSkipsTheCounterByte(t *testing.T) {
body := append([]byte{0x07}, field(0xbb, typeUint8, 0x05)...)
_, values, err := decodeFrame(buildInbound(t, msgEVTelemetry, body))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if v, _ := values["status"].(float64); v != 5 {
t.Errorf("status = %v, want 5 (the counter byte was not skipped)", values["status"])
}
}
// A message type we have no map for still has to parse, and what it carried is
// kept under the message and name byte it arrived with rather than dropped: an
// unnamed field is the only place some of what the charger knows appears.
func TestDecodeFrameOfAnUnmappedTypeKeepsRawFields(t *testing.T) {
_, values, err := decodeFrame(buildInbound(t, "0400", field(0xa2, typeUint8, 0x01)))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if v, _ := values["0400.a2"].(float64); v != 1 {
t.Errorf("values = %v, want 0400.a2 = 1", values)
}
}
// A field a mapped message does not name is kept the same way, beside the ones
// it does — and unscaled, since a factor is part of a meaning we do not have.
func TestDecodeFrameKeepsUnnamedFieldsBesideNamedOnes(t *testing.T) {
body := append(field(0xbb, typeUint8, 0x05), field(0xc9, typeInt16LE, 0x2c, 0x01)...)
_, values, err := decodeFrame(buildInbound(t, msgEVTelemetry, body))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if v, _ := values["status"].(float64); v != 5 {
t.Errorf("status = %v, want 5", values["status"])
}
if v, _ := values[rawFieldName(msgEVTelemetry, 0xc9)].(float64); v != 300 {
t.Errorf("0410.c9 = %v, want 300 unscaled", values[rawFieldName(msgEVTelemetry, 0xc9)])
}
}
func TestDecodeValueSignsAndScales(t *testing.T) {
// Two's-complement over two bytes: a relay reading below zero must stay below
// zero rather than wrapping to 6553.5.
v, ok := decodeValue(typeInt16LE, []byte{0xf6, 0xff}, mqttField{name: "x", factor: 0.1})
if !ok || v.(float64) != -1 {
t.Errorf("signed 2-byte value = %v (ok=%v), want -1", v, ok)
}
// The same bytes read unsigned are a large positive number, which is what the
// fields marked unsigned actually mean.
v, ok = decodeValue(typeInt16LE, []byte{0xf6, 0xff}, mqttField{name: "x", unsigned: true})
if !ok || v.(float64) != 65526 {
t.Errorf("unsigned 2-byte value = %v (ok=%v), want 65526", v, ok)
}
// A value shorter than its type is dropped rather than read as a smaller one.
if _, ok := decodeValue(typeInt32LE, []byte{0x01, 0x02}, mqttField{name: "x"}); ok {
t.Error("a 2-byte value was accepted for a 4-byte type")
}
// Scaling must not leave floating-point dust behind.
v, _ = decodeValue(typeInt32LE, []byte{0xd4, 0x30, 0x00, 0x00}, mqttField{name: "x", factor: 0.001})
if v.(float64) != 12.5 {
t.Errorf("scaled value = %v, want 12.5", v)
}
}
func TestPrintableKeepsOnlyReadableText(t *testing.T) {
if got := printable([]byte("192.168.1.44\x00\x00")); got != "192.168.1.44" {
t.Errorf("printable = %q, want %q", got, "192.168.1.44")
}
}
func TestDecodeHexRejectsRubbish(t *testing.T) {
for _, s := range []string{"abc", "zz", "00 11"} {
if _, err := decodeHex(s); err == nil {
t.Errorf("decodeHex(%q) accepted a non-hex string", s)
}
}
b, err := decodeHex("FF09")
if err != nil || len(b) != 2 || b[0] != 0xff || b[1] != 0x09 {
t.Errorf("decodeHex(%q) = %v, %v", "FF09", b, err)
}
if s := encodeHex([]byte{0xff, 0x09}); s != "ff09" {
t.Errorf("encodeHex = %q, want ff09", s)
}
if strings.ToUpper(encodeHex([]byte{0xab})) != "AB" {
t.Errorf("encodeHex is not lowercase hex")
}
}
// The three identity fields the reference leaves alone: four bytes that are the
// parts of a version, kept in the order they arrived rather than rearranged into
// one we cannot check.
func TestDecodeVersionFields(t *testing.T) {
v, ok := decodeValue(typeInt32LE, []byte{1, 0, 6, 1}, mqttField{name: "softwareVersion", version: true})
if !ok || v != "1.0.6.1" {
t.Errorf("softwareVersion = %v (ok=%v), want 1.0.6.1", v, ok)
}
// A field that is not four bytes is text where it can be read as text, and
// the bytes themselves where it cannot — never nothing.
if v, ok := decodeValue(typeString, []byte("V1.2\x00"), mqttField{version: true}); !ok || v != "V1.2" {
t.Errorf("text version = %v (ok=%v), want V1.2", v, ok)
}
if v, ok := decodeValue(typeInt32LE, []byte{0x01, 0x02}, mqttField{version: true}); !ok || v != "0102" {
t.Errorf("unreadable version = %v (ok=%v), want its bytes", v, ok)
}
}