A dash printed above the value it was missing

The Charger information card read "—" beside State and OCPP status while the raw
block three rows below it printed chargerStatus 1 and ocpp_connect_status 2. The
account had answered both. The merge asked for the state as evChargerStatus,
operating_state or status, which is how the standalone and station views spell
it, and the bound-device view — the one this account actually answers from —
spells it chargerStatus. The OCPP state it never asked that view for at all. All
three views now read through one fillDevice, which tries every spelling a view is
known to use, so a value any of them sends reaches the row that was drawing a
dash for want of it.

The same views were carrying the whole box-on-the-wall half unread: the Wi-Fi
network and its MAC, the signal strength, the Bluetooth MAC, the time zone, when
the account bound the charger, how the app can reach it — BLE, Wi-Fi — and the
product shot for the model, which now sits beside the charger's name in both
apps. Named rows, in three languages, the way the register map's readings are
named.

One field wanted the opposite treatment. The device record carries blue_password,
the charger's own Bluetooth pairing password, and the card was printing it in
clear into every screenshot anyone takes of that page. Any leaf key holding a
password, secret, token, private key or certificate is now masked in the raw
block: that the field exists is worth reporting, its value is not.

Four endpoints answer only when a serial is named, so none of them could belong
to the list the card is drawn from, and nothing had ever called them. The station
record, the charging totals, the OCPP backend and the RFID cards now arrive
through a charger-details capability behind
GET …/anker-solix/chargers/{sn}/details, asked for the charger being looked at,
best effort, each view reporting its own failure — an account that is not the
owner cannot read the cards, which is a fact about the account rather than an
error in the read.

Those four are shown under the cloud's own keys, and that is not an oversight.
The REST map documents which endpoints exist and what each is for; it does not
document a single one of their payloads. Naming those fields is the next commit,
made from what actually comes back, now that there is somewhere to see it.

Not touched: the endpoints the map marks ready but unwired — session history,
site price, OTA, sharing, notifications — each a feature rather than a row on this
card; and the unmapped ones, which the map warns delete sessions and unbind
devices with payloads nobody has ever seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 21:45:08 +02:00
co-authored by Claude Opus 5
parent 8e2073fc4c
commit 1a7f04cba0
18 changed files with 670 additions and 36 deletions
+5
View File
@@ -328,6 +328,11 @@ export const api = {
// with an empty list and a reason when a gate is off, so the caller can show
// the reason rather than an error.
listAnkerChargers: () => request("/integrations/anker-solix/chargers"),
// Every per-charger view the account holds — the station record, the charging
// totals, the OCPP backend and the RFID cards — asked for one charger at a
// time, because none of those endpoints lists chargers.
getAnkerChargerDetails: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/details`),
// Anker Solix control (per charger), over whichever transport the user's
// control mode selects. getAnkerControl returns the control mode, connection
+13
View File
@@ -263,6 +263,19 @@
"ocpp": "OCPP-status",
"added": "Tilføjet",
"online": "Online",
"wifiName": "Wi-Fi-netværk",
"wifiMac": "Wi-Fi-MAC",
"signal": "Signal",
"bleMac": "Bluetooth-MAC",
"relatedBy": "Kan nås via",
"timeZone": "Tidszone",
"linked": "Tilknyttet",
"views": {
"station": "Stationspost",
"totals": "Ladetotaler",
"ocpp": "OCPP-backend",
"rfid": "RFID-kort"
},
"offline": "Offline",
"refresh": "Opdater",
"rawTitle": "Som tjenesten melder det",
+13
View File
@@ -249,6 +249,19 @@
"ocpp": "OCPP status",
"added": "Added",
"online": "Online",
"wifiName": "Wi-Fi network",
"wifiMac": "Wi-Fi MAC",
"signal": "Signal",
"bleMac": "Bluetooth MAC",
"relatedBy": "Reachable by",
"timeZone": "Time zone",
"linked": "Linked",
"views": {
"station": "Station record",
"totals": "Charging totals",
"ocpp": "OCPP backend",
"rfid": "RFID cards"
},
"offline": "Offline",
"refresh": "Refresh",
"rawTitle": "As the service reports it",
+13
View File
@@ -265,6 +265,19 @@
"ocpp": "Status OCPP",
"added": "Dodano",
"online": "Online",
"wifiName": "Sieć Wi-Fi",
"wifiMac": "MAC Wi-Fi",
"signal": "Sygnał",
"bleMac": "MAC Bluetooth",
"relatedBy": "Dostępna przez",
"timeZone": "Strefa czasowa",
"linked": "Powiązano",
"views": {
"station": "Rekord stacji",
"totals": "Sumy ładowania",
"ocpp": "Backend OCPP",
"rfid": "Karty RFID"
},
"offline": "Offline",
"refresh": "Odśwież",
"rawTitle": "Tak, jak podaje to usługa",
+84 -1
View File
@@ -755,6 +755,9 @@ async function loadChargerLive(force = false) {
chargerLive.value = live;
chargerLiveLoaded.value = true;
chargerLiveLoading.value = false;
// The card's other half: the views that answer per charger rather than per
// account. Refreshing the card refreshes both.
loadChargerDetails(force);
}
// The live half for one imported charger, or null when the service it came from
@@ -830,6 +833,7 @@ const selectedHomeCharger = computed(
// A charger picked here becomes the one the control card drives.
function selectHomeCharger(c) {
selected.value = c.id;
loadChargerDetails();
if (!c.serial) return;
ctlSerial.value = c.serial;
refreshCtl();
@@ -886,6 +890,15 @@ function chargerInfoRows(c) {
// on it here would be inventing it.
["chargePower", live.power],
["ocpp", ocppStatusLabel(live)],
// The box on the wall, as opposed to the charging: the networks it is on,
// where it thinks it is, and when the account first saw it.
["wifiName", live.wifiName],
["wifiMac", live.wifiMac],
["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""],
["bleMac", live.bleMac],
["relatedBy", (live.relatedBy || []).join(" · ")],
["timeZone", live.timeZone],
["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""],
["providerId", c.providerChargerId],
["added", c.created ? formatDateTime(c.created) : ""],
];
@@ -896,6 +909,50 @@ function chargerInfoRows(c) {
}));
}
// --- The account's other views of one charger ---
//
// Four endpoints answer only when a serial is named — the station record, the
// charging totals, the OCPP backend, the RFID cards — so none of them can be
// part of the list the card is drawn from. They are asked for the charger being
// looked at, when it is being looked at, and the answers are kept per serial so
// switching back to a charger does not ask again.
const chargerDetails = ref({}); // serial → the views document
const chargerDetailsLoading = ref("");
async function loadChargerDetails(force = false) {
const c = selectedHomeCharger.value;
const sn = c?.providerChargerId || c?.serial || "";
if (!sn || c.provider !== "anker-solix") return;
if (chargerDetailsLoading.value === sn) return;
if (chargerDetails.value[sn] && !force) return;
chargerDetailsLoading.value = sn;
try {
const res = await api.getAnkerChargerDetails(sn);
chargerDetails.value = { ...chargerDetails.value, [sn]: res };
} 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.
} finally {
chargerDetailsLoading.value = "";
}
}
// One view's fields, sorted, or the reason it could not be read. The keys are
// the cloud's own: Anker documents none of these payloads, so a name invented
// here would be a meaning invented here.
const chargerDetailViews = computed(() => {
const c = selectedHomeCharger.value;
const sn = c?.providerChargerId || c?.serial || "";
const doc = chargerDetails.value[sn];
return (doc?.views || []).map((view) => ({
id: view.id,
error: view.error || "",
rows: Object.keys(view.attrs || {})
.sort()
.map((key) => ({ key, value: view.attrs[key] })),
})).filter((view) => view.rows.length || view.error);
});
// 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
@@ -1811,7 +1868,18 @@ onMounted(async () => {
<!-- The charger picked in the list beside this card, and only it. -->
<div v-if="selectedHomeCharger" class="mt-3 rounded-control bg-sunken p-3">
<div class="flex items-center justify-between gap-2">
<p class="truncate text-sm font-semibold text-strong">{{ selectedHomeCharger.name }}</p>
<div class="flex min-w-0 items-center gap-2">
<!-- The product shot the app shows for this model, when the
account sent one. Decorative: the name beside it says
everything the picture does. -->
<img
v-if="liveFor(selectedHomeCharger)?.imageUrl"
:src="liveFor(selectedHomeCharger).imageUrl"
alt=""
class="h-8 w-8 shrink-0 rounded object-contain"
/>
<p class="truncate text-sm font-semibold text-strong">{{ selectedHomeCharger.name }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<!-- Reachability, said either way. A charger the service says
nothing about stays silent: unknown is not offline. -->
@@ -1846,6 +1914,21 @@ onMounted(async () => {
</dl>
<p class="mt-1 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p>
</template>
<!-- The views that answer per charger rather than per account. Each
says what it knows, or why it could not be read an account
that is not the charger's owner cannot read the cards, which is
a fact about the account rather than a failure. -->
<template v-for="view in chargerDetailViews" :key="view.id">
<p class="eyebrow mt-3">{{ t(`charging.info.views.${view.id}`) }}</p>
<p v-if="view.error" class="mt-1 text-[11px] text-muted">{{ view.error }}</p>
<dl v-else class="mt-1 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>
</template>
</div>
<p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted">