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>
271 lines
8.3 KiB
Go
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
|
|
}
|