Anker: every charger on the account, not just the ones outside a station

get_user_bind_and_not_in_station_evchargers is the only list the connector ever
asked for, and its name says exactly what it withholds. A charger that belongs to
a system is not in it. Its userBindEvChargersCount, though, counts every charger
bound to the account — so an owner with two chargers in a system got "authenticated;
2 EV charger(s) bound to account" from the health probe and an empty list from the
capability that is supposed to show them. A working login that finds nothing.

So the capability now asks every view the cloud has and merges them by serial. The
standalone list still answers for chargers standing on their own; get_site_list
walks the systems and reads each one through get_scen_info, falling back to
get_system_running_info where that is silent — the power-service / HES split
charger-state already knows; and get_relate_and_bind_devices contributes model,
firmware and the Wi-Fi flag, and discovers anything in the A519 family that the
first two missed. Whichever way a charger was registered, one of the three has it.

The merge is first-writer-wins per field rather than last view overwriting: the
standalone record knows the name, the site record knows the live state, and neither
should blank what the other established. A view that fails is a warning on the
document instead of an error on the call, because one dead endpoint should not
cost the chargers the other two found. Only losing all three is a failure. When
nothing comes back at all the response says so in its own words and names the
remaining suspect — country picks the regional server, and the wrong one
authenticates happily and shows an empty account.

The other half of "not showing any chargers" was that neither client ever showed a
list. The serial was a text box, and the number is printed on a charger hanging on
a wall. Both apps now list what the account holds — name, serial, model, site,
state, an offline badge — and hand the serial to the OCPP control card instead of
asking anyone to go and read it. Where control is off the list still stands on its
own, as the answer to the first question an owner has after entering credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-08-31 20:13:36 +02:00
1 parent a7cab50e06
commit e138fad3f4
17 files changed
+1062 -17

No files matched your search

+33 -1
View File
@@ -74,6 +74,28 @@ async function loadCtlMode() {
}
}
// The chargers on the linked Anker account. With them the serial is a pick from
// a list; without them (account not linked, or the cloud unreachable) the field
// stays a plain text box so a serial can still be typed in by hand.
const chargers = ref([]);
async function loadChargers() {
try {
const res = await api.listAnkerChargers();
chargers.value = res?.chargers || [];
} catch {
chargers.value = [];
}
// Nothing chosen yet: start on the first charger the account reports.
if (!ctlSerial.value.trim() && chargers.value.length) {
ctlSerial.value = chargers.value[0].sn;
}
}
function chargerLabel(c) {
return c.name ? `${c.name} · ${c.sn}` : c.sn;
}
async function refreshCtl() {
const sn = ctlSerial.value.trim();
if (!sn) {
@@ -131,6 +153,7 @@ function cancelReset() {
onMounted(async () => {
await loadCtlMode();
if (ctlActive.value) await loadChargers();
await refreshCtl();
});
</script>
@@ -290,7 +313,16 @@ onMounted(async () => {
</div>
<div class="mt-3 flex gap-2">
<input v-model="ctlSerial" class="dh-input" :placeholder="t('charging.control.serialPlaceholder')" autocomplete="off" />
<select v-if="chargers.length" v-model="ctlSerial" class="dh-input" @change="refreshCtl">
<option v-for="c in chargers" :key="c.sn" :value="c.sn">{{ chargerLabel(c) }}</option>
</select>
<input
v-else
v-model="ctlSerial"
class="dh-input"
:placeholder="t('charging.control.serialPlaceholder')"
autocomplete="off"
/>
<button class="dh-btn dh-btn-ghost shrink-0" @click="refreshCtl">{{ t("charging.control.refresh") }}</button>
</div>
+98
View File
@@ -529,6 +529,57 @@ watch(ankerCtlSerial, () => {
ankerNewToken.value = "";
});
// --- The chargers on the linked Anker account ---
// Proof that the login found something, and the source of the serial the control
// block needs — the serial is printed on the charger, but nobody wants to go and
// read it off the wall when the account already knows it.
const ankerChargers = ref([]);
const ankerChargersLoading = ref(false);
const ankerChargersError = ref("");
const ankerChargersDetail = ref(""); // why the list is empty, when the server says
const ankerChargersLoaded = ref(false);
async function loadAnkerChargers() {
ankerChargersError.value = "";
ankerChargersLoading.value = true;
try {
const res = await api.listAnkerChargers();
ankerChargers.value = res?.chargers || [];
ankerChargersDetail.value = res?.detail || "";
ankerChargersLoaded.value = true;
} catch (e) {
ankerChargers.value = [];
ankerChargersError.value = e.message;
} finally {
ankerChargersLoading.value = false;
}
}
// Load once the card is open and the integration is on — opening the card is the
// moment the user is asking "what is on my account?", and every other visit to
// Settings should not spend an Anker round trip.
watch([ankerOpen, () => anker.value?.enabled], ([open, enabled]) => {
if (open && enabled && !ankerChargersLoaded.value && !ankerChargersLoading.value) {
loadAnkerChargers();
}
});
// A charger's operating state arrives as the cloud's own slug (charging,
// standby, …); translate it, and fall back to the readable slug for a state we
// have no wording for yet.
function ankerStateLabel(slug) {
if (!slug) return "";
const key = `settings.integrations.states.${slug}`;
const label = t(key);
return label === key ? slug.replace(/_/g, " ") : label;
}
// Point the control block at a charger picked from the list.
function useAnkerCharger(sn) {
ankerCtlSerial.value = sn;
loadAnkerControl();
}
function applyAnkerView(body) {
anker.value = body;
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
@@ -1428,6 +1479,53 @@ onBeforeUnmount(() => {
{{ ankerHealth.detail || ankerHealth.status }}
</p>
<!-- The chargers found on the account -->
<div v-if="anker.enabled" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.chargersTitle") }}</p>
<button class="dh-btn dh-btn-ghost" :disabled="ankerChargersLoading" @click="loadAnkerChargers">
{{ ankerChargersLoading ? t("settings.integrations.chargersLoading") : t("settings.integrations.chargersRefresh") }}
</button>
</div>
<p class="mt-0.5 text-xs text-muted">{{ t("settings.integrations.chargersHint") }}</p>
<ul v-if="ankerChargers.length" class="mt-3 grid gap-2">
<li
v-for="c in ankerChargers"
:key="c.sn"
class="flex flex-wrap items-center justify-between gap-2 rounded-control border border-subtle bg-card px-3 py-2"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-strong">
{{ c.name || t("settings.integrations.chargerUnnamed") }}
</p>
<p class="data truncate text-xs text-muted">
{{ c.sn }}<span v-if="c.model"> · {{ c.model }}</span><span v-if="c.siteName"> · {{ c.siteName }}</span>
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span v-if="c.online === false" class="dh-badge dh-badge-warning">
{{ t("settings.integrations.chargerOffline") }}
</span>
<span v-if="c.statusDesc" class="dh-badge" :class="c.statusDesc === 'charging' ? 'dh-badge-success' : 'dh-badge-neutral'">
{{ ankerStateLabel(c.statusDesc) }}
</span>
<button
v-if="ankerControlMode !== 'off'"
class="dh-btn dh-btn-ghost"
@click="useAnkerCharger(c.sn)"
>
{{ t("settings.integrations.chargerUse") }}
</button>
</div>
</li>
</ul>
<p v-else-if="ankerChargersLoaded" class="mt-3 text-sm text-muted">
{{ ankerChargersDetail || t("settings.integrations.chargersEmpty") }}
</p>
<p v-if="ankerChargersError" class="mt-2 text-sm text-danger">{{ ankerChargersError }}</p>
</div>
<!-- OCPP control provisioning (only when a control mode is active) -->
<div v-if="ankerControlMode !== 'off'" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.controlTitle") }}</p>