From 3064bff8ad43e80e6328b93dcc400ce20bbb5875 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:35:27 +0200 Subject: [PATCH] The charger's own card list gets a door, and says where it differs 0104 was already implemented and already in the action catalogue; nothing routed to it, so the only way to see the device's list was as a side effect of writing a card. It has an endpoint now, and the panel a button. The two lists are compared where they meet: a card the charger holds and the account has forgotten still opens it, and a card only the account holds will not, and neither shows anywhere else. The comparison is drawn only when they disagree, and the device's reading is dropped on a refresh rather than measured against an account list from a later moment. The new test asks all four card routes without a token: a capability the plugin implements and the catalogue advertises is still unusable if nothing routes to it, and no other test here would notice. Co-Authored-By: Claude Opus 5 --- .../internal/api/integrations_ankersolix.go | 21 ++++- .../api/integrations_ankersolix_test.go | 24 +++++ API Server/internal/api/server.go | 2 + Web App/web/src/api.js | 6 ++ Web App/web/src/i18n/da.json | 7 ++ Web App/web/src/i18n/en.json | 7 ++ Web App/web/src/i18n/pl.json | 7 ++ Web App/web/src/views/Charging.vue | 93 +++++++++++++++++++ 8 files changed, 165 insertions(+), 2 deletions(-) diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index ff41cf3..d823552 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -648,7 +648,24 @@ func (s *Server) handleAnkerCardScan(w http.ResponseWriter, r *http.Request) { s.ankerCardWrite(w, r, who, cfg, "rfid-card-scan", sn, map[string]any{"sn": sn}) } -// ankerCardGate is everything both writes need before they may run: a caller, a +// GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger — the list +// of cards the charger itself holds, asked of the device with 0104 rather than +// of the account. +// +// The two lists are written together and can still come apart: a card the +// account has forgotten still opens the charger until the device is told +// otherwise, and the account's copy is the only one every other view here +// draws. Asking the device is the only way to see the difference. It answers +// with UIDs and nothing else — the charger has no field for a card's name. +func (s *Server) handleAnkerChargerCards(w http.ResponseWriter, r *http.Request) { + who, sn, cfg, ok := s.ankerCardGate(w, r) + if !ok { + return + } + s.ankerCardWrite(w, r, who, cfg, "rfid-cards-charger", sn, map[string]any{"sn": sn}) +} + +// ankerCardGate is everything a card call needs before it 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. @@ -670,7 +687,7 @@ func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerI return nil, "", nil, false } if !s.ctlRL.allow(who.ID + "|" + sn) { - writeError(w, http.StatusTooManyRequests, "too many card changes; please slow down") + writeError(w, http.StatusTooManyRequests, "too many card requests; please slow down") return nil, "", nil, false } return who, sn, map[string]string{ diff --git a/API Server/internal/api/integrations_ankersolix_test.go b/API Server/internal/api/integrations_ankersolix_test.go index 230ba39..f5f2797 100644 --- a/API Server/internal/api/integrations_ankersolix_test.go +++ b/API Server/internal/api/integrations_ankersolix_test.go @@ -3,10 +3,12 @@ package api import ( "context" "encoding/json" + "net/http" "net/http/httptest" "testing" "time" + "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/pb" ) @@ -217,3 +219,25 @@ func TestOCPPEndpoint(t *testing.T) { t.Errorf("tls endpoint = %q", got) } } + +// The four card routes are reachable at all. A capability the plugin implements +// and the action catalogue advertises is still unusable if nothing routes to it, +// and that is not a failure any other test here would notice: the plugin's own +// tests pass, and the panel simply has no button. Each route is asked for +// without a token, so what is being checked is that the request reached the +// authentication middleware rather than a 404. +func TestAnkerCardRoutesAreRegistered(t *testing.T) { + h := New(config.Config{}, nil).Handler() + for _, tc := range []struct{ method, path string }{ + {"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards"}, + {"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/scan"}, + {"GET", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/charger"}, + {"DELETE", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/AABBCCDD"}, + } { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) + if rr.Code == http.StatusNotFound { + t.Errorf("%s %s is not routed", tc.method, tc.path) + } + } +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 8b66026..c5e511b 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -55,6 +55,7 @@ // POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards // POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan // DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number} +// GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger // GET /api/integrations/greencell PUT /api/integrations/greencell // POST /api/integrations/greencell/health // GET /api/integrations/greencell/chargers @@ -447,6 +448,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave) mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan", s.handleAnkerCardScan) mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete) + mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger", s.handleAnkerChargerCards) 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/Web App/web/src/api.js b/Web App/web/src/api.js index 2a2809c..ca87d70 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -357,6 +357,12 @@ export const api = { `/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/${encodeURIComponent(cardNumber)}`, { method: "DELETE" } ), + // The list the charger itself holds, asked of the device rather than of the + // account. Both are written by every add and remove, and they can still come + // apart; this is the only call that says so. Answers with {cards} — bare + // numbers, because the device has no field for a card's name. + getAnkerChargerCards: (sn) => + request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/charger`), // Anker Solix control (per charger), over whichever transport the user's // control mode selects. getAnkerControl returns the control mode, connection diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index c1599b8..b2236fc 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -80,6 +80,13 @@ "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.", + "readCharger": "Læs laderens egen liste", + "chargerTitle": "På selve laderen", + "chargerNone": "Laderen har ingen kort.", + "chargerHint": "Spurgt laderen, ikke kontoen. Den svarer kun med numre — et korts navn hører til på kontoen.", + "driftTitle": "De to lister er ikke enige", + "onlyOnCharger": "Åbner laderen, men findes ikke på kontoen: {cards}", + "onlyOnAccount": "På kontoen, men ikke på laderen, så det åbner den ikke: {cards}", "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": { diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 11308e5..d0cfbd4 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -349,6 +349,13 @@ "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.", + "readCharger": "Read the charger's own list", + "chargerTitle": "On the charger itself", + "chargerNone": "The charger holds no cards.", + "chargerHint": "Asked of the charger, not of the account. It answers with numbers only — a card's name lives on the account.", + "driftTitle": "The two lists disagree", + "onlyOnCharger": "Opens the charger but is not on the account: {cards}", + "onlyOnAccount": "On the account but not on the charger, so it will not open it: {cards}", "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": { diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index c07e9de..2fe40b9 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -80,6 +80,13 @@ "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.", + "readCharger": "Odczytaj własną listę ładowarki", + "chargerTitle": "Na samej ładowarce", + "chargerNone": "Ładowarka nie ma żadnych kart.", + "chargerHint": "Zapytana została ładowarka, nie konto. Odpowiada samymi numerami — nazwa karty jest po stronie konta.", + "driftTitle": "Obie listy się nie zgadzają", + "onlyOnCharger": "Otwiera ładowarkę, ale nie ma jej na koncie: {cards}", + "onlyOnAccount": "Jest na koncie, ale nie na ładowarce, więc jej nie otworzy: {cards}", "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": { diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index f1c7b7d..c5b6c1b 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -1163,6 +1163,13 @@ async function loadChargerDetails(force = false) { const { [sn]: _dropped, ...rest } = rfidWritten.value; rfidWritten.value = rest; } + // The charger's own list was read against the account list that has just + // been replaced. Comparing it against the new one would be comparing two + // answers from different moments, so it is dropped and asked for again. + if (chargerCards.value[sn]) { + const { [sn]: _stale, ...kept } = chargerCards.value; + chargerCards.value = kept; + } } 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. @@ -1302,6 +1309,58 @@ async function scanRfidCard() { } } +// The charger's own list, asked of the device rather than of the account. +// +// Every add and remove writes both halves, and they can still come apart: the +// account write is inferred and the charger's is not, so either can be the one +// that landed. A card the account has forgotten still opens the charger until +// the device is told otherwise, and no other view on this page would say so. +// The device answers with numbers and no names — it has no field for one. +const chargerCards = ref({}); // serial → the numbers the device answered with + +// Two numbers are the same card when they are the same hex; people and services +// write them with spaces, dashes or colons, and the charger writes them with +// none. The server normalizes what it stores, so this only has to agree with it. +function cardKey(number) { + return String(number || "").replace(/[^0-9A-Za-z]/g, "").toUpperCase(); +} + +const chargerCardList = computed(() => chargerCards.value[detailSn.value] || null); + +// What the two lists disagree about, once the device has answered. Named from +// the list each card is missing from, because that is what has to be fixed: +// a card only on the charger opens it without the account knowing, and a card +// only on the account is one the charger will not open for. +const cardsOnlyOnCharger = computed(() => { + const held = chargerCardList.value; + if (!held) return []; + const account = new Set(rfidCards.value.map((c) => cardKey(c.number))); + return held.filter((n) => !account.has(n)); +}); + +const cardsOnlyOnAccount = computed(() => { + const held = chargerCardList.value; + if (!held) return []; + return rfidCards.value.filter((c) => !held.includes(cardKey(c.number))).map((c) => c.number); +}); + +async function readChargerCards() { + const sn = detailSn.value; + if (!sn || rfidBusy.value) return; + rfidBusy.value = "charger"; + rfidError.value = ""; + try { + const res = await api.getAnkerChargerCards(sn); + // An empty answer is an answer — a charger with no cards on it — so the + // list is stored either way, and the card draws it rather than the button. + chargerCards.value = { ...chargerCards.value, [sn]: (res?.cards || []).map(cardKey) }; + } 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) { @@ -1881,6 +1940,40 @@ onMounted(async () => { + + +
+

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

+

+ {{ t("charging.rfid.chargerNone") }} +

+

{{ chargerCardList.join(", ") }}

+ +
+

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

+

+ {{ t("charging.rfid.onlyOnCharger", { cards: cardsOnlyOnCharger.join(", ") }) }} +

+

+ {{ t("charging.rfid.onlyOnAccount", { cards: cardsOnlyOnAccount.join(", ") }) }} +

+
+

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

+
+