Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go
T
tajniak81andClaude Opus 5 2425a8d3d6 A field we have no name for is still a field it sent
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>
2026-09-02 20:58:15 +02:00

268 lines
10 KiB
Go

package ankersolix
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"drivervault/apiserver/internal/mqtt"
)
func TestMqttCredentialsAddressAndAppName(t *testing.T) {
c := mqttCredentials{EndpointAddr: "aiot-mqtt-eu.anker.com"}
if got := c.address(); got != "aiot-mqtt-eu.anker.com:8883" {
t.Errorf("address = %q, want the TLS port appended", got)
}
// A response that already carries a port must not have another appended.
c.EndpointAddr = "aiot-mqtt-eu.anker.com:8884"
if got := c.address(); got != "aiot-mqtt-eu.anker.com:8884" {
t.Errorf("address = %q, want the endpoint's own port kept", got)
}
if got := c.appName(); got != "anker_power" {
t.Errorf("appName = %q, want the default when the cloud sent none", got)
}
c.AppName = "anker_charging"
if got := c.appName(); got != "anker_charging" {
t.Errorf("appName = %q, want the cloud's own value", got)
}
}
func TestMqttCredentialsNeedCertificateAndKey(t *testing.T) {
full := mqttCredentials{EndpointAddr: "host", CertificatePE: "cert", PrivateKey: "key"}
if !full.valid() {
t.Error("complete credentials were rejected")
}
for _, c := range []mqttCredentials{
{CertificatePE: "cert", PrivateKey: "key"},
{EndpointAddr: "host", PrivateKey: "key"},
{EndpointAddr: "host", CertificatePE: "cert"},
} {
if c.valid() {
t.Errorf("credentials missing a field were accepted: %+v", c)
}
}
}
func TestTopicsAddressOneCharger(t *testing.T) {
c := mqttCredentials{AppName: "anker_power"}
if got := commandTopic(c, "A5191", "SN123"); got != "cmd/anker_power/A5191/SN123/req" {
t.Errorf("commandTopic = %q", got)
}
if got := dataTopic(c, "A5191", "SN123"); got != "dt/anker_power/A5191/SN123/#" {
t.Errorf("dataTopic = %q", got)
}
}
// envelope builds a message shaped like the ones the cloud delivers: JSON, whose
// payload is itself a JSON string, whose data field is a base64 device frame.
func envelope(t *testing.T, sn string, frame []byte) mqtt.Message {
t.Helper()
inner, err := json.Marshal(map[string]any{
"device_sn": sn,
"data": base64.StdEncoding.EncodeToString(frame),
})
if err != nil {
t.Fatal(err)
}
outer, err := json.Marshal(map[string]any{
"head": map[string]any{"device_sn": sn, "timestamp": 1756813256},
"payload": string(inner),
})
if err != nil {
t.Fatal(err)
}
return mqtt.Message{Topic: "dt/anker_power/A5191/" + sn + "/param_info", Payload: outer}
}
func TestParseEnvelopeUnwrapsTheDeviceFrame(t *testing.T) {
frame := buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02))
sn, data, ok := parseEnvelope(envelope(t, "SN123", frame))
if !ok {
t.Fatal("a well-formed envelope was rejected")
}
if sn != "SN123" {
t.Errorf("serial = %q, want SN123", sn)
}
if string(data) != string(frame) {
t.Errorf("frame came back changed")
}
}
// The serial is not always repeated in the payload; the topic carries it too,
// and a message we cannot attribute to a charger must be dropped rather than
// folded into some other charger's state.
func TestParseEnvelopeFallsBackToTheTopic(t *testing.T) {
inner, _ := json.Marshal(map[string]any{"data": base64.StdEncoding.EncodeToString([]byte{1, 2, 3})})
outer, _ := json.Marshal(map[string]any{"payload": string(inner)})
sn, _, ok := parseEnvelope(mqtt.Message{Topic: "dt/anker_power/A5191/SN999/param_info", Payload: outer})
if !ok || sn != "SN999" {
t.Errorf("serial = %q (ok=%v), want SN999 from the topic", sn, ok)
}
for _, bad := range []mqtt.Message{
{Topic: "dt/a/b/SN/x", Payload: []byte("not json")},
{Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"not json"}`)},
{Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"{\"device_sn\":\"SN\"}"}`)}, // no data
{Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"{\"data\":\"!!not b64\"}"}`)}, // undecodable
{Topic: "short", Payload: []byte(`{"payload":"{\"data\":\"AQID\"}"}`)}, // no serial anywhere
} {
if _, _, ok := parseEnvelope(bad); ok {
t.Errorf("a malformed envelope was accepted: %s", bad.Payload)
}
}
}
// A charger's state is assembled from two message types that arrive at different
// times: telemetry must not erase the settings that came with the last command,
// and vice versa.
func TestIngestMergesTelemetryAndSettings(t *testing.T) {
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
c.ingest(envelope(t, "SN1", buildInbound(t, msgEVParams, field(0xa8, typeInt16LE, 0x40, 0x01))))
c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02))))
values, telemetryAt, settingsAt, _ := c.snapshotOf("SN1")
if v, _ := values["maxCurrentSetA"].(float64); v != 32 {
t.Errorf("the settings value was lost when telemetry arrived: %v", values["maxCurrentSetA"])
}
if v, _ := values["status"].(float64); v != 2 {
t.Errorf("status = %v, want 2", values["status"])
}
if telemetryAt.IsZero() || settingsAt.IsZero() {
t.Errorf("both halves should be timestamped: telemetry %v, settings %v", telemetryAt, settingsAt)
}
if !telemetryAt.After(settingsAt) && !telemetryAt.Equal(settingsAt) {
t.Errorf("telemetry arrived second but is stamped earlier")
}
// A message from a charger we have no map for leaves the state untouched.
c.ingest(mqtt.Message{Topic: "dt/a/b/SN1/x", Payload: []byte("rubbish")})
after, _, _, _ := c.snapshotOf("SN1")
if len(after) != len(values) {
t.Errorf("an unreadable message changed the charger's state")
}
}
// A message type we have no map for still carries its fields into the state,
// but it must not pass for a settings report: settingsAt is what a control
// command waits on to say the charger acknowledged it, and a frame we cannot
// read is not an acknowledgement.
func TestIngestOfAnUnmappedMessageKeepsFieldsButNotTheStamp(t *testing.T) {
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
c.ingest(envelope(t, "SN1", buildInbound(t, "0400", field(0xa2, typeUint8, 0x01))))
values, telemetryAt, settingsAt, _ := c.snapshotOf("SN1")
if v, _ := values["0400.a2"].(float64); v != 1 {
t.Errorf("values = %v, want the unnamed field kept as 0400.a2", values)
}
if !settingsAt.IsZero() || !telemetryAt.IsZero() {
t.Errorf("an unmapped message stamped the state: telemetry %v, settings %v", telemetryAt, settingsAt)
}
}
func TestWaitForReturnsWhenTheStateArrives(t *testing.T) {
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
go func() {
time.Sleep(20 * time.Millisecond)
c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02))))
}()
ok, err := c.waitFor(context.Background(), "SN1", func(st *deviceState) bool {
return !st.telemetryAt.IsZero()
}, 2*time.Second)
if err != nil || !ok {
t.Fatalf("waitFor = %v, %v; want it to see the message", ok, err)
}
}
func TestWaitForGivesUpAndReportsADeadConnection(t *testing.T) {
c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
ok, err := c.waitFor(context.Background(), "SN1", func(*deviceState) bool { return false }, 30*time.Millisecond)
if err != nil || ok {
t.Errorf("waitFor = %v, %v; want a quiet timeout", ok, err)
}
// A connection that dies while a caller is waiting must wake it with the
// reason rather than making it sit out the whole timeout.
c2 := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})}
go func() {
time.Sleep(20 * time.Millisecond)
c2.fail(mqtt.ErrClosed)
}()
start := time.Now()
if _, err := c2.waitFor(context.Background(), "SN1", func(*deviceState) bool { return false }, 5*time.Second); err == nil {
t.Error("a dropped connection ended the wait without an error")
}
if time.Since(start) > time.Second {
t.Error("the waiter was not woken when the connection dropped")
}
}
// A command that cannot be sent should be refused before it costs a sign-in, a
// certificate fetch and a broker connection — so the check runs on a plugin with
// no session at all.
func TestMqttCommandValidatesBeforeReachingTheCloud(t *testing.T) {
p := &Plugin{}
for _, tc := range []struct {
command string
amps float64
want string
}{
{"reboot", 0, "not a cloud command"},
{"limit", 3, "below the charger's 6 A floor"},
{"limit", 40, "outside the charger's range"},
} {
_, err := p.mqttCommand(context.Background(), "SN1", tc.command, tc.amps)
if err == nil {
t.Errorf("%s(%v) was accepted", tc.command, tc.amps)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s(%v) failed with %q, want it to mention %q", tc.command, tc.amps, err, tc.want)
}
}
// A valid command gets past validation and fails on the missing session
// instead, which is what proves the order.
if _, err := p.mqttCommand(context.Background(), "SN1", "start", 0); err == nil ||
!strings.Contains(err.Error(), "not initialised") {
t.Errorf("start failed with %v, want it to reach the session lookup", err)
}
}
func TestMqttModeValuesMatchTheChargerEnum(t *testing.T) {
want := map[string]uint8{
modeStartCharge: 1, modeStopCharge: 2, modeSkipDelay: 3, modeBoostCharge: 4,
}
for mode, v := range want {
if mqttModeValues[mode] != v {
t.Errorf("%s = %d, want %d", mode, mqttModeValues[mode], v)
}
}
// Every accepted command name must resolve to one of those modes, or the
// endpoint can offer a name the transport cannot send.
for name, mode := range mqttCommands {
if _, ok := mqttModeValues[mode]; !ok {
t.Errorf("command %q maps to %q, which is not a charger mode", name, mode)
}
}
}
func TestClientIDDoesNotCollideWithTheApp(t *testing.T) {
creds := mqttCredentials{ThingName: "abc-anker_power"}
a, b := clientIDFor(creds), clientIDFor(creds)
if !strings.HasPrefix(a, "abc-anker_power_") {
t.Errorf("client id %q does not carry the account's thing name", a)
}
if a == b {
t.Error("two connections were given the same client id, which would evict each other")
}
// With no thing name the user id stands in, so the id is still account-scoped.
if got := clientIDFor(mqttCredentials{UserID: "u1"}); !strings.HasPrefix(got, "u1_") {
t.Errorf("client id %q does not fall back to the user id", got)
}
}