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:
co-authored by
Claude Opus 5
parent
a5842201c0
commit
245870a96a
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user