The dropdown could only show a serial it had an option for. A remembered one the account does not report — a charger imported before the account was linked, one the cloud is quiet about today — selected nothing, so the field sat blank while that serial was the one every command went to. Nothing on screen said which charger was being driven, or let it be corrected. The field now falls back to the text box in that case, the way it already does when the account lists no chargers at all, and shows the serial actually in force. One link switches between picking and typing, so a serial off the list is not a dead end and the list is not the only way in; coming back to it lands on a charger the list holds rather than blanking the dropdown again. Which of the two is showing is decided when the list arrives, not on every keystroke — recomputing it as the serial is typed would turn the text box into a dropdown mid-word, the moment what had been typed happened to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
900 lines
38 KiB
Vue
900 lines
38 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted, watch } from "vue";
|
|
import { t } from "../i18n";
|
|
import { askConfirm } from "../lib/confirm.js";
|
|
import { api } from "../api";
|
|
import { formatDateTime } from "../lib/format.js";
|
|
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
|
|
// session + stations below are presentational placeholders — swap them for real
|
|
// endpoints once the server exposes charging telemetry.
|
|
const TONE = {
|
|
good: { bg: "var(--success-100)", fg: "var(--success-600)" },
|
|
due: { bg: "var(--warning-100)", fg: "var(--warning-600)" },
|
|
fault: { bg: "var(--danger-100)", fg: "var(--danger-600)" },
|
|
};
|
|
|
|
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%" },
|
|
];
|
|
|
|
// Two tabs split the public charging network (discovery map + nearby stations)
|
|
// 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;
|
|
|
|
const selected = ref("sc");
|
|
const charging = ref(true);
|
|
|
|
function stationStatus(s) {
|
|
return s.avail === 0
|
|
? t("charging.stations.full")
|
|
: t("charging.stations.free", { avail: s.avail, total: s.total });
|
|
}
|
|
|
|
const session = {
|
|
car: "Model Y",
|
|
from: 62,
|
|
to: 80,
|
|
metrics: [
|
|
["rate", "142 kW"],
|
|
["added", "+29 km"],
|
|
["cost", "5,80 €"],
|
|
["done", "~18 min"],
|
|
],
|
|
};
|
|
|
|
const sessionMetrics = computed(() =>
|
|
session.metrics.map(([key, value]) => ({ label: t(`charging.session.${key}`), value }))
|
|
);
|
|
|
|
// --- Real OCPP control (Anker Solix), gated by the per-user control mode ---
|
|
// The demo session card above is presentational; this card drives a real charger
|
|
// via the control endpoints when the user has picked Own/Proxy CSMS in Settings.
|
|
const ctlMode = ref("off");
|
|
const ctlSerial = ref(localStorage.getItem("dv_ctl_serial") || "");
|
|
const ctl = ref(null); // { connected, status, controlMode, ... }
|
|
const ctlError = ref("");
|
|
const ctlBusy = ref(""); // action name currently in flight
|
|
const limitAmps = ref(16);
|
|
|
|
const ctlActive = computed(() => ctlMode.value !== "off");
|
|
const ctlConnected = computed(() => !!ctl.value?.connected);
|
|
|
|
// Modbus is the local path: we dial the charger rather than wait for it to dial
|
|
// us, so what the card asks for is an address rather than a token, and what it
|
|
// can offer differs — boost is a Modbus command, reset and clear-limit are OCPP
|
|
// ones the register map has no equivalent for.
|
|
const ctlIsModbus = computed(() => ctlMode.value === "modbus");
|
|
|
|
// The two transports report a charging session in different words: an OCPP
|
|
// session snapshot counts a meter in Wh, a Modbus snapshot counts the session's
|
|
// own energy. Both land in the same tile.
|
|
const ctlMeterKwh = computed(() => {
|
|
const s = ctl.value?.status || {};
|
|
const wh = ctlIsModbus.value ? s.sessionWh : s.meterWh;
|
|
return ((wh || 0) / 1000).toFixed(2);
|
|
});
|
|
const ctlStatusLabel = computed(() => {
|
|
const s = ctl.value?.status || {};
|
|
return (ctlIsModbus.value ? s.statusDesc : s.connectorStatus) || "—";
|
|
});
|
|
|
|
async function loadCtlMode() {
|
|
try {
|
|
const v = await api.getAnkerSolix();
|
|
ctlMode.value = v?.controlMode || "off";
|
|
} catch {
|
|
ctlMode.value = "off";
|
|
}
|
|
}
|
|
|
|
// 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([]);
|
|
// Typing the serial rather than picking it. A dropdown can only show a serial it
|
|
// has an option for, so a remembered serial the account does not report — a
|
|
// charger imported before the account was linked, one the cloud is quiet about
|
|
// today — would render as a blank field with no way to read or fix it. Falling
|
|
// back to the text box shows the serial that is actually in force.
|
|
const manualSerial = ref(false);
|
|
|
|
const serialInList = computed(() => chargers.value.some((c) => c.sn === ctlSerial.value.trim()));
|
|
const pickingFromList = computed(() => chargers.value.length > 0 && !manualSerial.value);
|
|
|
|
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;
|
|
}
|
|
// Decided when the list arrives rather than on every keystroke: recomputing it
|
|
// as the serial is typed would swap the text box for a dropdown mid-word, the
|
|
// moment what had been typed happened to match a charger.
|
|
manualSerial.value = chargers.value.length > 0 && !serialInList.value;
|
|
}
|
|
|
|
// One control for both directions, so a serial that is not on the account is
|
|
// never a dead end and the list is never the only option.
|
|
function toggleSerialEntry() {
|
|
manualSerial.value = !manualSerial.value;
|
|
// Returning to a list that does not hold this serial would blank the dropdown
|
|
// again, which is the thing being fixed; land on a charger it does hold.
|
|
if (!manualSerial.value && !serialInList.value && chargers.value.length) {
|
|
ctlSerial.value = chargers.value[0].sn;
|
|
refreshCtl();
|
|
}
|
|
}
|
|
|
|
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);
|
|
// The charger services this user could import from. Importing only makes sense
|
|
// once one of them is connected, so the button appears only then — same rule the
|
|
// garage's import button follows — and the list doubles as the id → label map
|
|
// the information card names a charger's origin with.
|
|
const chargerProviders = ref([]);
|
|
const canImportCharger = computed(() => chargerProviders.value.some((p) => p.connected));
|
|
|
|
function providerLabel(id) {
|
|
return chargerProviders.value.find((p) => p.id === id)?.label || id;
|
|
}
|
|
|
|
async function loadChargerProviders() {
|
|
try {
|
|
chargerProviders.value = await api.listChargerProviders();
|
|
} catch {
|
|
chargerProviders.value = [];
|
|
}
|
|
}
|
|
|
|
// --- Live reachability, keyed by the provider's own id for the charger ---
|
|
// The record says what a charger is; only the service it came from knows
|
|
// whether it is reachable right now, so that half is asked for separately and
|
|
// held beside the records rather than in them. Asking costs a round trip to
|
|
// each connected service, so it happens when the home tab is first opened —
|
|
// the moment the question is being asked — and on demand after that.
|
|
const chargerLive = ref({}); // provider charger id → the service's own record
|
|
const chargerLiveLoading = ref(false);
|
|
const chargerLiveLoaded = ref(false);
|
|
|
|
async function loadChargerLive(force = false) {
|
|
const connected = chargerProviders.value.filter((p) => p.connected);
|
|
if (chargerLiveLoading.value || connected.length === 0) return;
|
|
if (chargerLiveLoaded.value && !force) return; // switching tabs is not a new question
|
|
chargerLiveLoading.value = true;
|
|
const live = {};
|
|
await Promise.all(
|
|
connected.map(async (p) => {
|
|
try {
|
|
const res = await api.listProviderChargers(p.id);
|
|
for (const c of res?.chargers || []) live[c.id] = c;
|
|
} catch {
|
|
// A service that will not answer leaves its chargers unknown rather
|
|
// than offline — this page cannot tell those two apart.
|
|
}
|
|
})
|
|
);
|
|
chargerLive.value = live;
|
|
chargerLiveLoaded.value = true;
|
|
chargerLiveLoading.value = false;
|
|
}
|
|
|
|
// The live half for one imported charger, or null when the service it came from
|
|
// says nothing about it (disconnected since the import, or a charger added by
|
|
// hand). Both services we speak to id a charger by its serial, so the serial is
|
|
// a sound fallback for a record imported before the provider link was stored.
|
|
function liveFor(c) {
|
|
return chargerLive.value[c.providerChargerId] || chargerLive.value[c.serial] || null;
|
|
}
|
|
|
|
// How the charger is registered on the account, in the service's own terms.
|
|
function sourcesLabel(sources) {
|
|
if (!sources?.length) return "";
|
|
return sources.map((src) => {
|
|
const key = `charging.info.sourceNames.${src}`;
|
|
const label = t(key);
|
|
return label === key ? src : label;
|
|
}).join(" · ");
|
|
}
|
|
|
|
// The colour the list icon is drawn in: the same green and amber the badges in
|
|
// the information card use, so the list can be read at a glance without opening
|
|
// anything. A charger the service says nothing about stays muted — unknown is
|
|
// not offline, and a green bolt for it would be a claim.
|
|
function homeChargerStatusLabel(c) {
|
|
const online = liveFor(c)?.online;
|
|
if (online === true) return t("charging.info.online");
|
|
if (online === false) return t("charging.info.offline");
|
|
return "";
|
|
}
|
|
|
|
function homeChargerTone(c) {
|
|
const online = liveFor(c)?.online;
|
|
if (online === true) return TONE.good.fg;
|
|
if (online === false) return TONE.due.fg;
|
|
return "var(--text-muted)";
|
|
}
|
|
|
|
// The OCPP connector state as the *service* sees it — the cloud's own reading,
|
|
// not our CSMS's. It words it when it can and numbers it when it cannot.
|
|
function ocppStatusLabel(live) {
|
|
if (live?.ocppStatusDesc) return live.ocppStatusDesc;
|
|
return live?.ocppStatus == null ? "" : String(live.ocppStatus);
|
|
}
|
|
|
|
// The cloud's own slug for what the charger is doing (charging, standby, …),
|
|
// translated. The vocabulary is the integration's, so the wording lives with it
|
|
// in Settings rather than being said twice.
|
|
function chargerStateLabel(slug) {
|
|
if (!slug) return "";
|
|
const key = `settings.integrations.states.${slug}`;
|
|
const label = t(key);
|
|
return label === key ? slug.replace(/_/g, " ") : label;
|
|
}
|
|
|
|
async function loadHomeChargers() {
|
|
homeChargersError.value = "";
|
|
try {
|
|
homeChargers.value = await api.listHomeChargers();
|
|
} catch (e) {
|
|
homeChargersError.value = e.message;
|
|
}
|
|
}
|
|
|
|
// The card shows one charger: whichever is picked in the list beside it. Until
|
|
// something is picked that is the first one — a card that says nothing until
|
|
// clicked would be a worse first impression than the charger most people have
|
|
// only one of.
|
|
const selectedHomeCharger = computed(
|
|
() => homeChargers.value.find((c) => c.id === selected.value) || homeChargers.value[0] || null
|
|
);
|
|
|
|
// 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);
|
|
loadChargerLive(true);
|
|
}
|
|
|
|
// Removing asks first — through the app's own prompt, never window.confirm(),
|
|
// which a browser that suppresses dialogs answers with a silent no.
|
|
const removing = ref("");
|
|
|
|
async function removeHomeCharger(c) {
|
|
homeChargersError.value = "";
|
|
if (!(await askConfirm(t("charging.home.removeConfirm", { name: c.name })))) return;
|
|
removing.value = c.id;
|
|
try {
|
|
await api.deleteHomeCharger(c.id);
|
|
homeChargers.value = homeChargers.value.filter((x) => x.id !== c.id);
|
|
} catch (e) {
|
|
homeChargersError.value = e.message;
|
|
} finally {
|
|
removing.value = "";
|
|
}
|
|
}
|
|
|
|
function homeChargerSubtitle(c) {
|
|
return [c.serial, c.model, c.siteName].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
// Everything the card can say about one charger, as label/value rows. Every row
|
|
// is drawn every time, a field nothing supplied included: which fields a charger
|
|
// has an answer for is itself worth seeing, and a row that comes and goes with
|
|
// the data makes two chargers side by side impossible to read against each
|
|
// other. Nothing to say is said with a dash.
|
|
function chargerInfoRows(c) {
|
|
const live = liveFor(c) || {};
|
|
const rows = [
|
|
["vendor", c.vendor],
|
|
["model", c.model],
|
|
["firmware", live.firmware],
|
|
["serial", c.serial],
|
|
["site", c.siteName || live.siteName],
|
|
["siteId", live.siteId],
|
|
["sources", sourcesLabel(live.sources)],
|
|
["power", c.powerKw ? `${c.powerKw} kW` : ""],
|
|
["connector", c.connector],
|
|
["state", chargerStateLabel(live.status)],
|
|
// Relayed as the service words it — the unit is upstream's, so putting one
|
|
// on it here would be inventing it.
|
|
["chargePower", live.power],
|
|
["ocpp", ocppStatusLabel(live)],
|
|
["providerId", c.providerChargerId],
|
|
["added", c.created ? formatDateTime(c.created) : ""],
|
|
];
|
|
return rows.map(([key, value]) => ({
|
|
key,
|
|
label: t(`charging.info.${key}`),
|
|
value: value === "" || value == null ? "—" : value,
|
|
}));
|
|
}
|
|
|
|
async function refreshCtl() {
|
|
const sn = ctlSerial.value.trim();
|
|
if (!sn) {
|
|
ctl.value = null;
|
|
return;
|
|
}
|
|
localStorage.setItem("dv_ctl_serial", sn);
|
|
ctlError.value = "";
|
|
try {
|
|
ctl.value = await api.getAnkerControl(sn);
|
|
// The saved address belongs to the charger, not to the form: switching
|
|
// chargers has to bring its own address along rather than leave the previous
|
|
// one sitting in the field, where saving would move it to the wrong charger.
|
|
modbusHost.value = ctl.value?.modbusHost || "";
|
|
modbusPort.value = ctl.value?.modbusPort || 502;
|
|
} catch (e) {
|
|
ctlError.value = e.message;
|
|
ctl.value = null;
|
|
}
|
|
}
|
|
|
|
// --- The charger's address on the local network (Modbus mode) ---
|
|
// Enabled on the device in the Anker app under Settings > Integrations > Modbus
|
|
// TCP, which is where the address to type here comes from.
|
|
const modbusHost = ref("");
|
|
const modbusPort = ref(502);
|
|
const savingAddress = ref(false);
|
|
|
|
// Saving is only worth offering when there is something to save that is not
|
|
// already saved, so the button goes quiet once the form matches the server.
|
|
const addressChanged = computed(
|
|
() =>
|
|
modbusHost.value.trim() !== (ctl.value?.modbusHost || "") ||
|
|
Number(modbusPort.value || 502) !== (ctl.value?.modbusPort || 502)
|
|
);
|
|
|
|
async function saveModbusAddress() {
|
|
const sn = ctlSerial.value.trim();
|
|
if (!sn || !modbusHost.value.trim()) return;
|
|
savingAddress.value = true;
|
|
ctlError.value = "";
|
|
try {
|
|
await api.ankerControlAddress(sn, modbusHost.value.trim(), Number(modbusPort.value) || 502);
|
|
await refreshCtl();
|
|
} catch (e) {
|
|
ctlError.value = e.message;
|
|
} finally {
|
|
savingAddress.value = false;
|
|
}
|
|
}
|
|
|
|
async function forgetModbusAddress() {
|
|
const sn = ctlSerial.value.trim();
|
|
if (!sn) return;
|
|
if (!(await askConfirm(t("charging.control.forgetAddressConfirm")))) return;
|
|
savingAddress.value = true;
|
|
ctlError.value = "";
|
|
try {
|
|
await api.ankerControlForgetAddress(sn);
|
|
await refreshCtl();
|
|
} catch (e) {
|
|
ctlError.value = e.message;
|
|
} finally {
|
|
savingAddress.value = false;
|
|
}
|
|
}
|
|
|
|
async function doAction(action, body) {
|
|
const sn = ctlSerial.value.trim();
|
|
if (!sn) return;
|
|
ctlBusy.value = action;
|
|
ctlError.value = "";
|
|
try {
|
|
await api.ankerControlAction(sn, action, body || {});
|
|
await refreshCtl();
|
|
} catch (e) {
|
|
ctlError.value = e.message;
|
|
} finally {
|
|
ctlBusy.value = "";
|
|
}
|
|
}
|
|
|
|
// Reset reboots the charger — a destructive action the server gates behind an
|
|
// explicit confirmation AND a password re-authentication (step-up). Reveal the
|
|
// inline password prompt; the actual call happens in confirmReset().
|
|
const resetPrompt = ref(false);
|
|
const resetPassword = ref("");
|
|
|
|
function askReset() {
|
|
ctlError.value = "";
|
|
resetPassword.value = "";
|
|
resetPrompt.value = true;
|
|
}
|
|
|
|
async function confirmReset() {
|
|
if (!resetPassword.value) return;
|
|
resetPrompt.value = false;
|
|
await doAction("reset", { hard: false, confirm: true, password: resetPassword.value });
|
|
resetPassword.value = "";
|
|
}
|
|
|
|
function cancelReset() {
|
|
resetPrompt.value = false;
|
|
resetPassword.value = "";
|
|
}
|
|
|
|
// Opening the home tab is the moment reachability is being asked about; the
|
|
// public half never needs it.
|
|
watch(chargerTab, (tab) => {
|
|
if (tab === "home") loadChargerLive();
|
|
});
|
|
|
|
onMounted(async () => {
|
|
await loadHomeChargers();
|
|
await loadChargerProviders();
|
|
if (chargerTab.value === "home") loadChargerLive();
|
|
await loadCtlMode();
|
|
if (ctlActive.value) await loadChargers();
|
|
await refreshCtl();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="mb-6">
|
|
<p class="eyebrow">{{ t("charging.eyebrow") }}</p>
|
|
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("charging.title") }}</h1>
|
|
</div>
|
|
|
|
<!-- Tabs: public network vs. the user's own home charger(s) -->
|
|
<div class="mb-6 flex gap-2 border-b border-subtle">
|
|
<button
|
|
v-for="tab in ['public', 'home']"
|
|
:key="tab"
|
|
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
|
|
:class="chargerTab === tab
|
|
? 'border-accent text-strong'
|
|
: 'border-transparent text-muted hover:text-body'"
|
|
@click="chargerTab = tab"
|
|
>
|
|
{{ t(`charging.tabs.${tab}`) }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Public chargers: discovery map + nearby public stations -->
|
|
<div v-show="chargerTab === 'public'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
|
|
<!-- Map panel -->
|
|
<div
|
|
class="relative h-[520px] overflow-hidden rounded-card border border-subtle shadow-card"
|
|
style="background-color: var(--ink-25);
|
|
background-image:
|
|
radial-gradient(circle at 32% 28%, rgba(37,99,235,.06), transparent 42%),
|
|
repeating-linear-gradient(0deg, transparent 0 44px, rgba(15,30,61,.045) 44px 45px),
|
|
repeating-linear-gradient(90deg, transparent 0 44px, rgba(15,30,61,.045) 44px 45px);"
|
|
>
|
|
<!-- roads -->
|
|
<div class="absolute left-0 right-0 top-[52%] h-3.5 bg-brand-100"></div>
|
|
<div class="absolute bottom-0 top-0 left-[58%] w-3.5 bg-brand-100"></div>
|
|
<div class="absolute left-[18%] top-[-10%] h-[130%] w-2.5 origin-top rotate-[24deg]" style="background: rgba(37,99,235,.10)"></div>
|
|
|
|
<!-- legend -->
|
|
<div class="eyebrow absolute left-4 top-4 flex items-center gap-2 rounded-pill border border-subtle bg-card/90 px-3 py-1.5 backdrop-blur">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-3.5 w-3.5" style="color: var(--brand-600)"><path stroke-linecap="round" stroke-linejoin="round" d="M9 6 3 4v14l6 2 6-2 6 2V6l-6-2-6 2Zm0 0v14m6-16v14"/></svg>
|
|
{{ t("charging.liveMap") }}
|
|
</div>
|
|
|
|
<!-- you are here -->
|
|
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" :title="t('charging.youAreHere')">
|
|
<div class="grid h-10 w-10 place-items-center rounded-full" style="background: rgba(37,99,235,.16)">
|
|
<div class="h-3.5 w-3.5 rounded-full border-2 border-white bg-brand-600 shadow-card"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- charger pins -->
|
|
<button
|
|
v-for="s in publicStations"
|
|
:key="s.id"
|
|
type="button"
|
|
class="absolute flex -translate-x-1/2 -translate-y-full flex-col items-center"
|
|
:style="{ left: s.x, top: s.y, zIndex: selected === s.id ? 4 : 2 }"
|
|
@click="selected = s.id"
|
|
>
|
|
<span
|
|
v-if="selected === s.id"
|
|
class="mb-1.5 whitespace-nowrap rounded-control border border-subtle bg-card px-2.5 py-1.5 text-xs font-semibold text-strong shadow-card"
|
|
>{{ s.avail }}/{{ s.total }} · {{ s.kw }} kW</span>
|
|
<span
|
|
class="grid place-items-center rounded-[50%_50%_50%_2px] border-2 border-white"
|
|
:class="selected === s.id ? 'h-9 w-9' : 'h-7 w-7'"
|
|
:style="{ background: TONE[s.tone].fg, transform: 'rotate(45deg)', boxShadow: 'var(--shadow-sm)' }"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" class="-rotate-45" :class="selected === s.id ? 'h-[18px] w-[18px]' : 'h-3.5 w-3.5'"><path stroke-linecap="round" stroke-linejoin="round" d="M13 2 4.5 13.5H11l-1 8.5 8.5-11.5H12z"/></svg>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Right column: demo session + public station list -->
|
|
<div class="flex flex-col gap-4">
|
|
<!-- Active session (presentational demo) -->
|
|
<div class="relative overflow-hidden rounded-card bg-brand-900 p-5 text-white">
|
|
<template v-if="charging">
|
|
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]" style="color: var(--brand-300)">
|
|
<span class="h-1.5 w-1.5 rounded-full" style="background: var(--success-600)"></span>
|
|
{{ t("charging.session.chargingNow") }} · {{ session.car }}
|
|
</div>
|
|
<div class="mt-3 font-mono text-4xl font-medium tracking-[-0.03em]">
|
|
{{ session.from }}<span class="text-base font-medium text-white/70"> % → {{ session.to }}%</span>
|
|
</div>
|
|
<div class="mt-3.5 h-2 overflow-hidden rounded-pill bg-white/15">
|
|
<div class="h-full rounded-pill bg-brand-400" :style="{ width: session.from + '%' }"></div>
|
|
</div>
|
|
<div class="mt-4 flex justify-between">
|
|
<div v-for="m in sessionMetrics" :key="m.label">
|
|
<div class="font-mono text-[9px] uppercase tracking-[0.12em]" style="color: var(--brand-300)">{{ m.label }}</div>
|
|
<div class="mt-0.5 font-mono text-[15px] font-medium">{{ m.value }}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="mt-4 w-full rounded-control border border-white/20 bg-white/10 px-3 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-white/15"
|
|
@click="charging = false"
|
|
>{{ t("charging.session.stop") }}</button>
|
|
</template>
|
|
<template v-else>
|
|
<div class="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em]" style="color: var(--brand-300)">
|
|
<span class="h-1.5 w-1.5 rounded-full bg-white/40"></span>
|
|
{{ t("charging.session.idle") }}
|
|
</div>
|
|
<p class="mt-3 text-sm text-white/70">{{ t("charging.session.idleHint") }}</p>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Station list (public network) -->
|
|
<div class="dh-card p-2">
|
|
<div class="eyebrow px-3 pb-1.5 pt-2.5">
|
|
{{ t("charging.stations.heading") }} · {{ t("charging.stations.count", { n: publicStations.length }) }}
|
|
</div>
|
|
<button
|
|
v-for="s in publicStations"
|
|
: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'"
|
|
>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Home chargers: the user's own charger(s) + real OCPP control -->
|
|
<div v-show="chargerTab === 'home'" class="grid gap-6 lg:grid-cols-[1fr_360px] lg:items-start">
|
|
<!-- Real OCPP control (Own/Proxy CSMS) — the real home-charger control -->
|
|
<div class="flex flex-col gap-4">
|
|
<div v-if="ctlActive" class="dh-card p-4">
|
|
<div class="flex items-center justify-between">
|
|
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
|
|
<span class="dh-badge" :class="ctlConnected ? 'dh-badge-success' : 'dh-badge-warning'">
|
|
{{ ctlConnected ? t("charging.control.connected") : t("charging.control.disconnected") }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="mt-3 flex gap-2">
|
|
<select v-if="pickingFromList" 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"
|
|
spellcheck="false"
|
|
@keyup.enter="refreshCtl"
|
|
/>
|
|
<button class="dh-btn dh-btn-ghost shrink-0" @click="refreshCtl">{{ t("charging.control.refresh") }}</button>
|
|
</div>
|
|
<!-- Only worth offering when there is a list to switch to or from. -->
|
|
<button
|
|
v-if="chargers.length"
|
|
class="mt-1.5 text-[11px] text-muted underline-offset-2 transition-colors hover:text-body hover:underline"
|
|
@click="toggleSerialEntry"
|
|
>
|
|
{{ pickingFromList ? t("charging.control.enterSerial") : t("charging.control.pickSerial") }}
|
|
</button>
|
|
|
|
<!-- Modbus mode dials the charger, so it needs the charger's address on
|
|
this network rather than a token installed into the charger. -->
|
|
<div v-if="ctlIsModbus" class="mt-3 rounded-control bg-sunken p-3">
|
|
<label class="dh-label" for="modbus-host">{{ t("charging.control.address") }}</label>
|
|
<div class="mt-1 flex gap-2">
|
|
<input
|
|
id="modbus-host"
|
|
v-model="modbusHost"
|
|
class="dh-input grow"
|
|
:placeholder="t('charging.control.addressPlaceholder')"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
@keyup.enter="saveModbusAddress"
|
|
/>
|
|
<input
|
|
v-model.number="modbusPort"
|
|
type="number"
|
|
min="1"
|
|
max="65535"
|
|
class="dh-input w-24 shrink-0"
|
|
:aria-label="t('charging.control.addressPort')"
|
|
/>
|
|
</div>
|
|
<p class="mt-1.5 text-[11px] text-muted">{{ t("charging.control.addressHint") }}</p>
|
|
<div class="mt-2 flex gap-2">
|
|
<button
|
|
class="dh-btn dh-btn-primary grow"
|
|
:disabled="!modbusHost.trim() || !addressChanged || savingAddress"
|
|
@click="saveModbusAddress"
|
|
>
|
|
{{ t("charging.control.saveAddress") }}
|
|
</button>
|
|
<button
|
|
v-if="ctl?.modbusHost"
|
|
class="dh-btn dh-btn-ghost shrink-0"
|
|
:disabled="savingAddress"
|
|
@click="forgetModbusAddress"
|
|
>
|
|
{{ t("charging.control.forgetAddress") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="ctlConnected">
|
|
<div class="mt-3 grid grid-cols-2 gap-2">
|
|
<div class="rounded-control bg-sunken px-3 py-2">
|
|
<div class="data text-sm font-semibold text-strong">{{ ctlStatusLabel }}</div>
|
|
<div class="text-[11px] text-muted">{{ t("charging.control.status") }}</div>
|
|
</div>
|
|
<div class="rounded-control bg-sunken px-3 py-2">
|
|
<div class="data text-sm font-semibold text-strong">{{ ctlMeterKwh }} kWh</div>
|
|
<div class="text-[11px] text-muted">{{ t("charging.control.meter") }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 flex gap-2">
|
|
<button class="dh-btn dh-btn-primary grow" :disabled="ctlBusy === 'start'" @click="doAction('start')">
|
|
{{ t("charging.control.start") }}
|
|
</button>
|
|
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'stop'" @click="doAction('stop')">
|
|
{{ t("charging.control.stop") }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<label class="dh-label flex justify-between">
|
|
<span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span>
|
|
</label>
|
|
<input v-model.number="limitAmps" type="range" min="6" max="32" step="1" class="w-full accent-[var(--accent)]" />
|
|
<div class="mt-2 flex gap-2">
|
|
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'limit'" @click="doAction('limit', { amps: limitAmps })">
|
|
{{ t("charging.control.applyLimit") }}
|
|
</button>
|
|
<!-- Clearing a limit is an OCPP command; the register map takes an
|
|
explicit ceiling, so in Modbus mode there is nothing to clear to. -->
|
|
<button
|
|
v-if="!ctlIsModbus"
|
|
class="dh-btn dh-btn-ghost grow"
|
|
:disabled="ctlBusy === 'clear-limit'"
|
|
@click="doAction('clear-limit')"
|
|
>
|
|
{{ t("charging.control.clearLimit") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Boost is a Modbus command, and lasts for the current session only. -->
|
|
<button
|
|
v-if="ctlIsModbus"
|
|
class="dh-btn dh-btn-ghost mt-3 w-full"
|
|
:disabled="ctlBusy === 'boost'"
|
|
@click="doAction('boost', { on: true })"
|
|
>
|
|
{{ t("charging.control.boost") }}
|
|
</button>
|
|
|
|
<!-- Reset reboots the charger over OCPP; the register map has no
|
|
equivalent, so the button is not offered on the local path. -->
|
|
<button
|
|
v-if="!ctlIsModbus && !resetPrompt"
|
|
class="dh-btn dh-btn-ghost mt-3 w-full"
|
|
:disabled="ctlBusy === 'reset'"
|
|
@click="askReset"
|
|
>
|
|
{{ t("charging.control.reset") }}
|
|
</button>
|
|
<!-- Step-up: destructive reset requires re-entering the password. -->
|
|
<div v-else-if="resetPrompt" class="mt-3 rounded-control border border-danger/40 bg-danger-soft p-3">
|
|
<p class="text-xs font-medium text-danger">{{ t("charging.control.resetConfirm") }}</p>
|
|
<input
|
|
v-model="resetPassword"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
class="dh-input mt-2"
|
|
:placeholder="t('charging.control.resetPassword')"
|
|
@keyup.enter="confirmReset"
|
|
/>
|
|
<div class="mt-2 flex gap-2">
|
|
<button class="dh-btn dh-btn-ghost grow" @click="cancelReset">{{ t("common.cancel") }}</button>
|
|
<button class="dh-btn dh-btn-danger grow" :disabled="!resetPassword || ctlBusy === 'reset'" @click="confirmReset">
|
|
{{ t("charging.control.reset") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Not connected. In Modbus mode the server has already tried to reach
|
|
the charger and says what it found, which beats a generic hint. -->
|
|
<p v-else class="mt-3 text-xs text-muted">
|
|
{{ ctl?.detail || t(ctlIsModbus ? "charging.control.modbusHint" : "charging.control.connectHint") }}
|
|
</p>
|
|
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
|
|
</div>
|
|
|
|
<!-- Charger information: everything the record holds about each imported
|
|
charger. It stands on its own — control needs Own/Proxy CSMS, but
|
|
what the charger *is* is known either way, so with control off this
|
|
card is what fills the column instead of a bare hint. -->
|
|
<div class="dh-card p-4">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p>
|
|
<button
|
|
v-if="homeChargers.length"
|
|
type="button"
|
|
class="dh-btn dh-btn-ghost !px-2 !py-1 text-xs"
|
|
:disabled="chargerLiveLoading"
|
|
@click="loadChargerLive(true)"
|
|
>
|
|
{{ chargerLiveLoading ? t("common.loading") : t("charging.info.refresh") }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 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 shrink-0 items-center gap-2">
|
|
<!-- Reachability, said either way. A charger the service says
|
|
nothing about stays silent: unknown is not offline. -->
|
|
<span v-if="liveFor(selectedHomeCharger)?.online === true" class="dh-badge dh-badge-success">
|
|
{{ t("charging.info.online") }}
|
|
</span>
|
|
<span v-else-if="liveFor(selectedHomeCharger)?.online === false" class="dh-badge dh-badge-warning">
|
|
{{ t("charging.info.offline") }}
|
|
</span>
|
|
<span v-if="selectedHomeCharger.provider" class="dh-badge dh-badge-neutral">
|
|
{{ providerLabel(selectedHomeCharger.provider) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
|
<template v-for="row in chargerInfoRows(selectedHomeCharger)" :key="row.key">
|
|
<dt class="text-[11px] text-muted">{{ row.label }}</dt>
|
|
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
|
|
</template>
|
|
</dl>
|
|
</div>
|
|
|
|
<p v-if="homeChargers.length === 0" class="mt-2 text-xs text-muted">
|
|
{{ t("charging.info.empty") }}
|
|
</p>
|
|
<!-- Control mode off: say why the card above is missing, here, where
|
|
there is now something to read it against. -->
|
|
<p v-if="!ctlActive" class="mt-3 text-xs text-muted">{{ t("charging.stations.noControlHint") }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- The user's own chargers -->
|
|
<div class="flex flex-col gap-4">
|
|
<div class="dh-card p-2">
|
|
<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"
|
|
>
|
|
{{ 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'"
|
|
:title="homeChargerStatusLabel(c)"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" :stroke="homeChargerTone(c)" 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 disabled:opacity-50"
|
|
:title="t('charging.home.remove')"
|
|
:disabled="removing === c.id"
|
|
@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>
|