The reader speaks: 0904 is the card list, 0908 is the tap

Named from a live capture on an A5191: 0904 carried the nine UIDs the
account's own card list answers with, and 0908 arrived the moment a card
touched the reader, carrying its UID — and arrived without one when the
window closed empty. 0911 names the OCPP backend the charger is pointed
at. A UID is bytes, not a number, so type 0x04 now reads as hex.

With the frame log on, the command topic is subscribed too: the app's own
commands are the half no capture has seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 13:04:52 +02:00
co-authored by Claude Opus 5
parent a7719fca6a
commit 62b0a29598
3 changed files with 219 additions and 0 deletions
@@ -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
}
@@ -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"])
}
}
@@ -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