Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/rfidcards.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

193 lines
7.6 KiB
Go

package ankersolix
// The cards that open the charger, and the two writes that change them.
//
// get_device_cards is well behaved: every card comes back as alias_name,
// card_number and create_time, and nothing else. There is no card id anywhere in
// that payload, which is why both writes below address a card by its number —
// it is the only handle the account ever gives out.
//
// save_device_card and delete_device_card are a different matter. Neither is
// documented, by Anker or by the reference implementation: both names were read
// out of the app package, and the bodies here are inferred from the field names
// the read view answers with. That is a guess, and it is treated as one:
//
// - a write never reports its own success. After the call the card list is
// read again and the answer says whether the card is on the charger now, so
// a caller never has to take an ack's word for what happened;
// - the cloud's own response is relayed alongside it, because an endpoint
// nobody has documented is one whose reply is worth reading;
// - nothing here deletes by pattern, by index, or in bulk. One card, named in
// full, per call.
import (
"context"
"encoding/json"
"fmt"
"strings"
)
const (
epRfidSaveCard = "power_service/v1/rfid/save_device_card" // add or rename one card — payload inferred, see above
epRfidDeleteCard = "power_service/v1/rfid/delete_device_card" // remove one card — payload inferred, see above
)
// rfidCard is one authorised card, under the names get_device_cards uses for it.
// create_time is relayed rather than parsed: it is upstream's own epoch and the
// UI already knows how to read one.
type rfidCard struct {
Name string `json:"alias_name,omitempty"`
Number string `json:"card_number,omitempty"`
Added json.RawMessage `json:"create_time,omitempty"`
}
// 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 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.
// The numbers arrive as bare uppercase hex; people type them with spaces, dashes
// or colons between the bytes, and a number that differs from the stored one
// only in punctuation would delete nothing and add a duplicate.
func normalizeCardNumber(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= 'a' && r <= 'z':
b.WriteRune(r - 'a' + 'A')
}
}
return b.String()
}
// rfidCardLabel is the name a card gets when it is added without one — the same
// shape the Anker app writes, so a card added here does not stand out in it.
func rfidCardLabel(number string) string {
if len(number) > 4 {
number = number[len(number)-4:]
}
return "RFID " + number
}
// parseRfidCards reads the card list out of get_device_cards' envelope. A
// response that carries no list is an empty charger, not an error: an account
// with no cards answers exactly that way.
func parseRfidCards(body []byte) ([]rfidCard, error) {
var env struct {
Data struct {
List []rfidCard `json:"list"`
} `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return env.Data.List, nil
}
// cardPresent says whether a number is among the cards, comparing them the way
// normalizeCardNumber writes them so punctuation cannot answer for the account.
func cardPresent(cards []rfidCard, number string) bool {
want := normalizeCardNumber(number)
for _, c := range cards {
if normalizeCardNumber(c.Number) == want {
return true
}
}
return false
}
// rfidList is the cards authorised on one charger, as the account holds them.
func (p *Plugin) rfidList(ctx context.Context, sn string) ([]rfidCard, error) {
body, err := p.apiRequest(ctx, epRfidCards, map[string]any{"device_sn": sn})
if err != nil {
return nil, err
}
return parseRfidCards(body)
}
// 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 == "" {
return nil, fmt.Errorf("anker-solix: a card number is required")
}
name = strings.TrimSpace(name)
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 && 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, dev, devErr)
}
// rfidDeleteCard removes one card from the charger. The caller names the whole
// number: there is no "delete the third one" here, because an index into a list
// that was read a minute ago is not a card.
func (p *Plugin) rfidDeleteCard(ctx context.Context, sn, number string) (json.RawMessage, error) {
number = normalizeCardNumber(number)
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 && 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, 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,
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()
} else {
out.Cards = cards
out.Present = cardPresent(cards, number)
}
return json.Marshal(out)
}