Tap the card on the charger and the number fills itself in

The enrolment the Anker app does, done here: 0108 a2=7 opens the reader,
0908 brings back the UID. The frames this sends are byte-for-byte the
ones the app was captured sending — checksum included — which is what the
new test asserts.

Adding and removing now write the charger as well as the account: the
device write is the app's own message, the account write is the inferred
one that carries the name, and either may fail without the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 13:45:00 +02:00
co-authored by Claude Opus 5
parent 4c73d4ac05
commit 7176867eb3
13 changed files with 473 additions and 19 deletions
@@ -636,6 +636,18 @@ func (s *Server) handleAnkerCardDelete(w http.ResponseWriter, r *http.Request) {
})
}
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan — open the
// charger's card reader and answer with the card someone taps on it. Takes as
// long as the window does, about twenty seconds, and answers either way: a
// window that closed with nothing tapped is an answer, not a timeout.
func (s *Server) handleAnkerCardScan(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-scan", sn, map[string]any{"sn": sn})
}
// ankerCardGate is everything both writes need before they may run: a caller, a
// serial, an integration that is on and has credentials, and a rate limit. A
// gate that is off answers 409 rather than the reads' 200-with-a-reason: a write
+2
View File
@@ -53,6 +53,7 @@
// GET /api/integrations/anker-solix/chargers
// GET /api/integrations/anker-solix/chargers/{sn}/details
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
@@ -444,6 +445,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan", s.handleAnkerCardScan)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete)
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
@@ -253,8 +253,10 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
{ID: "charge-orders", Method: "POST", Endpoint: epChargeStatsList, Description: "Per-session charging history for one charger (needs sn)."},
{ID: "charge-energy", Method: "POST", Endpoint: epEnergyAnalysis, Description: "Interval charging energy for a site's EV charger (needs siteId; optional sn, range, startDate, endDate)."},
{ID: "rfid-cards", Method: "POST", Endpoint: epRfidCards, Description: "RFID cards authorised on one charger (needs sn)."},
{ID: "rfid-card-save", Method: "POST", Endpoint: epRfidSaveCard, Description: "Add a card to one charger, or rename one already on it, and answer with the list as it stands afterwards (needs sn, cardNumber; optional cardName). WRITES; the payload is inferred from the read view rather than documented (see rfidcards.go)."},
{ID: "rfid-card-delete", Method: "POST", Endpoint: epRfidDeleteCard, Description: "Remove one card from a charger and answer with the list as it stands afterwards (needs sn, cardNumber). WRITES; the payload is inferred from the read view rather than documented (see rfidcards.go)."},
{ID: "rfid-card-save", Method: "POST", Endpoint: epRfidSaveCard, Description: "Add a card, at the charger over MQTT and on the account over REST, and answer with the list as it stands afterwards (needs sn, cardNumber; optional cardName). WRITES; the charger's half is the app's own message, the account's is inferred (see rfidcards.go, mqttcards.go)."},
{ID: "rfid-card-delete", Method: "POST", Endpoint: epRfidDeleteCard, Description: "Remove one card, both places it is held, and answer with the list afterwards (needs sn, cardNumber). WRITES; same two halves."},
{ID: "rfid-card-scan", Method: "MQTT", Endpoint: "0108 a2=7", Description: "Open the charger's card reader for twenty seconds and answer with the card tapped, or with the fact that none was (needs sn). This is what \"add through the charger\" in the Anker app does."},
{ID: "rfid-cards-charger", Method: "MQTT", Endpoint: "0104", Description: "The cards the charger itself holds, asked of the device rather than of the account (needs sn)."},
{ID: "ocpp-info", Method: "POST", Endpoint: epOcppInfo, Description: "OCPP endpoint source info for one charger (needs sn)."},
{ID: "ocpp-endpoints", Method: "POST", Endpoint: epOcppEndpoints, Description: "The OCPP endpoints Anker itself uses, with their source numbers."},
{ID: "devices", Method: "POST", Endpoint: epBindDevices, Description: "Bound devices on the account, incl. firmware version."},
@@ -468,6 +470,17 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
}
return p.chargerState(ctx, pp.SiteID, pp.SN)
}
// Reading a card at the charger and asking the charger for its list are the
// device's own messages, not endpoints (see mqttcards.go).
if action == "rfid-card-scan" || action == "rfid-cards-charger" {
if pp.SN == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
}
if action == "rfid-card-scan" {
return p.mqttReadCard(ctx, pp.SN)
}
return p.mqttChargerCards(ctx, pp.SN)
}
// The two card writes are a write followed by the read that checks it, so
// neither fits the single-endpoint dispatch below either.
if action == "rfid-card-save" || action == "rfid-card-delete" {
@@ -275,6 +275,16 @@ type deviceState struct {
settingsAt time.Time
triggeredUntil time.Time
// The card half, kept beside the readings rather than in them: when the
// reader last reported a card and which, and when the charger last published
// its card list and what was in it. Each carries its own time because both
// are waited on — a value that was already there before the question was
// asked is not an answer to it.
cardReadAt time.Time
cardRead string
cardsAt time.Time
cards []string
// How many status requests this charger has been asked and not answered.
// The request is cheap to send and is sent regardless; what it buys is the
// right to wait a few seconds for the reply, and a charger whose firmware
@@ -527,6 +537,18 @@ func (c *mqttConn) ingest(msg mqtt.Message) {
st.values[k] = v
}
now := time.Now()
// The card frames are stamped separately: a tap and a card list are answers
// to questions this package asks one at a time, and each waits for its own.
if s, ok := values["rfidCardRead"].(string); ok && s != "" {
st.cardRead, st.cardReadAt = s, now
} else if msgType == msgEVCardRead {
// The window closed with nothing tapped. That is an answer too, and the
// wait must end on it rather than run to its own timeout.
st.cardRead, st.cardReadAt = "", now
}
if cards, ok := values["rfidCards"].([]string); ok {
st.cards, st.cardsAt = cards, now
}
// Only a message this package can name counts as a report. An unnamed one
// still leaves its fields behind, but it must not stamp settingsAt: that
// timestamp is what a control command waits on to say the charger
@@ -0,0 +1,270 @@
package ankersolix
// Cards, at the charger rather than at the account.
//
// The Anker app offers two ways to add an RFID card: type its number, which is
// the account write in rfidcards.go, or hold the card against the reader inside
// a twenty-second window. The second one never touches the REST API. It is three
// messages on the charger's own MQTT topics, captured from the app's traffic:
//
// 0108 a2=7 open the reader -> 0908, with the UID when a card is
// tapped and without one when the
// window closes empty
// 0103 a2=1 write the card -> 0903, then 0904
// 0103 a2=2 remove it -> the same pair
// 0104 ask for the card list -> 0904
//
// Nothing here is inferred: every frame above is one this connector watched the
// app send and the charger answer. What is inferred is the account write those
// three replace, which is why a card written here is checked by reading the
// charger's own list back rather than by trusting the write.
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
const (
// cardReadWindow is how long the reader stays open. The app counts twenty
// seconds down; a couple more allow for the answer's trip back through the
// cloud, and the charger closes the window on its own either way.
cardReadWindow = 24 * time.Second
// cardWriteWait is how long a write waits for the charger to republish its
// list. The answer arrived within a second in every capture.
cardWriteWait = 8 * time.Second
// cardWriteAdd and cardWriteRemove are the two values the write takes.
cardWriteAdd uint8 = 1
cardWriteRemove uint8 = 2
)
// cardReadResult is what a scan answers with: the card that was tapped, or the
// plain fact that nothing was.
type cardReadResult struct {
SN string `json:"sn"`
Card string `json:"card,omitempty"`
Tapped bool `json:"tapped"`
Seconds int `json:"windowSeconds"`
}
// mqttReadCard opens the charger's card reader and waits for a card. The
// charger answers either way — with a UID when one is tapped, without when the
// window closes — so a scan that finds nothing is an answer rather than a
// timeout, and says so.
func (p *Plugin) mqttReadCard(ctx context.Context, sn string) (json.RawMessage, error) {
model, err := p.chargerModel(ctx, sn)
if err != nil {
return nil, err
}
conn, err := p.mqttClient(ctx)
if err != nil {
return nil, err
}
// Listen before asking: the charger answers in under a second, and a
// subscription made afterwards would miss it.
if err := conn.listen(ctx, model, sn); err != nil {
return nil, err
}
since := conn.cardReadAt(sn)
// Field for field what the app sends, timestamp included — which is to say
// not included: the app's own reader-open frame carries none, and this
// command is copied rather than composed.
frame, err := encodeFrame(msgEVPowerMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, powerModeReadCard),
})
if err != nil {
return nil, err
}
// The same encoding the other power-mode command carries; this is that
// command with a different value, and the charger expects the field on it.
if err := conn.publishFrame(ctx, model, sn, frame, mqttEncodingMode); err != nil {
return nil, fmt.Errorf("anker-solix: opening the card reader on %s: %w", sn, err)
}
ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.cardReadAt.After(since)
}, cardReadWindow)
if err != nil {
return nil, err
}
out := cardReadResult{SN: sn, Seconds: int(cardReadWindow / time.Second)}
if ok {
out.Card = conn.cardRead(sn)
out.Tapped = out.Card != ""
}
return json.Marshal(out)
}
// cardWriteResult is what a device write answers with: what was asked, and the
// charger's own list afterwards. Present is read from that list — the charger
// says 0903 to everything, and a write is judged by what it changed.
type cardWriteResult struct {
SN string `json:"sn"`
Action string `json:"action"` // save | delete
Number string `json:"cardNumber"`
Present bool `json:"present"`
Cards []string `json:"cards"`
Via string `json:"via"` // charger
Detail string `json:"detail,omitempty"`
}
// mqttWriteCard adds a card to the charger or removes one, and then reads the
// charger's list back. Both halves are the app's own messages.
func (p *Plugin) mqttWriteCard(ctx context.Context, sn, number string, add bool) (*cardWriteResult, error) {
number = normalizeCardNumber(number)
uid, err := hex.DecodeString(number)
if err != nil || len(uid) == 0 {
return nil, fmt.Errorf("anker-solix: %q is not a card number: the charger takes the UID as hex", number)
}
model, err := p.chargerModel(ctx, sn)
if err != nil {
return nil, err
}
conn, err := p.mqttClient(ctx)
if err != nil {
return nil, err
}
if err := conn.listen(ctx, model, sn); err != nil {
return nil, err
}
since := conn.cardsAt(sn)
action := cardWriteAdd
name := "save"
if !add {
action, name = cardWriteRemove, "delete"
}
frame, err := encodeFrame(msgEVCardWrite, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, action),
uintField(0xa3, 1),
bytesField(0xa4, uid),
})
if err != nil {
return nil, err
}
if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil {
return nil, fmt.Errorf("anker-solix: writing card %s to %s: %w", number, sn, err)
}
out := &cardWriteResult{SN: sn, Action: name, Number: number, Via: "charger"}
cards, err := p.mqttCardList(ctx, conn, model, sn, since)
if err != nil {
// The write went out; what it did is simply unknown, which is not the
// same as it having failed and must not be reported as either.
out.Detail = err.Error()
return out, nil
}
out.Cards = cards
for _, c := range cards {
if normalizeCardNumber(c) == number {
out.Present = true
break
}
}
return out, nil
}
// mqttCardList is the charger's own list of the cards it will open for. A write
// republishes it unasked; when it does not, it is asked for.
func (p *Plugin) mqttCardList(ctx context.Context, conn *mqttConn, model, sn string, since time.Time) ([]string, error) {
ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.cardsAt.After(since)
}, cardWriteWait)
if err != nil {
return nil, err
}
if !ok {
// Nothing came unasked, so ask.
frame, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)})
if err != nil {
return nil, err
}
if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil {
return nil, err
}
ok, err = conn.waitFor(ctx, sn, func(st *deviceState) bool {
return st.cardsAt.After(since)
}, cardWriteWait)
if err != nil {
return nil, err
}
}
if !ok {
return nil, fmt.Errorf("anker-solix: charger %s did not answer with its card list", sn)
}
return conn.cards(sn), nil
}
// mqttChargerCards asks the charger for its list on its own, for a caller that
// wants to know what the device holds rather than what the account does.
func (p *Plugin) mqttChargerCards(ctx context.Context, sn string) (json.RawMessage, error) {
model, err := p.chargerModel(ctx, sn)
if err != nil {
return nil, err
}
conn, err := p.mqttClient(ctx)
if err != nil {
return nil, err
}
if err := conn.listen(ctx, model, sn); err != nil {
return nil, err
}
since := conn.cardsAt(sn)
frame, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)})
if err != nil {
return nil, err
}
if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil {
return nil, err
}
cards, err := p.mqttCardList(ctx, conn, model, sn, since)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"sn": sn, "cards": cards, "via": "charger"})
}
// ---- the card half of a device's state, read out from under the lock --------
func (c *mqttConn) cardReadAt(sn string) time.Time {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return st.cardReadAt
}
return time.Time{}
}
func (c *mqttConn) cardRead(sn string) string {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return st.cardRead
}
return ""
}
func (c *mqttConn) cardsAt(sn string) time.Time {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return st.cardsAt
}
return time.Time{}
}
func (c *mqttConn) cards(sn string) []string {
c.mu.Lock()
defer c.mu.Unlock()
if st := c.devices[sn]; st != nil {
return append([]string(nil), st.cards...)
}
return nil
}
@@ -152,3 +152,51 @@ func TestDecodeCardWriteCommands(t *testing.T) {
}
}
}
// The frames this connector sends to enrol a card, against the ones the Anker
// app was captured sending. They are compared byte for byte: neither carries a
// timestamp, so there is nothing in them that can differ between two senders,
// and anything that does differ is a difference the charger would see.
func TestCardCommandsMatchTheApp(t *testing.T) {
open, err := encodeFrame(msgEVPowerMode, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, powerModeReadCard),
})
if err != nil {
t.Fatal(err)
}
if got, want := hex.EncodeToString(open), "ff09110003000f0108a10122a2020107c6"; got != want {
t.Errorf("reader-open frame:\n got %s\nwant %s (the app's own)", got, want)
}
list, err := encodeFrame(msgEVCardListReq, []cmdField{rawField(0xa1, 0x22)})
if err != nil {
t.Fatal(err)
}
if got, want := hex.EncodeToString(list), "ff090d0003000f0104a1012270"; got != want {
t.Errorf("list-request frame:\n got %s\nwant %s (the app's own)", got, want)
}
uid, _ := hex.DecodeString("04DFE672151A90")
for _, tc := range []struct {
name string
action uint8
want string
}{
{"add", cardWriteAdd, "ff091f0003000f0103a10122a2020101a3020101a4080404dfe672151a901c"},
{"delete", cardWriteRemove, "ff091f0003000f0103a10122a2020102a3020101a4080404dfe672151a901f"},
} {
frame, err := encodeFrame(msgEVCardWrite, []cmdField{
rawField(0xa1, 0x22),
uintField(0xa2, tc.action),
uintField(0xa3, 1),
bytesField(0xa4, uid),
})
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if got := hex.EncodeToString(frame); got != tc.want {
t.Errorf("%s frame:\n got %s\nwant %s (the app's own)", tc.name, got, tc.want)
}
}
}
@@ -323,6 +323,12 @@ func varField(name byte, v uint32) cmdField {
return cmdField{name: name, typ: typeInt32LE, value: b}
}
// bytesField builds a field holding bytes that are an identifier rather than a
// number — an RFID UID, four bytes or seven, exactly as the reader reported it.
func bytesField(name byte, b []byte) cmdField {
return cmdField{name: name, typ: typeBytes, value: append([]byte(nil), b...)}
}
// timestampField is the `fe` field every command ends with: the sender's clock,
// in whole seconds.
func timestampField(now time.Time) cmdField {
@@ -44,13 +44,16 @@ type rfidCard struct {
// rfidWriteResult is what a write answers with: what was asked, what the cloud
// said, and — the part that matters — the list as it stands afterwards.
type rfidWriteResult struct {
SN string `json:"sn"`
Action string `json:"action"` // save | delete
Number string `json:"cardNumber"` // as it was sent, normalized
Present bool `json:"present"` // whether that card is on the charger now
Cards []rfidCard `json:"cards"`
Response json.RawMessage `json:"response,omitempty"`
Detail string `json:"detail,omitempty"` // why the list could not be read back
SN string `json:"sn"`
Action string `json:"action"` // save | delete
Number string `json:"cardNumber"` // as it was sent, normalized
Present bool `json:"present"` // whether the account holds that card now
Cards []rfidCard `json:"cards"`
// What the charger itself did, when it could be reached: the write the Anker
// app makes, and the only one of the two that is not inferred.
Charger *cardWriteResult `json:"charger,omitempty"`
Response json.RawMessage `json:"response,omitempty"`
Detail string `json:"detail,omitempty"` // why the list could not be read back
}
// normalizeCardNumber puts a card number in the form the account stores it in.
@@ -115,8 +118,14 @@ func (p *Plugin) rfidList(ctx context.Context, sn string) ([]rfidCard, error) {
return parseRfidCards(body)
}
// rfidSaveCard adds a card to the charger, or renames one already on it — the
// same endpoint does both, addressed by the number either way.
// rfidSaveCard adds a card, both places it has to be added.
//
// The charger is written first, with the app's own message: it is the device
// that decides who may start a charge, and that write is the one this connector
// watched the app make rather than inferred. The account write follows because
// it is the half that carries a name — the charger's message has no name field —
// and because the list people read is the account's. Either may fail on its own
// and the answer says which; only both failing is an error.
func (p *Plugin) rfidSaveCard(ctx context.Context, sn, number, name string) (json.RawMessage, error) {
number = normalizeCardNumber(number)
if number == "" {
@@ -126,15 +135,16 @@ func (p *Plugin) rfidSaveCard(ctx context.Context, sn, number, name string) (jso
if name == "" {
name = rfidCardLabel(number)
}
dev, devErr := p.mqttWriteCard(ctx, sn, number, true)
body, err := p.apiRequest(ctx, epRfidSaveCard, map[string]any{
"device_sn": sn,
"card_number": number,
"alias_name": name,
})
if err != nil {
return nil, fmt.Errorf("anker-solix: saving card %s: %w", number, err)
if err != nil && devErr != nil {
return nil, fmt.Errorf("anker-solix: saving card %s: charger: %v; account: %v", number, devErr, err)
}
return p.rfidAfterWrite(ctx, sn, "save", number, body)
return p.rfidAfterWrite(ctx, sn, "save", number, body, dev, devErr)
}
// rfidDeleteCard removes one card from the charger. The caller names the whole
@@ -145,22 +155,32 @@ func (p *Plugin) rfidDeleteCard(ctx context.Context, sn, number string) (json.Ra
if number == "" {
return nil, fmt.Errorf("anker-solix: a card number is required")
}
dev, devErr := p.mqttWriteCard(ctx, sn, number, false)
body, err := p.apiRequest(ctx, epRfidDeleteCard, map[string]any{
"device_sn": sn,
"card_number": number,
})
if err != nil {
return nil, fmt.Errorf("anker-solix: deleting card %s: %w", number, err)
if err != nil && devErr != nil {
return nil, fmt.Errorf("anker-solix: deleting card %s: charger: %v; account: %v", number, devErr, err)
}
return p.rfidAfterWrite(ctx, sn, "delete", number, body)
return p.rfidAfterWrite(ctx, sn, "delete", number, body, dev, devErr)
}
// rfidAfterWrite reads the list back and answers with it. A list that cannot be
// read is not a failed write — the write already happened — so it is reported as
// the detail beside an answer that says nothing about presence rather than
// guessing at one.
func (p *Plugin) rfidAfterWrite(ctx context.Context, sn, action, number string, response []byte) (json.RawMessage, error) {
func (p *Plugin) rfidAfterWrite(ctx context.Context, sn, action, number string, response []byte,
dev *cardWriteResult, devErr error) (json.RawMessage, error) {
out := rfidWriteResult{SN: sn, Action: action, Number: number, Response: json.RawMessage(response)}
if dev != nil {
out.Charger = dev
} else if devErr != nil {
// The charger could not be reached or would not answer. The account write
// may still have landed, so this is said beside the answer rather than
// instead of it.
out.Charger = &cardWriteResult{SN: sn, Action: action, Number: number, Via: "charger", Detail: devErr.Error()}
}
cards, err := p.rfidList(ctx, sn)
if err != nil {
out.Detail = err.Error()