Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/mqttcards.go
T
tajniak81andClaude Opus 5 7176867eb3 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>
2026-09-03 13:45:00 +02:00

271 lines
8.3 KiB
Go

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
}