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:
1 parent
a5842201c0
commit
245870a96a
10 files changed
+588
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user