Every field in the cloud MQTT map that has a documented meaning now reads as a named row, on the same labels the register map uses for the same quantities. The raw block stays, and shrinks to what genuinely nobody has identified — which is the only honest reason for a key like 0410.b9 to be on screen at all. Three fields the reference decodes for nobody are decoded here. ac is where the charge is coming from — off or paused, grid, solar — and it is called chargingSource rather than chargingMode, because that name already belongs to a Modbus register and the last time a cloud field borrowed one, d9 spent a release reporting the wrong thing under the right name. b6 is the session's order id. f1, f2 and f3 are the identity fields the reference marks multi-value: four bytes read as the parts of a version, in the order they arrive, which is what the account view's own firmware string looks like. If the panel shows those parts reversed, the order is the thing to flip — it is the one assumption here that the wire has not yet confirmed. The rest was already decoded and simply never drawn. The readings card now shows the session's start, its id, the charging source, whether a cable is in, the charging window, and — since a reading is worth what its age is — the live-stream flag and both stream clocks, because telemetry and settings arrive on different messages with different triggers. Per-phase session energy joins the phase matrix as a fourth column, appearing on the transport that counts a session and staying away from the one that does not, exactly as the reactive and apparent pair does. The settings block gains the fourteen the register map has no address for: plug lock, auto restart, random delay, the schedule and its mode, the weekend window and how the weekend is handled, the light-off schedule and window, the breaker limit, the solar mode and its minimum current, automatic phase switching, the three panel gestures, and what the two balancing features are watching — the meter and monitor serials by name, their two unpinned numbers as the numbers they are. A local network block says whether the charger's own Modbus server is on and where, which is the answer the Modbus mode's setup screen otherwise has to be given by hand. The device block gains the controller version. A test now holds the line the projection quietly drew: every name in the message maps must reach a snapshot field. A name added to a map without a field to land in would otherwise surface in the raw block looking like something we understood. Left raw: a1, the frame opener the charger echoes back; b7, which the map itself calls unidentified; b9, bc and bd, which appear in no map; the five-minute 0400; and 0857 — a message type the reference's closed inventory of fourteen does not contain and this charger publishes anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
11 KiB
Go
299 lines
11 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, 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)
|
|
}
|
|
}
|