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:
tajniak81andClaude Opus 5 committed 2026-09-03 10:50:00 +02:00
1 parent a5842201c0
commit 245870a96a
10 files changed
+588 -16

No files matched your search

+16
View File
@@ -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
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+151 -10
View File
@@ -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>