Two cards on the Charging page were answering with a fraction of what the charger and the account actually report, and in both the losses happened quietly, in a parse that kept the fields it recognised and dropped the rest on the floor. Charger information asked three account-wide views and kept fourteen fields. A charger registered on its own is absent from the site view, which is the only one of the three carrying state, charge power and OCPP status — so exactly the charger that stands alone got the column of dashes, and nothing said why. The per-charger station record, get_evcharger_station_info, is what the mobile app opens when you tap a charger, and it is the one view that answers for a charger outside a station; it is now the fourth view, asked per charger, a failure there costing that charger's row and no more. Alongside it, every field each of the four views sent is kept as attrs, under the cloud's own key, nested objects joined with a dot and arrays carrying their index. First view to answer a key wins, which is the rule the named fields already merged by. Two hundred keys and two hundred and forty runes per value keep a station record with a session list from becoming the whole card. Charger readings lost data twice over. The frame decoder skipped any field byte its per-message map could not name, and a message type with no map decoded to nothing at all; those fields are now kept under the message and the byte they arrived in — 0410.c9 — decoded but unscaled, because a factor is half of a meaning and we do not have the other half. Then the projection read forty-odd names into typed fields and dropped the remainder: sessionStartedAt, the per-phase session energies, the three touch modes, the load-balance monitor and its meter flag, the solar monitor. Those land in extra, and the list maintains itself — the four accessors note every key they read, extra is what is left, and a field modelled later stops appearing there without anyone remembering to remove it. Keeping unnamed fields had one consequence worth guarding. An unmapped message now decodes to something rather than nothing, and ingest stamped settingsAt for anything that was not telemetry — the timestamp a control command waits on to say the charger acknowledged it. A frame we cannot read is not an acknowledgement, so the stamp is now conditional on the message type being one we map, while its fields are kept either way. Both cards show the remainder as what it is: the service's own key, no unit, no translation, no renaming, under a heading that says whose words these are. The blocks appear only when there is something in them, so a Modbus charger's readings card and a charger the cloud says nothing more about are unchanged. Naming one of these fields is a later commit, made from evidence; inventing a label for it today would only make a guess look settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
10 KiB
Go
281 lines
10 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")
|
|
}
|
|
}
|