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:
co-authored by
Claude Opus 5
parent
a7719fca6a
commit
62b0a29598
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user