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) } }