Home chargers: your own wallbox as a record, imported the way a car is

The Home chargers tab has been showing a hardcoded "Home charger · 11 kW · NACS"
since it was drawn, and the control card asked for a serial as free text — a
number printed on a box hanging in a garage, typed in by hand while the connected
account already knew it. The garage solved the same problem for cars a while ago,
so this is that solution aimed at the wall: pick the charger off a service you
have connected, press Import, and it becomes a record of yours.

A charger is a record rather than a live listing because it has to outlive the
account it came from. Disconnect Anker and the wallbox is still on the wall; the
integration is how the charger was found, not what it is. Hence home_chargers,
owned by a person and not related to any car — it charges whichever car is
plugged into it, and it outlives all of them — and hence no sharing: a charger is
one household's business in a way a car shared with a partner is not.

The provider layer is vehicleproviders.go's shape on purpose, down to the soft
gate: a listing answers 200 with an empty list and the sentence that says what to
do about a closed gate, a write answers 400, because there the caller asked for
something that did not happen. Anker and Greencell are two adapters over plugins
that already exist, so the next charger service is an adapter appended to
chargerSources() and nothing else. What is deliberately absent is the car
import's checkbox panel: a charger is a name, a serial and the hardware behind
it, all of which the list already carries, so there is nothing to choose and the
whole screen is pick one, press Import.

Only the name is editable afterwards. The rest describes hardware and came from
the service, and the provider link is written by the import endpoint alone, so
renaming a charger cannot quietly orphan it from the account it tracks. Deleting
one says as much in its confirmation: the charger is untouched, and importing it
again brings the record straight back.

The Anker gate moved into ankerGate() beside greencellGate(), because the same
four-case switch was about to exist in a third place. Behaviour is unchanged —
the same sentences, and the probe still skips the personal opt-in, since checking
credentials is what you do before switching the integration on.

The phone app still has the old tab; parity there is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-08-31 21:07:21 +02:00
co-authored by Claude Opus 5
parent a809980d8b
commit a3f69fa5ef
15 changed files with 1150 additions and 59 deletions
+23
View File
@@ -293,6 +293,29 @@ export const api = {
syncCarProvider: (carId, body = {}) =>
request(`/cars/${carId}/provider/sync`, { method: "POST", body: JSON.stringify(body) }),
// Charger providers — the garage's import, aimed at the wall: a charger on a
// connected service (Anker Solix, Greencell) becomes one of the caller's own
// home chargers. listChargerProviders reports each with a `connected` flag and,
// when it isn't, a `detail` sentence saying what to do about it.
listChargerProviders: () => request("/charger-providers").then((r) => r.providers),
listProviderChargers: (provider) =>
request(`/charger-providers/${encodeURIComponent(provider)}/chargers`),
importProviderCharger: (provider, body) =>
request(`/charger-providers/${encodeURIComponent(provider)}/import`, {
method: "POST",
body: JSON.stringify(body),
}),
// The caller's own chargers. Only the name is editable — everything else
// describes the hardware and comes from the service it was imported from.
listHomeChargers: () => request("/home-chargers").then((r) => r.chargers),
renameHomeCharger: (id, name) =>
request(`/home-chargers/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify({ name }),
}),
deleteHomeCharger: (id) => request(`/home-chargers/${encodeURIComponent(id)}`, { method: "DELETE" }),
// Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix
// returns the resolved view (effective/own/locked per field, secrets and
// inherited emails masked); saveAnkerSolix writes the caller's editable layer;
@@ -0,0 +1,184 @@
<script setup>
// Create a home charger from one on a connected charger service (Anker Solix or
// Greencell today; the endpoints are generic, so the next service needs no
// changes here).
//
// Shorter than the car import on purpose: a charger is a name, a serial and the
// hardware behind it, and the service's list already carries all three. There is
// nothing to choose about what to pull, so the whole screen is pick one, press
// Import.
import { ref, computed, onMounted, watch } from "vue";
import { api } from "../api";
import { t } from "../i18n";
import Modal from "./Modal.vue";
const emit = defineEmits(["saved", "close"]);
const providers = ref([]);
const provider = ref("");
const chargers = ref([]);
const selectedId = ref("");
const name = ref("");
const loading = ref(true);
const loadingChargers = ref(false);
const importing = ref(false);
const error = ref("");
// Why the provider can't be used right now (not connected, org switch off, …).
// The server phrases this; the UI just shows it.
const detail = ref("");
const connected = computed(() => providers.value.filter((p) => p.connected));
const current = computed(() => providers.value.find((p) => p.id === provider.value) || null);
const selected = computed(() => chargers.value.find((c) => c.id === selectedId.value) || null);
const canImport = computed(() => !!selected.value && !selected.value.linkedChargerId && !importing.value);
async function loadProviders() {
loading.value = true;
error.value = "";
try {
providers.value = await api.listChargerProviders();
provider.value = connected.value[0]?.id || providers.value[0]?.id || "";
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function loadChargers() {
chargers.value = [];
selectedId.value = "";
detail.value = "";
if (!provider.value) return;
if (!current.value?.connected) {
detail.value = current.value?.detail || "";
return;
}
loadingChargers.value = true;
error.value = "";
try {
const res = await api.listProviderChargers(provider.value);
chargers.value = res.chargers || [];
detail.value = res.unavailable ? res.detail || "" : "";
// Preselect the first charger that isn't here already — with one charger on
// the account that is the whole selection step.
selectedId.value = chargers.value.find((c) => !c.linkedChargerId)?.id || "";
} catch (e) {
error.value = e.message;
} finally {
loadingChargers.value = false;
}
}
// The name field tracks the selected charger until the user types their own.
const nameEdited = ref(false);
watch(selected, (c) => {
if (!nameEdited.value) name.value = c?.name || "";
});
watch(provider, loadChargers);
function subtitle(c) {
return [c.vendor, c.model, c.siteName].filter(Boolean).join(" · ");
}
async function submit() {
if (!canImport.value) return;
importing.value = true;
error.value = "";
try {
const res = await api.importProviderCharger(provider.value, {
chargerId: selected.value.id,
name: name.value.trim(),
});
emit("saved", res.charger);
} catch (e) {
error.value = e.message;
} finally {
importing.value = false;
}
}
onMounted(async () => {
await loadProviders();
await loadChargers();
});
</script>
<template>
<Modal :title="t('forms.importCharger.title')" @close="emit('close')">
<p v-if="error" class="mb-3 rounded-control bg-danger-soft px-3 py-2 text-sm font-medium text-danger">{{ error }}</p>
<p v-if="loading" class="text-sm text-muted">{{ t("common.loading") }}</p>
<template v-else-if="providers.length === 0">
<p class="text-sm text-muted">{{ t("forms.importCharger.noProviders") }}</p>
<div class="mt-4 flex justify-end">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
</div>
</template>
<form v-else class="space-y-4" @submit.prevent="submit">
<p class="text-sm text-muted">{{ t("forms.importCharger.subtitle") }}</p>
<!-- Service picker. Hidden while there is only one to pick. -->
<div v-if="providers.length > 1">
<label class="dh-label">{{ t("forms.import.service") }}</label>
<select v-model="provider" class="dh-input">
<option v-for="p in providers" :key="p.id" :value="p.id" :disabled="!p.connected">
{{ p.label }}{{ p.connected ? "" : " " + t("forms.import.notConnected") }}
</option>
</select>
</div>
<p v-if="detail" class="rounded-control bg-warning-soft px-3 py-2 text-sm text-warning">{{ detail }}</p>
<!-- Chargers on the account -->
<div>
<label class="dh-label">{{ t("forms.importCharger.selectCharger") }}</label>
<p v-if="loadingChargers" class="text-sm text-muted">{{ t("forms.importCharger.loadingChargers") }}</p>
<p v-else-if="!detail && chargers.length === 0" class="text-sm text-muted">
{{ t("forms.importCharger.noChargers") }}
</p>
<ul v-else-if="chargers.length" class="space-y-2">
<li v-for="c in chargers" :key="c.id">
<label
class="flex cursor-pointer items-start gap-3 rounded-control border p-3 transition-colors"
:class="selectedId === c.id ? 'border-accent bg-sunken' : 'border-subtle hover:bg-sunken'">
<input
v-model="selectedId"
type="radio"
:value="c.id"
:disabled="!!c.linkedChargerId"
class="mt-1"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-strong">{{ c.name || c.id }}</span>
<span v-if="subtitle(c)" class="block truncate text-xs text-muted">{{ subtitle(c) }}</span>
<span class="data mt-0.5 block truncate text-xs text-muted">{{ c.id }}</span>
<span v-if="c.linkedChargerId" class="mt-1 inline-flex items-center gap-2">
<span class="dh-badge dh-badge-neutral">{{ t("forms.importCharger.alreadyImported") }}</span>
</span>
<span v-else-if="c.online === false" class="mt-1 inline-flex">
<span class="dh-badge dh-badge-warning">{{ t("settings.integrations.chargerOffline") }}</span>
</span>
</span>
</label>
</li>
</ul>
</div>
<div v-if="selected">
<label class="dh-label">{{ t("forms.importCharger.name") }}</label>
<input v-model="name" required class="dh-input" @input="nameEdited = true" />
</div>
<div class="flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="emit('close')">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="!canImport" class="dh-btn dh-btn-primary">
{{ importing ? t("forms.import.importing") : t("forms.importCharger.submit") }}
</button>
</div>
</form>
</Modal>
</template>
+19
View File
@@ -37,6 +37,14 @@
},
"charging": {
"home": {
"count": { "one": "{n} lader", "other": "{n} ladere" },
"import": "Importer fra tjeneste",
"remove": "Fjern",
"removeConfirm": "Fjern {name} fra dine ladere? Selve laderen røres ikke — importerer du den igen, er den tilbage.",
"empty": "Ingen ladere endnu. Importer den fra din Anker- eller Greencell-konto i stedet for at taste serienummeret.",
"connectFirst": "Tilslut en ladertjeneste under Indstillinger Integrationer for at importere."
},
"eyebrow": "Opladning og kort",
"title": "Ladere i nærheden",
"tabs": {
@@ -690,6 +698,17 @@
},
"forms": {
"importCharger": {
"title": "Importer en lader",
"subtitle": "Tilføj en lader fra en tjeneste, du har tilsluttet. Navn, serienummer og model følger med.",
"noProviders": "Ingen ladertjeneste er tilsluttet. Tilføj en under Indstillinger Integrationer.",
"selectCharger": "Lader",
"loadingChargers": "Indlæser dine ladere…",
"noChargers": "Ingen ladere på denne konto.",
"alreadyImported": "Allerede importeret",
"name": "Ladernavn",
"submit": "Importer lader"
},
"common": {
"pickDate": "Åbn kalender",
"dateInvalid": "Indtast en gyldig dato i dette format."
+19
View File
@@ -37,6 +37,14 @@
},
"charging": {
"home": {
"count": { "one": "{n} charger", "other": "{n} chargers" },
"import": "Import from service",
"remove": "Remove",
"removeConfirm": "Remove {name} from your chargers? The charger itself is untouched — importing it again brings it back.",
"empty": "No chargers yet. Import the one on your Anker or Greencell account instead of typing its serial.",
"connectFirst": "Connect a charger service in Settings Integrations to import one."
},
"eyebrow": "Charging & map",
"title": "Nearby chargers",
"tabs": {
@@ -689,6 +697,17 @@
},
"forms": {
"importCharger": {
"title": "Import a charger",
"subtitle": "Add a charger from a service you have connected. Its name, serial and model come with it.",
"noProviders": "No charger service is connected. Add one in Settings Integrations.",
"selectCharger": "Charger",
"loadingChargers": "Loading your chargers…",
"noChargers": "No chargers on this account.",
"alreadyImported": "Already imported",
"name": "Charger name",
"submit": "Import charger"
},
"common": {
"pickDate": "Open calendar",
"dateInvalid": "Enter a real date in this format."
+19
View File
@@ -37,6 +37,14 @@
},
"charging": {
"home": {
"count": { "one": "{n} ładowarka", "few": "{n} ładowarki", "many": "{n} ładowarek", "other": "{n} ładowarki" },
"import": "Importuj z usługi",
"remove": "Usuń",
"removeConfirm": "Usunąć {name} z Twoich ładowarek? Sama ładowarka pozostaje nietknięta — ponowny import ją przywróci.",
"empty": "Nie masz jeszcze ładowarek. Zaimportuj tę z konta Anker lub Greencell, zamiast przepisywać numer seryjny.",
"connectFirst": "Podłącz usługę ładowarki w Ustawienia Integracje, aby zaimportować."
},
"eyebrow": "Ładowanie i mapa",
"title": "Ładowarki w pobliżu",
"tabs": {
@@ -704,6 +712,17 @@
},
"forms": {
"importCharger": {
"title": "Importuj ładowarkę",
"subtitle": "Dodaj ładowarkę z podłączonej usługi. Nazwa, numer seryjny i model przychodzą razem z nią.",
"noProviders": "Nie podłączono żadnej usługi ładowarki. Dodaj ją w Ustawienia Integracje.",
"selectCharger": "Ładowarka",
"loadingChargers": "Wczytywanie ładowarek…",
"noChargers": "Brak ładowarek na tym koncie.",
"alreadyImported": "Już zaimportowana",
"name": "Nazwa ładowarki",
"submit": "Importuj ładowarkę"
},
"common": {
"pickDate": "Otwórz kalendarz",
"dateInvalid": "Wpisz istniejącą datę w tym formacie."
+121 -30
View File
@@ -2,6 +2,7 @@
import { ref, computed, onMounted } from "vue";
import { t } from "../i18n";
import { api } from "../api";
import ChargerImportModal from "../components/ChargerImportModal.vue";
// Charging & map screen, mirroring the web-dashboard UI kit. There is no live
// charging API yet (only the Anker Solix credential cascade in Settings), so the
@@ -17,14 +18,14 @@ const stations = [
{ id: "sc", name: "DriverVault Supercharge", dist: "0.4 km", kw: 250, conn: "CCS · NACS", avail: 6, total: 8, price: "0,34 €", tone: "good", x: "47%", y: "34%" },
{ id: "evgo", name: "EVgo · Market St", dist: "1.2 km", kw: 150, conn: "CCS", avail: 2, total: 6, price: "0,41 €", tone: "due", x: "26%", y: "60%" },
{ id: "cp", name: "ChargePoint Garage", dist: "2.1 km", kw: 62, conn: "J1772", avail: 0, total: 4, price: "0,29 €", tone: "fault", x: "70%", y: "64%" },
{ id: "home", name: t("charging.stations.homeCharger"), dist: "—", kw: 11, conn: "NACS", avail: 1, total: 1, price: t("charging.stations.offPeak"), tone: "good", x: "60%", y: "19%", home: true },
];
// Two tabs split the public charging network (discovery map + nearby stations)
// from the user's own home charger(s) and their real OCPP control.
// from the user's own chargers and their real OCPP control. The public half is
// still placeholder data; the home half is not — those are records the user
// imported from a service they connected.
const chargerTab = ref("public"); // "public" | "home"
const publicStations = stations.filter((s) => !s.home);
const homeStations = stations.filter((s) => s.home);
const publicStations = stations;
const selected = ref("sc");
const charging = ref(true);
@@ -96,6 +97,54 @@ function chargerLabel(c) {
return c.name ? `${c.name} · ${c.sn}` : c.sn;
}
// --- The user's own chargers, imported from a connected service ---
// The same move the garage makes for a car: a charger on a connected account
// becomes a record here, and stays one after the account is disconnected.
const homeChargers = ref([]);
const homeChargersError = ref("");
const showChargerImport = ref(false);
// Importing only makes sense once a charger service is connected, so the button
// appears only then — same rule the garage's import button follows.
const canImportCharger = ref(false);
async function loadHomeChargers() {
homeChargersError.value = "";
try {
homeChargers.value = await api.listHomeChargers();
} catch (e) {
homeChargersError.value = e.message;
}
}
// A charger picked here becomes the one the control card drives.
function selectHomeCharger(c) {
selected.value = c.id;
if (!c.serial) return;
ctlSerial.value = c.serial;
refreshCtl();
}
function onChargerImported(charger) {
showChargerImport.value = false;
homeChargers.value = [...homeChargers.value, charger];
selectHomeCharger(charger);
}
async function removeHomeCharger(c) {
if (!confirm(t("charging.home.removeConfirm", { name: c.name }))) return;
homeChargersError.value = "";
try {
await api.deleteHomeCharger(c.id);
homeChargers.value = homeChargers.value.filter((x) => x.id !== c.id);
} catch (e) {
homeChargersError.value = e.message;
}
}
function homeChargerSubtitle(c) {
return [c.serial, c.model, c.siteName].filter(Boolean).join(" · ");
}
async function refreshCtl() {
const sn = ctlSerial.value.trim();
if (!sn) {
@@ -152,6 +201,11 @@ function cancelReset() {
}
onMounted(async () => {
await loadHomeChargers();
api
.listChargerProviders()
.then((list) => (canImportCharger.value = list.some((p) => p.connected)))
.catch(() => (canImportCharger.value = false));
await loadCtlMode();
if (ctlActive.value) await loadChargers();
await refreshCtl();
@@ -395,37 +449,74 @@ onMounted(async () => {
</div>
</div>
<!-- Home station list -->
<!-- The user's own chargers -->
<div class="flex flex-col gap-4">
<div class="dh-card p-2">
<div class="eyebrow px-3 pb-1.5 pt-2.5">
{{ t("charging.stations.homeHeading") }} · {{ t("charging.stations.count", { n: homeStations.length }) }}
</div>
<button
v-for="s in homeStations"
:key="s.id"
type="button"
class="flex w-full items-center gap-3 rounded-control p-3 text-left transition-colors"
:class="selected === s.id ? 'bg-brand-100' : 'hover:bg-sunken'"
@click="selected = s.id"
>
<div
class="grid h-9 w-9 flex-none place-items-center rounded-control"
:class="selected === s.id ? 'bg-card' : 'bg-sunken'"
<div class="flex items-center justify-between gap-2 px-3 pb-1.5 pt-2.5">
<span class="eyebrow">
{{ t("charging.stations.homeHeading") }} · {{ t("charging.home.count", { n: homeChargers.length }) }}
</span>
<button
v-if="canImportCharger"
type="button"
class="dh-btn dh-btn-ghost !px-2 !py-1 text-xs"
@click="showChargerImport = true"
>
<svg viewBox="0 0 24 24" fill="none" :stroke="TONE[s.tone].fg" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-strong">{{ s.name }}</div>
<div class="data text-[11px] text-muted">{{ s.dist }} · {{ s.kw }} kW · {{ s.conn }}</div>
</div>
<div class="text-right">
<div class="data text-[11px] font-medium" :style="{ color: TONE[s.tone].fg }">{{ stationStatus(s) }}</div>
<div class="data mt-0.5 text-[11px] text-muted">{{ s.price }}</div>
</div>
</button>
{{ t("charging.home.import") }}
</button>
</div>
<div
v-for="c in homeChargers"
:key="c.id"
class="flex w-full items-center gap-3 rounded-control p-3 text-left transition-colors"
:class="selected === c.id ? 'bg-brand-100' : 'hover:bg-sunken'"
>
<button type="button" class="flex min-w-0 flex-1 items-center gap-3 text-left" @click="selectHomeCharger(c)">
<span
class="grid h-9 w-9 flex-none place-items-center rounded-control"
:class="selected === c.id ? 'bg-card' : 'bg-sunken'"
>
<svg viewBox="0 0 24 24" fill="none" :stroke="TONE.good.fg" stroke-width="2" class="h-4.5 w-4.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-strong">{{ c.name }}</span>
<span class="data block truncate text-[11px] text-muted">{{ homeChargerSubtitle(c) }}</span>
</span>
</button>
<button
type="button"
class="shrink-0 text-xs font-medium text-muted hover:text-danger"
:title="t('charging.home.remove')"
@click="removeHomeCharger(c)"
>
{{ t("charging.home.remove") }}
</button>
</div>
<!-- Nothing imported yet: say what this list is for and offer the import. -->
<div v-if="homeChargers.length === 0" class="px-3 pb-3 pt-1">
<p class="text-sm text-muted">{{ t("charging.home.empty") }}</p>
<button
v-if="canImportCharger"
type="button"
class="dh-btn dh-btn-primary mt-3 w-full"
@click="showChargerImport = true"
>
{{ t("charging.home.import") }}
</button>
<p v-else class="mt-2 text-xs text-muted">{{ t("charging.home.connectFirst") }}</p>
</div>
<p v-if="homeChargersError" class="px-3 pb-3 text-sm text-danger">{{ homeChargersError }}</p>
</div>
</div>
</div>
<ChargerImportModal
v-if="showChargerImport"
@saved="onChargerImported"
@close="showChargerImport = false"
/>
</div>
</template>