diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go index 9c38568..0b92e32 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -618,6 +618,27 @@ func (c *mqttConn) listen(ctx context.Context, model, sn string) error { c.mu.Lock() c.subs[topic] = true c.mu.Unlock() + // With the frame log on, listen to the charger's command topic as well. The + // data topic carries only what the charger says; the commands the Anker app + // sends it — the half no capture here has ever seen — go to this one, and a + // command nobody has a name for cannot be named without reading it first. + // Off by default: it is a diagnostic subscription, not part of control. + if mqttFrameLog { + cmd := commandTopic(c.creds, model, sn) + c.mu.Lock() + seen := c.subs[cmd] + c.mu.Unlock() + if !seen { + if err := c.client.Subscribe(ctx, cmd); err == nil { + c.mu.Lock() + c.subs[cmd] = true + c.mu.Unlock() + log.Printf("ANKER-MQTT listening to the command topic %s (frame log)", cmd) + } else { + log.Printf("ANKER-MQTT cannot listen to the command topic %s: %v", cmd, err) + } + } + } return nil } diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttcards_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttcards_test.go new file mode 100644 index 0000000..4503efc --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttcards_test.go @@ -0,0 +1,124 @@ +package ankersolix + +import ( + "encoding/hex" + "testing" +) + +// The card-list frame, captured from a live A5191 on firmware 1.0.6.1. The nine +// UIDs in it are the nine the account's own card list answered with, which is +// what named this frame in the first place. +const capturedCardFrame = "ff09540003010f090400a10132a2020109a3080404dfe672151a90a4050457c4df2a" + + "a505046745bb4ea6050477dfd24ea70504d7b1c44ea80504778acd4ea905048a3cbc3f" + + "aa0504da7cb93fab05044754dc2a00" + +func TestDecodeCardListFrame(t *testing.T) { + data, err := hex.DecodeString(capturedCardFrame) + if err != nil { + t.Fatal(err) + } + msgType, values, err := decodeFrame(data) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if msgType != msgEVCards { + t.Fatalf("msgType = %q, want %q", msgType, msgEVCards) + } + if n, _ := values["rfidCardCount"].(float64); n != 9 { + t.Errorf("rfidCardCount = %v, want 9", values["rfidCardCount"]) + } + cards, ok := values["rfidCards"].([]string) + if !ok { + t.Fatalf("rfidCards = %T, want []string", values["rfidCards"]) + } + // In the charger's own order: newest first, which is the reverse of the way + // the account lists them. + want := []string{ + "04DFE672151A90", "57C4DF2A", "6745BB4E", "77DFD24E", "D7B1C44E", + "778ACD4E", "8A3CBC3F", "DA7CB93F", "4754DC2A", + } + if len(cards) != len(want) { + t.Fatalf("got %d cards, want %d: %v", len(cards), len(want), cards) + } + for i, w := range want { + if cards[i] != w { + t.Errorf("card %d = %q, want %q", i, cards[i], w) + } + } +} + +// A UID is an identifier, not a quantity: read as a big-endian number, 57C4DF2A +// becomes 1472519978 and matches nothing the account ever prints. +func TestCardUIDsAreHexNotNumbers(t *testing.T) { + v, ok := decodeValue(typeBytes, []byte{0x57, 0xc4, 0xdf, 0x2a}, mqttField{}) + if !ok || v != "57C4DF2A" { + t.Fatalf("decodeValue = %v (%T), want \"57C4DF2A\"", v, v) + } +} + +// The charger's own view of its OCPP backend, from the same capture. +func TestDecodeOcppInfoFrame(t *testing.T) { + data, err := hex.DecodeString("ff096a0003010f091100a10132a20600416e6b6572a331007773733a2f2f" + + "6368617267696e672d6f63707070726f78792d65752d70726f642e616e6b65722e636f6d2f6f6370706a" + + "a4020100a51200415432444b325a31463438333030313134a60100a70100a80100a7") + if err != nil { + t.Fatal(err) + } + msgType, values, err := decodeFrame(data) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if msgType != msgEVOcppInfo { + t.Fatalf("msgType = %q, want %q", msgType, msgEVOcppInfo) + } + if got := values["ocppBackendName"]; got != "Anker" { + t.Errorf("ocppBackendName = %v, want Anker", got) + } + if got := values["ocppBackendUrl"]; got != "wss://charging-ocppproxy-eu-prod.anker.com/ocppj" { + t.Errorf("ocppBackendUrl = %v", got) + } + if got, _ := values["ocppSource"].(float64); got != 0 { + t.Errorf("ocppSource = %v, want 0 — the number the account's OCPP view leaves bare", values["ocppSource"]) + } + if got := values["ocppChargePointId"]; got != "AT2DK2Z1F48300114" { + t.Errorf("ocppChargePointId = %v", got) + } +} + +// The tap itself, captured while a card was enrolled at the charger: the reader +// reports the UID the moment the card touches it. +func TestDecodeCardReadFrame(t *testing.T) { + data, err := hex.DecodeString("ff091b0003010f090800a10132a20107a3080404dfe672151a90a8") + if err != nil { + t.Fatal(err) + } + msgType, values, err := decodeFrame(data) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if msgType != msgEVCardRead { + t.Fatalf("msgType = %q, want %q", msgType, msgEVCardRead) + } + if got := values["rfidCardRead"]; got != "04DFE672151A90" { + t.Errorf("rfidCardRead = %v, want 04DFE672151A90", got) + } +} + +// The same frame when the window closed with nothing tapped: no UID, and the +// absence is the answer. +func TestCardReadWithoutACard(t *testing.T) { + data, err := hex.DecodeString("ff09110003010f090801a10132a20107dc") + if err != nil { + t.Fatal(err) + } + msgType, values, err := decodeFrame(data) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if msgType != msgEVCardRead { + t.Fatalf("msgType = %q, want %q", msgType, msgEVCardRead) + } + if _, ok := values["rfidCardRead"]; ok { + t.Errorf("rfidCardRead = %v, want it absent", values["rfidCardRead"]) + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go index dc85ae6..cb9719f 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -55,6 +55,7 @@ const ( typeUint8 byte = 0x01 typeInt16LE byte = 0x02 typeInt32LE byte = 0x03 // "var": four bytes, though not always one value + typeBytes byte = 0x04 // bytes that are an identifier, not a number — an RFID UID typeFloat32 byte = 0x05 typeNone byte = 0xff // this package's marker for "no type byte" ) @@ -81,6 +82,22 @@ const ( msgEVParamsAlt = "0840" // the same fields, in answer to a status request msgEVConfirm = "0900" // the same fields again, confirming a control change msgEVCharging = "0403" // a couple of charging parameters + // The charger's own copy of the RFID card list, published when the account + // asks it for one. Named from a live capture: nine UIDs in one frame, the + // same nine the cloud's get_device_cards answered with, in reverse order — + // newest first. The count is a field; the cards are one field each, so they + // are collected rather than mapped (see decodeFrame). + msgEVCards = "0904" + // Which OCPP backend the charger is pointed at, from the charger rather than + // from the account: the same address get_ocpp_info reports, and the source + // number that view leaves as a bare integer. + msgEVOcppInfo = "0911" + // The reader, reporting a card held against it. Captured during an + // enrol-at-the-charger: the frame arrives the moment the card is tapped, + // carrying the UID, and the charger publishes its updated card list a second + // later. The same frame arrives without a UID when the twenty-second window + // closes with nothing tapped, which is what makes the UID field the event. + msgEVCardRead = "0908" // powerModeRestart is the only value the power-mode command is known to take. // The map documents 5 and nothing else, so nothing else is sent. @@ -202,6 +219,36 @@ var evCharging = map[byte]mqttField{ 0xa6: {name: "solarMinCurrentA"}, } +// evCards names what is not a card in the card-list frame. The cards themselves +// start at 0xa3 and run one per field for as many as the charger holds, so they +// cannot be a map — decodeFrame collects them. +var evCards = map[byte]mqttField{ + 0xa2: {name: "rfidCardCount"}, +} + +// evCardRead names the reader's own event. a2 was 7 in every capture, with and +// without a card, so it is relayed under its own key rather than guessed at. +var evCardRead = map[byte]mqttField{ + 0xa3: {name: "rfidCardRead"}, +} + +// evOcppInfo names the charger's own view of its OCPP backend. +var evOcppInfo = map[byte]mqttField{ + 0xa2: {name: "ocppBackendName"}, + 0xa3: {name: "ocppBackendUrl"}, + 0xa4: {name: "ocppSource"}, + 0xa5: {name: "ocppChargePointId"}, +} + +// evInfoMessages are messages this package can name but must not treat as a +// settings report: evMessages is what stamps the timestamp a control command +// waits on, and a card list is not an acknowledgement of anything. +var evInfoMessages = map[string]map[byte]mqttField{ + msgEVCards: evCards, + msgEVOcppInfo: evOcppInfo, + msgEVCardRead: evCardRead, +} + // evMessages selects a field map by message type. A type absent from here is one // we have no map for; its frame is still parsed, but nothing is named. var evMessages = map[string]map[byte]mqttField{ @@ -365,6 +412,9 @@ func decodeFrame(data []byte) (string, map[string]any, error) { } fields := evMessages[msgType] + if fields == nil { + fields = evInfoMessages[msgType] + } values := map[string]any{} for _, r := range raw { f, known := fields[r.name] @@ -380,9 +430,28 @@ func decodeFrame(data []byte) (string, map[string]any, error) { values[f.name] = v } } + if msgType == msgEVCards { + values["rfidCards"] = cardsFromFields(raw) + } return msgType, values, nil } +// cardsFromFields reads the card UIDs out of a card-list frame. Every field from +// 0xa3 up is one card, in the order the charger sent them, and a UID is however +// many bytes the card has — four for the older tags, seven for the newer ones — +// so the bytes are taken as they are and read as hex, which is how the account +// prints them and how a person reads one off a card. +func cardsFromFields(raw []rawFieldBytes) []string { + out := []string{} + for _, r := range raw { + if r.name < 0xa3 || len(r.value) == 0 { + continue + } + out = append(out, strings.ToUpper(encodeHex(r.value))) + } + return out +} + // rawFieldName keys a field no map names, by the message it arrived in and the // name byte the charger gave it — "0410.b6". The message type belongs in the key // because a name byte means whatever its message says it means: the same b6 is a @@ -474,6 +543,11 @@ func decodeValue(typ byte, b []byte, f mqttField) (any, bool) { return scale(int64(binary.LittleEndian.Uint32(b))), true } return scale(int64(int32(binary.LittleEndian.Uint32(b)))), true + case typeBytes: + // An identifier rather than a quantity: a 4- or 7-byte card UID, which is + // the same hex the account prints on the card list. Read as a number it + // would be a different string on every screen it reached. + return strings.ToUpper(encodeHex(b)), true case typeFloat32: if len(b) < 4 { return nil, false