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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7176867eb3
commit
3064bff8ad
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What the charger itself holds. Everything above is the account's
|
||||
copy; this asks the device, which is the half that actually
|
||||
decides whether a card opens the charger. -->
|
||||
<button
|
||||
type="button"
|
||||
class="dh-btn dh-btn-ghost mt-3 w-full text-xs"
|
||||
:disabled="rfidBusy !== ''"
|
||||
@click="readChargerCards"
|
||||
>
|
||||
{{ rfidBusy === "charger" ? t("common.loading") : t("charging.rfid.readCharger") }}
|
||||
</button>
|
||||
<div v-if="chargerCardList" class="mt-2 rounded-control bg-sunken p-3">
|
||||
<p class="eyebrow">{{ t("charging.rfid.chargerTitle") }}</p>
|
||||
<p v-if="!chargerCardList.length" class="mt-1 text-[11px] text-muted">
|
||||
{{ t("charging.rfid.chargerNone") }}
|
||||
</p>
|
||||
<p v-else class="data mt-1 break-all text-[11px] text-body">{{ chargerCardList.join(", ") }}</p>
|
||||
<!-- Only drawn when the two lists actually disagree: agreement is
|
||||
the ordinary case and does not need saying twice. -->
|
||||
<section
|
||||
v-if="cardsOnlyOnCharger.length || cardsOnlyOnAccount.length"
|
||||
class="mt-2 rounded-control border border-warning/40 bg-warning-soft p-3"
|
||||
>
|
||||
<h4 class="eyebrow" style="color: var(--warning-600)">{{ t("charging.rfid.driftTitle") }}</h4>
|
||||
<p v-if="cardsOnlyOnCharger.length" class="mt-1 text-[11px] text-body">
|
||||
{{ t("charging.rfid.onlyOnCharger", { cards: cardsOnlyOnCharger.join(", ") }) }}
|
||||
</p>
|
||||
<p v-if="cardsOnlyOnAccount.length" class="mt-1 text-[11px] text-body">
|
||||
{{ t("charging.rfid.onlyOnAccount", { cards: cardsOnlyOnAccount.join(", ") }) }}
|
||||
</p>
|
||||
</section>
|
||||
<p class="mt-2 text-[11px] text-muted">{{ t("charging.rfid.chargerHint") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 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. -->
|
||||
|
||||
Reference in New Issue
Block a user