Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go
T
tajniak81andClaude Opus 5 576df58776 Go the way the owner's phone already goes
Control had two transports and neither fitted the ordinary customer. OCPP waits
for the charger to dial in, which needs a public endpoint it can reach, a
certificate, and a firmware willing to talk to our CSMS. Modbus TCP dials the
charger, which needs the server on the charger's own network. Between them they
cover a charger we host and a charger we stand next to; the common case is a
charger behind someone else's router, and that had nothing.

It was never unreachable, though. The charger holds a connection open to Anker's
own broker — it is how the mobile app drives it from anywhere, and it is the
mqttStatus register the Modbus snapshot has been reporting all along. So a third
control mode joins that broker as the account: get_user_mqtt_info issues a client
certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same
topics the app publishes on. Nothing on the customer's side has to be forwarded,
addressed or certificated.

What travels is not an API call. The payload is a JSON envelope around a base64
binary frame the device itself speaks — marker, little-endian length, message
type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec
rather than a client, written from the message maps in anker-solix-api and
anchored on the one frame that project documents byte for byte. A frame whose
fields do not tile exactly up to the checksum is refused rather than half-read:
these arrive over a link we do not control, and a truncated frame must not read
as a charger reporting zeros.

Two of the charger's habits shape the rest. It publishes nothing unless asked, so
a status read arms a telemetry trigger and waits for the next frame, and a poll
inside that window answers from what has since arrived. And a broker connection
costs a fetched certificate and a TLS handshake while the plugin manager builds a
throwaway instance per request — so the connection lives on the account's shared
session beside the auth token, for exactly the reason the token lives there, and
closes itself after five idle minutes.

The transport also sees two signals no other one does: the boost flag, and the
plug and start countdowns. The package doc has said since the first commit that
they are never set and the derived mode must do without them. Here they are set,
so a charger that has been told to start and is counting down a delay says so
rather than sitting in "preparing", and "skip the delay" is offered only while
there is a delay to skip.

The clients generalise instead of growing a second layout. Both snapshots name
the same quantities the same way, so what was Modbus-only in the readouts is now
whichever transport read the charger — ModbusStatus becomes ChargerStatus on the
phone, mb becomes dev on the web. What each transport can be *told* still
differs, and the buttons branch on that: reset and clear-limit stay with OCPP,
the timeout and phase registers with Modbus, skip-delay with the cloud. A command
a transport has no equivalent for is refused by name, saying which one has it.

The cost is worth saying plainly. This leans on Anker's cloud being up and on an
unofficial protocol the app may change under us, where Modbus leans on nothing
but the LAN. And it is checked against the reference implementation's own worked
example rather than against hardware — there is no charger on this end to point
it at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:47:10 +02:00

264 lines
9.7 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))
}
}
// 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, so an unknown frame is
// an empty answer rather than an error that hides the ones we can read.
func TestDecodeFrameOfAnUnmappedTypeIsEmpty(t *testing.T) {
_, values, err := decodeFrame(buildInbound(t, "0400", field(0xa2, typeUint8, 0x01)))
if err != nil {
t.Fatalf("decodeFrame: %v", err)
}
if len(values) != 0 {
t.Errorf("values = %v, want none for an unmapped message type", values)
}
}
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")
}
}