Each RFID card as a card, not as list[3]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-03 09:57:51 +02:00
co-authored by Claude Opus 5
parent 905eb24cc1
commit 928a39e03e
4 changed files with 184 additions and 10 deletions
+22
View File
@@ -283,6 +283,28 @@
"bleId": "Bluetooth-id",
"blePassword": "Bluetooth-parringskode",
"ownerId": "Ejer-id",
"fields": {
"sessions": "Sessioner",
"chargeTime": "Opladningstid",
"energy": "Opladet energi",
"co2Saved": "CO2 sparet",
"cost": "Omkostning",
"costSaved": "Sparet beløb",
"currency": "Valuta",
"mileage": "Kilometertal",
"page": "Side",
"perPage": "Pr. side",
"records": "Poster",
"from": "Fra",
"source": "Kilde",
"timeZone": "Tidszone",
"updated": "Opdateret",
"added": "Tilføjet",
"address": "Adresse",
"name": "Navn",
"cardName": "Kortnavn",
"cardNumber": "Kortnummer"
},
"groups": {
"device": "Enhed",
"status": "Status",
+22
View File
@@ -269,6 +269,28 @@
"bleId": "Bluetooth id",
"blePassword": "Bluetooth pairing code",
"ownerId": "Owner id",
"fields": {
"sessions": "Sessions",
"chargeTime": "Time charging",
"energy": "Energy charged",
"co2Saved": "CO2 saved",
"cost": "Cost",
"costSaved": "Cost saved",
"currency": "Currency",
"mileage": "Mileage",
"page": "Page",
"perPage": "Per page",
"records": "Records",
"from": "From",
"source": "Source",
"timeZone": "Time zone",
"updated": "Updated",
"added": "Added",
"address": "Address",
"name": "Name",
"cardName": "Card name",
"cardNumber": "Card number"
},
"groups": {
"device": "Device",
"status": "Status",
+22
View File
@@ -285,6 +285,28 @@
"bleId": "Identyfikator Bluetooth",
"blePassword": "Kod parowania Bluetooth",
"ownerId": "Identyfikator właściciela",
"fields": {
"sessions": "Sesje",
"chargeTime": "Czas ładowania",
"energy": "Naładowana energia",
"co2Saved": "Oszczędność CO2",
"cost": "Koszt",
"costSaved": "Oszczędność kosztów",
"currency": "Waluta",
"mileage": "Przebieg",
"page": "Strona",
"perPage": "Na stronę",
"records": "Rekordy",
"from": "Od",
"source": "Źródło",
"timeZone": "Strefa czasowa",
"updated": "Zaktualizowano",
"added": "Dodano",
"address": "Adres",
"name": "Nazwa",
"cardName": "Nazwa karty",
"cardNumber": "Numer karty"
},
"groups": {
"device": "Urządzenie",
"status": "Status",
+118 -10
View File
@@ -1018,6 +1018,99 @@ function chargerInfoGroups(c) {
}));
}
// The keys the views below answer with, in the card's words. Anker documents
// none of these payloads either, but a field like page_size or create_time says
// what it is once its key is read out, and those are named here rather than left
// as keys. Keyed by the field with any list index taken out, so list[0].name and
// list[3].name are one field asked about two records. Anything absent from this
// table keeps its own key, for the same reason the box above does: a name
// invented here would be a meaning invented here.
const VIEW_FIELDS = {
// What the account has counted for this charger.
"total_stats.charge_count": "sessions",
"total_stats.charge_time": "chargeTime",
"total_stats.charge_total": "energy",
"total_stats.co2_saving": "co2Saved",
"total_stats.cost": "cost",
"total_stats.cost_saving": "costSaved",
"total_stats.cost_unit": "currency",
"total_stats.mile_age": "mileage",
// How much of a list the view answered with — a page of a history that is
// empty is still the answer "there is nothing to page through".
page: "page",
page_num: "page",
page_size: "perPage",
total: "records",
total_count: "records",
start_use_time: { label: "from", time: true },
// The backend the charger is pointed at, and when it last said so.
source: "source",
time_zone: "timeZone",
timestamp: { label: "updated", time: true },
create_time: { label: "added", time: true },
// The records a list view answers with: one OCPP endpoint, one RFID card.
"list[].address": "address",
"list[].name": "name",
"list[].source": "source",
"list[].alias_name": "cardName",
"list[].card_number": "cardNumber",
"list[].create_time": { label: "added", time: true },
};
// list[0].card_number split into the list, which record, and which field.
const VIEW_LIST_KEY = /^([A-Za-z0-9_.]+)\[(\d+)\]\.(.+)$/;
// A unix second the cloud sent as a bare number, read as a date. Zero is how
// these views say "never" rather than 1970, so it is left as it arrived.
function viewTimeValue(v) {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return v;
return formatDateTime(new Date(n * 1000));
}
// One field of one view: named when the table above has a name for it, and
// keyed by the cloud's own key when it does not. The key is kept either way —
// it is what the row is drawn under and what makes it unique.
function viewRow(key, lookup, value) {
const spec = VIEW_FIELDS[lookup];
const label = typeof spec === "string" ? spec : spec?.label;
return {
key,
label: label ? t(`charging.info.fields.${label}`) : key,
named: !!label,
value: typeof spec === "object" && spec?.time ? viewTimeValue(value) : value,
};
}
// One view's fields, with the lists it answered with grouped a record at a time.
// list[0].* and list[1].* are two RFID cards, two endpoints, two anything: read
// as one alphabetical run of indexed keys they are unreadable, and as a block
// each — under the record's own name, when it has one — they are the list the
// view actually sent. The record's name becomes the heading rather than a row,
// so it is said once.
function viewRowGroups(attrs) {
const rows = [];
const items = new Map();
for (const key of Object.keys(attrs).sort()) {
const m = VIEW_LIST_KEY.exec(key);
if (!m) {
rows.push(viewRow(key, key, attrs[key]));
continue;
}
const [, list, index, field] = m;
const id = `${list}[${index}]`;
if (!items.has(id)) {
const label = attrs[`${id}.alias_name`] || attrs[`${id}.name`] || `#${Number(index) + 1}`;
const titleKey = attrs[`${id}.alias_name`] ? `${id}.alias_name` : attrs[`${id}.name`] ? `${id}.name` : "";
items.set(id, { key: id, index: Number(index), label, titleKey, rows: [] });
}
const item = items.get(id);
if (key === item.titleKey) continue;
item.rows.push(viewRow(key, `${list}[].${field}`, attrs[key]));
}
return { rows, items: [...items.values()].sort((a, b) => a.index - b.index) };
}
// --- The account's other views of one charger ---
//
// Four endpoints answer only when a serial is named — the station record, the
@@ -1063,9 +1156,7 @@ const chargerDetailViews = computed(() => {
id: view.id,
error: view.error || "",
note: view.note || "",
rows: Object.keys(view.attrs || {})
.sort()
.map((key) => ({ key, value: view.attrs[key] })),
...viewRowGroups(view.attrs || {}),
}));
});
@@ -2062,15 +2153,32 @@ onMounted(async () => {
<section v-for="view in chargerDetailViews" :key="view.id" class="rounded-control bg-sunken p-3">
<h4 class="eyebrow">{{ t(`charging.info.views.${view.id}`) }}</h4>
<p v-if="view.error" class="mt-2 text-[11px] text-muted">{{ view.error }}</p>
<p v-else-if="!view.rows.length" class="mt-2 text-[11px] text-muted">
<p v-else-if="!view.rows.length && !view.items.length" class="mt-2 text-[11px] text-muted">
{{ t("charging.info.viewEmpty") }}
</p>
<dl v-else class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in view.rows" :key="row.key">
<dt class="data break-all text-[11px] text-muted">{{ row.key }}</dt>
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
</template>
</dl>
<div v-else class="mt-2 flex flex-col gap-2">
<!-- What the view says about itself. A field the card has a name
for is drawn like every other named row; one it does not is
drawn under its own key, in the key's own typeface, so the
two are never mistaken for each other. -->
<dl v-if="view.rows.length" class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in view.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>
</template>
</dl>
<!-- And the records it answered with, a block each under the
record's own name. -->
<div v-for="item in view.items" :key="item.key" class="rounded-control bg-card p-2">
<p class="text-[11px] font-semibold text-strong">{{ item.label }}</p>
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<template v-for="row in item.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>
</template>
</dl>
</div>
</div>
<!-- The one line in DriverVault's own words: a number this view
reports that another view gives an address. -->
<p v-if="view.note" class="data mt-2 text-[11px] text-muted">{{ view.note }}</p>