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:
co-authored by
Claude Opus 5
parent
a7cab50e06
commit
e138fad3f4
@@ -300,6 +300,11 @@ export const api = {
|
||||
getAnkerSolix: () => request("/integrations/anker-solix"),
|
||||
saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }),
|
||||
testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }),
|
||||
// The chargers on the linked Anker account, fetched server-side under the
|
||||
// resolved credentials: {chargers, count, boundCount?, detail?}. Answers 200
|
||||
// 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"),
|
||||
|
||||
// Anker Solix OCPP control (per charger). getAnkerControl returns the control
|
||||
// mode, connection status, provisioning endpoint + token, and a live status
|
||||
|
||||
@@ -297,6 +297,26 @@
|
||||
"controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.",
|
||||
"controlConnected": "Forbundet til styrings-backend",
|
||||
"controlDisconnected": "Ikke forbundet",
|
||||
"chargersTitle": "Dine ladere",
|
||||
"chargersHint": "Alle EV-ladere på den tilknyttede Anker-konto — både dem, der står for sig selv, og dem i et system.",
|
||||
"chargersRefresh": "Opdater",
|
||||
"chargersLoading": "Indlæser…",
|
||||
"chargersEmpty": "Der blev ikke fundet nogen EV-lader på denne konto.",
|
||||
"chargerUnnamed": "Lader uden navn",
|
||||
"chargerOffline": "Offline",
|
||||
"chargerUse": "Brug denne",
|
||||
"states": {
|
||||
"standby": "Standby",
|
||||
"preparing": "Forbereder",
|
||||
"charging": "Lader",
|
||||
"charger_paused": "Sat på pause af laderen",
|
||||
"vehicle_paused": "Sat på pause af bilen",
|
||||
"completed": "Fuldført",
|
||||
"reserving": "Reserveret",
|
||||
"disabled": "Deaktiveret",
|
||||
"error": "Fejl",
|
||||
"unknown": "Ukendt"
|
||||
},
|
||||
"greencell": "Greencell (HabuDen EV-lader)",
|
||||
"greencellDesc": "Læs din Greencell-lader via den MQTT-broker, den publicerer til. Kun lokalt — ingen Greencell-skykonto er involveret.",
|
||||
"greencellBroker": "MQTT-broker",
|
||||
|
||||
@@ -296,6 +296,26 @@
|
||||
"controlRevokeConfirm": "Revoke this charger's control token? It will disconnect and can't reconnect until you generate a new one.",
|
||||
"controlConnected": "Connected to control backend",
|
||||
"controlDisconnected": "Not connected",
|
||||
"chargersTitle": "Your chargers",
|
||||
"chargersHint": "Every EV charger on the linked Anker account — the ones standing on their own and the ones inside a system.",
|
||||
"chargersRefresh": "Refresh",
|
||||
"chargersLoading": "Loading…",
|
||||
"chargersEmpty": "No EV charger was found on this account.",
|
||||
"chargerUnnamed": "Unnamed charger",
|
||||
"chargerOffline": "Offline",
|
||||
"chargerUse": "Use this one",
|
||||
"states": {
|
||||
"standby": "Standby",
|
||||
"preparing": "Preparing",
|
||||
"charging": "Charging",
|
||||
"charger_paused": "Paused by charger",
|
||||
"vehicle_paused": "Paused by car",
|
||||
"completed": "Completed",
|
||||
"reserving": "Reserved",
|
||||
"disabled": "Disabled",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"greencell": "Greencell (HabuDen EV charger)",
|
||||
"greencellDesc": "Read your Greencell wallbox over the MQTT broker it publishes to. Local only — no Greencell cloud account is involved.",
|
||||
"greencellBroker": "MQTT broker",
|
||||
|
||||
@@ -301,6 +301,26 @@
|
||||
"controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.",
|
||||
"controlConnected": "Połączono z backendem sterowania",
|
||||
"controlDisconnected": "Nie połączono",
|
||||
"chargersTitle": "Twoje ładowarki",
|
||||
"chargersHint": "Wszystkie ładowarki EV na połączonym koncie Anker — te wolnostojące i te należące do systemu.",
|
||||
"chargersRefresh": "Odśwież",
|
||||
"chargersLoading": "Wczytywanie…",
|
||||
"chargersEmpty": "Na tym koncie nie znaleziono żadnej ładowarki EV.",
|
||||
"chargerUnnamed": "Ładowarka bez nazwy",
|
||||
"chargerOffline": "Offline",
|
||||
"chargerUse": "Użyj tej",
|
||||
"states": {
|
||||
"standby": "Czuwanie",
|
||||
"preparing": "Przygotowanie",
|
||||
"charging": "Ładowanie",
|
||||
"charger_paused": "Wstrzymane przez ładowarkę",
|
||||
"vehicle_paused": "Wstrzymane przez auto",
|
||||
"completed": "Zakończone",
|
||||
"reserving": "Zarezerwowana",
|
||||
"disabled": "Wyłączona",
|
||||
"error": "Błąd",
|
||||
"unknown": "Nieznany"
|
||||
},
|
||||
"greencell": "Greencell (ładowarka EV HabuDen)",
|
||||
"greencellDesc": "Odczytuj swoją ładowarkę Greencell przez brokera MQTT, do którego publikuje. Tylko lokalnie — konto w chmurze Greencell nie jest potrzebne.",
|
||||
"greencellBroker": "Broker MQTT",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user