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>
193 lines
7.6 KiB
Go
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)
|
|
}
|