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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user