diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index c69d3b6..1130df4 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -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)) +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index e62995b..5f7b61b 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -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) diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 8b5f9bb..bbdb676 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -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. diff --git a/API Server/internal/plugins/builtin/ankersolix/rfidcards.go b/API Server/internal/plugins/builtin/ankersolix/rfidcards.go new file mode 100644 index 0000000..c87ee81 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/rfidcards.go @@ -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) +} diff --git a/API Server/internal/plugins/builtin/ankersolix/rfidcards_test.go b/API Server/internal/plugins/builtin/ankersolix/rfidcards_test.go new file mode 100644 index 0000000..a63e987 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/rfidcards_test.go @@ -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) + } + } + } +} diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 51a06d8..9454511 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -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 diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 50ffda2..01f9960 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -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", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 1511945..dfd2e20 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 6bea1cb..adfd938 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -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", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 735313e..5f823c9 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -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" >

{{ t("charging.rfid.title") }}

- {{ rfidView.items.length }} + {{ rfidCards.length }} +
-