The add and remove buttons, and the read that checks them

Anker documents neither rfid write, so the bodies are inferred from the
field names get_device_cards answers with, and every write re-reads the
list: what the card shows is what the account holds, never what an
undocumented endpoint claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 10:50:00 +02:00
co-authored by Claude Opus 5
parent a5842201c0
commit 245870a96a
10 changed files with 588 additions and 16 deletions
@@ -584,3 +584,100 @@ func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Reques
}
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
// The two card writes. They are the only calls in the Anker connector that
// change anything on the account, so both are gated exactly like the reads,
// rate-limited beside the control commands — a card is who may start a charge,
// which is the same actuator asked a slower question — and audited by serial and
// card, with the number kept out of the log line: it is the credential itself.
// Both answer with the card list as it stands after the write, so the caller
// sees what the account holds rather than what an undocumented endpoint claimed.
// ankerCardBody is what a card write is asked for.
type ankerCardBody struct {
CardNumber string `json:"cardNumber"`
CardName string `json:"cardName"`
}
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards — add a card to
// the charger, or rename one already on it.
func (s *Server) handleAnkerCardSave(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
var body ankerCardBody
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
if strings.TrimSpace(body.CardNumber) == "" {
writeError(w, http.StatusBadRequest, "card number required")
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-save", sn, map[string]any{
"sn": sn, "cardNumber": body.CardNumber, "cardName": body.CardName,
})
}
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number} —
// remove one card, named in full.
func (s *Server) handleAnkerCardDelete(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
number := strings.TrimSpace(r.PathValue("number"))
if number == "" {
writeError(w, http.StatusBadRequest, "card number required")
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-card-delete", sn, map[string]any{
"sn": sn, "cardNumber": number,
})
}
// 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
// that did not happen is not a state to render, it is a request that failed.
func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, string, map[string]string, bool) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return nil, "", nil, false
}
sn := strings.TrimSpace(r.PathValue("sn"))
if sn == "" {
writeError(w, http.StatusBadRequest, "charger serial required")
return nil, "", nil, false
}
userRaw := s.userPluginSettings(r.Context(), who.ID)
res := s.resolveAnker(r.Context(), who, userRaw)
if reason := ankerGate(res, true); reason != "" {
writeError(w, http.StatusConflict, reason)
return nil, "", nil, false
}
if !s.ctlRL.allow(who.ID + "|" + sn) {
writeError(w, http.StatusTooManyRequests, "too many card changes; please slow down")
return nil, "", nil, false
}
return who, sn, map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}, true
}
// ankerCardWrite runs one card capability and relays its document. The audit
// line names the charger and how the write went, never the card number.
func (s *Server) ankerCardWrite(w http.ResponseWriter, r *http.Request, who *callerIdentity,
cfg map[string]string, action, sn string, params map[string]any) {
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, action, mustJSON(params))
if err != nil {
s.auditControl(who, sn, action, nil, "failed", err)
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
s.auditControl(who, sn, action, nil, "ok", nil)
writeJSON(w, http.StatusOK, json.RawMessage(raw))
}
+4
View File
@@ -52,6 +52,8 @@
// POST /api/integrations/anker-solix/health
// GET /api/integrations/anker-solix/chargers
// GET /api/integrations/anker-solix/chargers/{sn}/details
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
// GET /api/integrations/greencell/chargers
@@ -441,6 +443,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
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("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)
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)
@@ -253,6 +253,8 @@ 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: "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."},
@@ -421,6 +423,10 @@ type invokeParams struct {
Company string `json:"company"`
Date string `json:"date"`
// The two RFID card writes: which card, and what to call it.
CardNumber string `json:"cardNumber"`
CardName string `json:"cardName"`
// The cloud MQTT actions: which command to send, the current ceiling "limit"
// carries, and the settings "mqtt-settings" writes, by the names the snapshot
// reports them under.
@@ -429,9 +435,11 @@ type invokeParams struct {
Settings map[string]any `json:"settings"`
}
// Invoke runs a named read-only capability. The upstream response body is
// returned verbatim, except for "charger-state", which is derived (see
// chargerState).
// Invoke runs a named capability. Every one of them reads, bar the two RFID
// card writes, which are the only calls in this connector that change anything
// on the account (see rfidcards.go). The upstream response body is returned
// verbatim, except for "charger-state" and those two, which are derived (see
// chargerState, rfidAfterWrite).
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
var pp invokeParams
if len(params) > 0 {
@@ -460,6 +468,17 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
}
return p.chargerState(ctx, pp.SiteID, 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" {
if pp.SN == "" {
return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action)
}
if action == "rfid-card-save" {
return p.rfidSaveCard(ctx, pp.SN, pp.CardNumber, pp.CardName)
}
return p.rfidDeleteCard(ctx, pp.SN, pp.CardNumber)
}
// The cloud MQTT actions address the charger itself over the account's broker
// rather than a REST endpoint, so they route to that transport instead of the
// single-endpoint dispatch below.
@@ -0,0 +1,172 @@
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 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
}
// 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 to the charger, or renames one already on it — the
// same endpoint does both, addressed by the number either way.
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)
}
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)
}
return p.rfidAfterWrite(ctx, sn, "save", number, body)
}
// 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")
}
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)
}
return p.rfidAfterWrite(ctx, sn, "delete", number, body)
}
// 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) {
out := rfidWriteResult{SN: sn, Action: action, Number: number, Response: json.RawMessage(response)}
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)
}
@@ -0,0 +1,99 @@
package ankersolix
import (
"context"
"encoding/json"
"strings"
"testing"
)
// A card number typed with punctuation is the same card as the one the account
// stores bare — anything else deletes nothing and adds a duplicate.
func TestNormalizeCardNumber(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"4754DC2A", "4754DC2A"},
{"47:54:dc:2a", "4754DC2A"},
{" 47-54 dc 2a ", "4754DC2A"},
{"04dfe672151a90", "04DFE672151A90"},
{"", ""},
{" :- ", ""},
} {
if got := normalizeCardNumber(tc.in); got != tc.want {
t.Errorf("normalizeCardNumber(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The name a card gets when it is added without one, in the shape the Anker app
// writes so a card added here does not stand out in it.
func TestRfidCardLabel(t *testing.T) {
if got := rfidCardLabel("4754DC2A"); got != "RFID DC2A" {
t.Errorf("rfidCardLabel = %q, want %q", got, "RFID DC2A")
}
if got := rfidCardLabel("2A"); got != "RFID 2A" {
t.Errorf("short number: rfidCardLabel = %q, want %q", got, "RFID 2A")
}
}
func TestParseRfidCards(t *testing.T) {
body := []byte(`{"code":0,"data":{"list":[
{"alias_name":"RFID DC2A","card_number":"4754DC2A","create_time":1788187299},
{"alias_name":"RFID B93F","card_number":"DA7CB93F","create_time":1788187315}]}}`)
cards, err := parseRfidCards(body)
if err != nil {
t.Fatalf("parseRfidCards: %v", err)
}
if len(cards) != 2 {
t.Fatalf("got %d cards, want 2", len(cards))
}
if cards[0].Name != "RFID DC2A" || cards[0].Number != "4754DC2A" {
t.Fatalf("first card = %+v", cards[0])
}
if string(cards[0].Added) != "1788187299" {
t.Fatalf("create_time = %q, want it relayed as it arrived", cards[0].Added)
}
// A charger with no cards answers with an empty document, which is an answer
// and not a failure.
cards, err = parseRfidCards([]byte(`{"code":0,"data":{}}`))
if err != nil || len(cards) != 0 {
t.Fatalf("empty list: got %d cards, err %v", len(cards), err)
}
}
// Presence is what a write is judged by, and it is judged on the number rather
// than on how either side punctuated it.
func TestCardPresent(t *testing.T) {
cards := []rfidCard{{Number: "4754DC2A"}, {Number: "DA7CB93F"}}
if !cardPresent(cards, "47:54:dc:2a") {
t.Error("punctuated number: want present")
}
if cardPresent(cards, "DEADBEEF") {
t.Error("unknown number: want absent")
}
if cardPresent(nil, "4754DC2A") {
t.Error("empty charger: want absent")
}
}
// Neither write may reach the cloud without knowing which charger and which
// card: an endpoint nobody has documented is not one to send half a request to.
func TestCardWritesRefuseIncompleteRequests(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "p"})
for _, action := range []string{"rfid-card-save", "rfid-card-delete"} {
if _, err := p.Invoke(context.Background(), action, nil); err == nil ||
!strings.Contains(err.Error(), "requires an sn") {
t.Errorf("%s without a serial: got %v, want an sn-required error", action, err)
}
// A serial but no card, and no card number to be found in punctuation
// either: both stop before anything is sent.
for _, params := range []string{`{"sn":"EVSN1"}`, `{"sn":"EVSN1","cardNumber":" :- "}`} {
_, err := p.Invoke(context.Background(), action, json.RawMessage(params))
if err == nil || !strings.Contains(err.Error(), "card number is required") {
t.Errorf("%s with %s: got %v, want a card-number-required error", action, params, err)
}
}
}
}
+16
View File
@@ -335,6 +335,22 @@ export const api = {
getAnkerChargerDetails: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/details`),
// The RFID cards on one charger — the only calls in this client that change
// anything on the Anker account. Anker documents neither endpoint, so the
// server infers the request and then reads the list back: both of these answer
// with {present, cards}, and it is the list that says what happened, not the
// status code.
saveAnkerRfidCard: (sn, cardNumber, cardName) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards`, {
method: "POST",
body: JSON.stringify({ cardNumber, cardName }),
}),
deleteAnkerRfidCard: (sn, cardNumber) =>
request(
`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/${encodeURIComponent(cardNumber)}`,
{ method: "DELETE" }
),
// Anker Solix control (per charger), over whichever transport the user's
// control mode selects. getAnkerControl returns the control mode, connection
// status, and a live status snapshot — an OCPP session snapshot in own/proxy
+9 -1
View File
@@ -68,7 +68,15 @@
"title": "RFID-kortindstillinger",
"none": "Ingen kort er godkendt til denne lader.",
"unsupported": "Tjenesten, som denne lader kommer fra, rapporterer ikke RFID-kort.",
"readOnly": "Kort tilføjes og fjernes i Anker-appen. Ankers endpoints til det er udokumenterede, så DriverVault læser listen i stedet for at gætte sig til en skrivning."
"add": "Tilføj kort",
"addTitle": "Tilføj et kort",
"remove": "Fjern",
"removeConfirm": "Fjern {name} fra denne lader?",
"numberPlaceholder": "Kortnummer",
"namePlaceholder": "Navn (valgfrit)",
"notAdded": "Tjenesten tog imod anmodningen, men kortet er ikke på laderen. Kontrollér nummeret, og prøv igen.",
"notRemoved": "Tjenesten tog imod anmodningen, men kortet er stadig på laderen.",
"inferred": "Anker dokumenterer hverken tilføjelse eller fjernelse. DriverVault udleder anmodningen af de felter, kortlisten svarer med, og læser derefter listen igen — det, du ser ovenfor, er det, kontoen har."
},
"stations": {
"heading": "I nærheden",
+9 -1
View File
@@ -337,7 +337,15 @@
"title": "RFID cards settings",
"none": "No cards are authorised on this charger.",
"unsupported": "The service this charger came from does not report RFID cards.",
"readOnly": "Cards are added and removed in the Anker app. Anker's endpoints for that are undocumented, so DriverVault reads the list rather than guessing at a write."
"add": "Add card",
"addTitle": "Add a card",
"remove": "Remove",
"removeConfirm": "Remove {name} from this charger?",
"numberPlaceholder": "Card number",
"namePlaceholder": "Name (optional)",
"notAdded": "The service took the request, but the card is not on the charger. Check the number and try again.",
"notRemoved": "The service took the request, but the card is still on the charger.",
"inferred": "Anker documents neither the add nor the remove endpoint. DriverVault infers the request from the fields the card list answers with, then reads the list back — what you see above is what the account holds."
},
"stations": {
"heading": "Nearby",
+9 -1
View File
@@ -68,7 +68,15 @@
"title": "Ustawienia kart RFID",
"none": "Na tej ładowarce nie autoryzowano żadnej karty.",
"unsupported": "Usługa, z której pochodzi ta ładowarka, nie zgłasza kart RFID.",
"readOnly": "Karty dodaje się i usuwa w aplikacji Anker. Endpointy Ankera do tego są nieudokumentowane, więc DriverVault odczytuje listę, zamiast zgadywać zapis."
"add": "Dodaj kartę",
"addTitle": "Dodaj kartę",
"remove": "Usuń",
"removeConfirm": "Usunąć {name} z tej ładowarki?",
"numberPlaceholder": "Numer karty",
"namePlaceholder": "Nazwa (opcjonalnie)",
"notAdded": "Usługa przyjęła żądanie, ale karty nie ma na ładowarce. Sprawdź numer i spróbuj ponownie.",
"notRemoved": "Usługa przyjęła żądanie, ale karta nadal jest na ładowarce.",
"inferred": "Anker nie dokumentuje ani dodawania, ani usuwania. DriverVault wnioskuje żądanie z pól, którymi odpowiada lista kart, a potem odczytuje listę ponownie — powyżej widzisz to, co ma konto."
},
"stations": {
"heading": "W pobliżu",
+151 -10
View File
@@ -1157,6 +1157,12 @@ async function loadChargerDetails(force = false) {
try {
const res = await api.getAnkerChargerDetails(sn);
chargerDetails.value = { ...chargerDetails.value, [sn]: res };
// The account has just been asked; whatever a write read back is now the
// older answer of the two.
if (rfidWritten.value[sn]) {
const { [sn]: _dropped, ...rest } = rfidWritten.value;
rfidWritten.value = rest;
}
} catch {
// A view the account cannot read is not an error to put on the page: the
// rows above still say everything the inventory knew.
@@ -1193,6 +1199,98 @@ const chargerDetailViews = computed(() => {
// hole in that list would be the one thing it cannot say.
const rfidView = computed(() => chargerDetailViews.value.find((v) => v.id === "rfid") || null);
// The cards on the charger being looked at, as objects rather than as the
// flattened keys the view answers with: this card needs a number to delete by,
// and a row of text is not a number. Both sources — the view and what a write
// read back — carry the same three fields, so one shape reads both.
const rfidWritten = ref({}); // serial → the list as the last write found it
const detailSn = computed(() => {
const c = selectedHomeCharger.value;
return c?.providerChargerId || c?.serial || "";
});
function rfidCardRow(c) {
const number = String(c.card_number ?? "").trim();
return {
number,
name: String(c.alias_name ?? "").trim() || number,
added: viewTimeValue(String(c.create_time ?? "")),
};
}
function rfidCardsFrom(attrs) {
const by = new Map();
for (const [key, value] of Object.entries(attrs || {})) {
const m = /^list\[(\d+)\]\.(alias_name|card_number|create_time)$/.exec(key);
if (!m) continue;
const i = Number(m[1]);
if (!by.has(i)) by.set(i, { index: i });
by.get(i)[m[2]] = value;
}
return [...by.values()].sort((a, b) => a.index - b.index).map(rfidCardRow);
}
// What the card draws: the list a write last read back when there is one, and
// the account's own view of it otherwise. A refresh drops the write's copy, so
// the server's answer is always what wins in the end.
const rfidCards = computed(() => {
const sn = detailSn.value;
const written = rfidWritten.value[sn];
if (written) return written.map(rfidCardRow);
const view = (chargerDetails.value[sn]?.views || []).find((v) => v.id === "rfid");
return rfidCardsFrom(view?.attrs);
});
const rfidBusy = ref(""); // the card being written, or "new" while one is added
const rfidError = ref("");
const newCardNumber = ref("");
const newCardName = ref("");
// Adding a card, and then believing the list rather than the answer: Anker's
// write endpoint is undocumented, so a 200 from it proves nothing on its own.
// The card is only cleared out of the form once the account says it is there.
async function addRfidCard() {
const sn = detailSn.value;
const number = newCardNumber.value.trim();
if (!sn || !number || rfidBusy.value) return;
rfidBusy.value = "new";
rfidError.value = "";
try {
const res = await api.saveAnkerRfidCard(sn, number, newCardName.value.trim());
rfidWritten.value = { ...rfidWritten.value, [sn]: res?.cards || [] };
if (res?.present === false) {
rfidError.value = t("charging.rfid.notAdded");
} else {
newCardNumber.value = "";
newCardName.value = "";
}
} catch (e) {
rfidError.value = e.message;
} finally {
rfidBusy.value = "";
}
}
// Removing one asks first — a card that is gone can only be put back by whoever
// still has it in their hand.
async function removeRfidCard(card) {
const sn = detailSn.value;
if (!sn || !card.number || rfidBusy.value) return;
if (!(await askConfirm(t("charging.rfid.removeConfirm", { name: card.name })))) return;
rfidBusy.value = card.number;
rfidError.value = "";
try {
const res = await api.deleteAnkerRfidCard(sn, card.number);
rfidWritten.value = { ...rfidWritten.value, [sn]: res?.cards || [] };
if (res?.present) rfidError.value = t("charging.rfid.notRemoved");
} catch (e) {
rfidError.value = e.message;
} finally {
rfidBusy.value = "";
}
}
// Everything else the service said about this charger, under its own field
// names. The rows above are the ones DriverVault has a name for; these are the
// remainder — the service documents none of them, so its own key is the only
@@ -1694,7 +1792,7 @@ onMounted(async () => {
@dragend="commitCardOrder"
>
<p class="text-sm font-semibold text-strong">{{ t("charging.rfid.title") }}</p>
<span v-if="rfidView?.items.length" class="dh-badge dh-badge-neutral">{{ rfidView.items.length }}</span>
<span v-if="rfidCards.length" class="dh-badge dh-badge-neutral">{{ rfidCards.length }}</span>
</button>
<button
type="button"
@@ -1720,24 +1818,67 @@ onMounted(async () => {
<div v-show="isOpen('rfid')">
<p class="mt-1 text-[11px] text-muted">{{ selectedHomeCharger.name }}</p>
<p v-if="rfidError" class="mt-3 rounded-control bg-danger-soft px-3 py-2 text-xs font-medium text-danger">
{{ rfidError }}
</p>
<!-- A service that does not answer for cards, an account that may
not read them, and a charger with none on it are three different
answers, and each is said in its own words. -->
<p v-if="!rfidView" class="mt-3 text-xs text-muted">{{ t("charging.rfid.unsupported") }}</p>
<p v-else-if="rfidView.error" class="mt-3 text-xs text-muted">{{ rfidView.error }}</p>
<p v-else-if="!rfidView.items.length" class="mt-3 text-xs text-muted">{{ t("charging.rfid.none") }}</p>
<p v-if="!rfidView && !rfidCards.length" class="mt-3 text-xs text-muted">{{ t("charging.rfid.unsupported") }}</p>
<p v-else-if="rfidView?.error && !rfidCards.length" class="mt-3 text-xs text-muted">{{ rfidView.error }}</p>
<p v-else-if="!rfidCards.length" class="mt-3 text-xs text-muted">{{ t("charging.rfid.none") }}</p>
<div v-else class="mt-3 flex flex-col gap-2">
<div v-for="card in rfidView.items" :key="card.key" class="rounded-control bg-sunken p-3">
<p class="text-sm font-semibold text-strong">{{ card.label }}</p>
<div v-for="card in rfidCards" :key="card.number" class="rounded-control bg-sunken p-3">
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-semibold text-strong">{{ card.name }}</p>
<button
type="button"
class="dh-btn dh-btn-ghost shrink-0 !px-2 !py-1 text-xs text-danger"
:disabled="rfidBusy !== ''"
@click="removeRfidCard(card)"
>
{{ rfidBusy === card.number ? t("common.loading") : t("charging.rfid.remove") }}
</button>
</div>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in card.rows" :key="row.key">
<dt class="break-all text-[11px] text-muted" :class="row.named ? '' : 'data'">{{ row.label }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
<dt class="text-[11px] text-muted">{{ t("charging.info.fields.cardNumber") }}</dt>
<dd class="data break-all text-[11px] text-body">{{ card.number }}</dd>
<template v-if="card.added">
<dt class="text-[11px] text-muted">{{ t("charging.info.fields.added") }}</dt>
<dd class="data break-all text-[11px] text-body">{{ card.added }}</dd>
</template>
</dl>
</div>
</div>
<p class="mt-3 text-[11px] text-muted">{{ t("charging.rfid.readOnly") }}</p>
<!-- Adding one. The number is the card itself, so it is the only
field that is required; a card added without a name gets the one
the Anker app would have given it. -->
<form class="mt-3 rounded-control bg-sunken p-3" @submit.prevent="addRfidCard">
<p class="eyebrow">{{ t("charging.rfid.addTitle") }}</p>
<div class="mt-2 flex flex-col gap-2 sm:flex-row">
<input
v-model="newCardNumber"
class="dh-input data grow"
:placeholder="t('charging.rfid.numberPlaceholder')"
:aria-label="t('charging.info.fields.cardNumber')"
/>
<input
v-model="newCardName"
class="dh-input grow"
:placeholder="t('charging.rfid.namePlaceholder')"
:aria-label="t('charging.info.fields.cardName')"
/>
<button
type="submit"
class="dh-btn dh-btn-primary shrink-0"
:disabled="!newCardNumber.trim() || rfidBusy !== ''"
>
{{ rfidBusy === "new" ? t("common.loading") : t("charging.rfid.add") }}
</button>
</div>
</form>
<p class="mt-3 text-[11px] text-muted">{{ t("charging.rfid.inferred") }}</p>
</div>
</div>