The enrolment the Anker app does, done here: 0108 a2=7 opens the reader, 0908 brings back the UID. The frames this sends are byte-for-byte the ones the app was captured sending — checksum included — which is what the new test asserts. Adding and removing now write the charger as well as the account: the device write is the app's own message, the account write is the inferred one that carries the name, and either may fail without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2562 lines
116 KiB
Vue
2562 lines
116 KiB
Vue
<script setup>
|
||
import { ref, computed, onMounted, watch } from "vue";
|
||
import { t } from "../i18n";
|
||
import { prefs } from "../prefs";
|
||
import { askConfirm } from "../lib/confirm.js";
|
||
import { api } from "../api";
|
||
import { formatDateTime } from "../lib/format.js";
|
||
import { CHARGING_TABS, defaultTabFor } from "../lib/tabs.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.
|
||
//
|
||
// Which of them the page opens on is not fixed: it follows the account's default
|
||
// (Settings › Appearance) and otherwise whichever tab was dragged to the front.
|
||
// Empty until that is resolved just below.
|
||
const chargerTab = ref(""); // "public" | "home"
|
||
|
||
// --- Arranging the tab bar ---
|
||
//
|
||
// The bar drags into either order, on the same native drag events as the
|
||
// garage and a car's tabs — so pointer-only, as touch browsers don't fire
|
||
// these. The arrangement is saved on the profile rather than in this browser:
|
||
// it is a layout choice that should follow the account, the way the garage
|
||
// order does, and unlike which cards are folded.
|
||
const ALL_CHARGER_TABS = CHARGING_TABS;
|
||
|
||
const tabKeys = ref([...ALL_CHARGER_TABS]);
|
||
watch(
|
||
() => prefs.chargerTabOrder,
|
||
(order) => {
|
||
const arranged = [];
|
||
for (const key of order || []) {
|
||
if (ALL_CHARGER_TABS.includes(key) && !arranged.includes(key)) arranged.push(key);
|
||
}
|
||
// A tab the stored arrangement doesn't mention — one added in a later
|
||
// release — follows the arranged ones, the same rule the car's tabs use.
|
||
tabKeys.value = [...arranged, ...ALL_CHARGER_TABS.filter((k) => !arranged.includes(k))];
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// Landing tab. The arrangement and the saved default both arrive with the
|
||
// profile, which on a hard refresh lands after this view has mounted — so the
|
||
// bar keeps following them until the user says otherwise, rather than opening on
|
||
// whatever it could guess first. A click or a drag is the user saying otherwise:
|
||
// from then on the page stays where they put it.
|
||
const tabPicked = ref(false);
|
||
watch(
|
||
[tabKeys, () => prefs.defaultTabs],
|
||
() => {
|
||
if (!tabPicked.value) chargerTab.value = defaultTabFor("charging", tabKeys.value);
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
function selectTab(tab) {
|
||
tabPicked.value = true;
|
||
chargerTab.value = tab;
|
||
}
|
||
|
||
// The rail's lock holds the bar still for someone who would rather not nudge it
|
||
// on the way to a tab.
|
||
const canArrangeTabs = computed(() => !prefs.dragLocked && tabKeys.value.length > 1);
|
||
const dragTab = ref(""); // tab being dragged
|
||
const dropTab = ref(""); // tab it is currently hovering over
|
||
const tabOrderError = ref("");
|
||
let tabsMoved = false; // the bar changed during this drag and isn't saved yet
|
||
|
||
function onTabDragStart(key, e) {
|
||
dragTab.value = key;
|
||
// Rearranging the bar must not pull the content out from under the drag, so
|
||
// the page stops following the arrangement the moment one starts.
|
||
tabPicked.value = true;
|
||
tabsMoved = false;
|
||
e.dataTransfer.effectAllowed = "move";
|
||
// Firefox only starts a drag once something is on the transfer.
|
||
e.dataTransfer.setData("text/plain", key);
|
||
}
|
||
|
||
// Reorder live as the pointer crosses tabs, so the bar shows the arrangement
|
||
// you are about to get. dragenter fires again for every child element inside
|
||
// the same button, so the tab being hovered is remembered and only a genuinely
|
||
// new one moves anything.
|
||
function onTabDragEnter(key) {
|
||
if (!dragTab.value || key === dragTab.value || dropTab.value === key) return;
|
||
dropTab.value = key;
|
||
const list = tabKeys.value;
|
||
const from = list.indexOf(dragTab.value);
|
||
const to = list.indexOf(key);
|
||
if (from < 0 || to < 0) return;
|
||
list.splice(to, 0, ...list.splice(from, 1));
|
||
tabsMoved = true;
|
||
}
|
||
|
||
// Save whatever the bar now shows. Called from both drop and dragend: a tab
|
||
// released beside the bar never produces a drop, and leaving that arrangement
|
||
// unsaved would quietly undo itself on the next load.
|
||
async function commitTabOrder() {
|
||
dragTab.value = "";
|
||
dropTab.value = "";
|
||
if (!tabsMoved) return;
|
||
tabsMoved = false;
|
||
tabOrderError.value = "";
|
||
const arranged = [...tabKeys.value];
|
||
try {
|
||
await api.updateMe({ chargerTabOrder: arranged });
|
||
prefs.chargerTabOrder = arranged;
|
||
} catch (e) {
|
||
// The arrangement didn't stick; say so and put the stored one back rather
|
||
// than leaving the bar showing an order the server does not have.
|
||
tabOrderError.value = e.message;
|
||
tabKeys.value = [...prefs.chargerTabOrder.filter((k) => ALL_CHARGER_TABS.includes(k)),
|
||
...ALL_CHARGER_TABS.filter((k) => !prefs.chargerTabOrder.includes(k))];
|
||
}
|
||
}
|
||
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.
|
||
// --- Arranging the cards ---
|
||
//
|
||
// The cards drag into any order, like the provider panel's readings, and the
|
||
// arrangement is saved on the profile beside the tab order. The column is a
|
||
// flex box, so a card is placed with the CSS order property rather than by
|
||
// moving markup: these are different things, not several of one thing.
|
||
//
|
||
// The handle is the card's own header. Making the whole card draggable would
|
||
// fight the controls inside it — a range slider and two text fields cannot
|
||
// share a pointer with a native drag.
|
||
//
|
||
// Settings follows control by default because it is the readback of what those
|
||
// buttons just did: the limit that was applied, whether boost took. An account
|
||
// that has already arranged this column gets it at the end instead, the same
|
||
// rule a tab added in a later release follows, and one drag puts it anywhere.
|
||
const ALL_CHARGER_CARDS = ["control", "rfid", "settings", "connection", "readings", "info"];
|
||
|
||
const cardKeys = ref([...ALL_CHARGER_CARDS]);
|
||
watch(
|
||
() => prefs.chargerCardOrder,
|
||
(order) => {
|
||
const arranged = [];
|
||
for (const key of order || []) {
|
||
if (ALL_CHARGER_CARDS.includes(key) && !arranged.includes(key)) arranged.push(key);
|
||
}
|
||
// A card added after this order was saved goes where the default order puts
|
||
// it — behind the neighbour it was written to sit under — rather than at the
|
||
// bottom of the column. Somebody who has arranged their cards once should not
|
||
// have to go looking for the new one.
|
||
for (const key of ALL_CHARGER_CARDS) {
|
||
if (arranged.includes(key)) continue;
|
||
const before = ALL_CHARGER_CARDS.slice(0, ALL_CHARGER_CARDS.indexOf(key))
|
||
.reverse()
|
||
.find((k) => arranged.includes(k));
|
||
const at = before ? arranged.indexOf(before) + 1 : 0;
|
||
arranged.splice(at, 0, key);
|
||
}
|
||
cardKeys.value = arranged;
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
const canArrangeCards = computed(() => !prefs.dragLocked);
|
||
const dragCard = ref(""); // card being dragged
|
||
const dropCard = ref(""); // card it is currently hovering over
|
||
const cardOrderError = ref("");
|
||
let cardsMoved = false; // the column changed during this drag and isn't saved yet
|
||
|
||
// Where this card sits in the column.
|
||
function cardOrder(key) {
|
||
const i = cardKeys.value.indexOf(key);
|
||
return i < 0 ? ALL_CHARGER_CARDS.length : i; // unarranged falls to the end, not the top
|
||
}
|
||
|
||
function onCardDragStart(key, e) {
|
||
dragCard.value = key;
|
||
cardsMoved = false;
|
||
e.dataTransfer.effectAllowed = "move";
|
||
// Firefox only starts a drag once something is on the transfer.
|
||
e.dataTransfer.setData("text/plain", key);
|
||
}
|
||
|
||
// Reorder live as the pointer crosses cards, so the column shows the
|
||
// arrangement you are about to get. dragenter fires again for every element
|
||
// inside the same card, so only a genuinely new one moves anything.
|
||
function onCardDragEnter(key) {
|
||
if (!dragCard.value || key === dragCard.value || dropCard.value === key) return;
|
||
dropCard.value = key;
|
||
const list = cardKeys.value;
|
||
const from = list.indexOf(dragCard.value);
|
||
const to = list.indexOf(key);
|
||
if (from < 0 || to < 0) return;
|
||
list.splice(to, 0, ...list.splice(from, 1));
|
||
cardsMoved = true;
|
||
}
|
||
|
||
// Save whatever the column now shows. Called from both drop and dragend: a card
|
||
// released beside the column never produces a drop.
|
||
async function commitCardOrder() {
|
||
dragCard.value = "";
|
||
dropCard.value = "";
|
||
if (!cardsMoved) return;
|
||
cardsMoved = false;
|
||
cardOrderError.value = "";
|
||
const arranged = [...cardKeys.value];
|
||
try {
|
||
await api.updateMe({ chargerCardOrder: arranged });
|
||
prefs.chargerCardOrder = arranged;
|
||
} catch (e) {
|
||
// The arrangement didn't stick; say so and put the stored one back rather
|
||
// than leaving the column showing an order the server does not have.
|
||
cardOrderError.value = e.message;
|
||
cardKeys.value = [
|
||
...prefs.chargerCardOrder.filter((k) => ALL_CHARGER_CARDS.includes(k)),
|
||
...ALL_CHARGER_CARDS.filter((k) => !prefs.chargerCardOrder.includes(k)),
|
||
];
|
||
}
|
||
}
|
||
|
||
// --- Folding the cards ---
|
||
//
|
||
// The column runs long once the readings are in it, so every card folds away.
|
||
// Which ones are folded is kept in localStorage rather than on the profile, the
|
||
// same way the provider panel keeps its own: it is a per-device reading habit,
|
||
// not an account setting, and it should survive leaving the tab. Keyed by card,
|
||
// so a fold outlives switching charger.
|
||
const COLLAPSED_KEY = "dv_charging_collapsed";
|
||
|
||
const collapsed = ref(readCollapsed());
|
||
|
||
function readCollapsed() {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(COLLAPSED_KEY) || "[]");
|
||
return Array.isArray(raw) ? raw.filter((id) => typeof id === "string") : [];
|
||
} catch {
|
||
return []; // unreadable (hand-edited, or written by an older version)
|
||
}
|
||
}
|
||
|
||
function isOpen(id) {
|
||
return !collapsed.value.includes(id);
|
||
}
|
||
|
||
function toggleCard(id) {
|
||
collapsed.value = isOpen(id)
|
||
? [...collapsed.value, id]
|
||
: collapsed.value.filter((k) => k !== id);
|
||
try {
|
||
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(collapsed.value));
|
||
} catch {
|
||
// A full or blocked store just means the choice lasts this visit only.
|
||
}
|
||
}
|
||
|
||
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.
|
||
const ctlIsModbus = computed(() => ctlMode.value === "modbus");
|
||
|
||
// The Anker cloud path: both ends meet at the broker the charger already talks
|
||
// to, so there is nothing to address and nothing to install — only an account to
|
||
// be signed in to. It is the mode for a charger that is somewhere else.
|
||
const ctlIsCloud = computed(() => ctlMode.value === "mqtt");
|
||
|
||
// Those two read the charger itself and answer with its own snapshot, where OCPP
|
||
// answers with the session our CSMS is holding. What a snapshot carries differs
|
||
// between them — the register map has the relay temperatures, the cloud has the
|
||
// plug and start countdowns — but anything both can report is named the same in
|
||
// both, so one set of readouts serves them and a missing value simply drops its
|
||
// row. What each can be *told* still differs: reset and clear-limit are OCPP,
|
||
// the timeout and phase registers are Modbus, skipping a start delay is cloud.
|
||
const ctlReadsDevice = computed(() => ctlIsModbus.value || ctlIsCloud.value);
|
||
|
||
// The transports report a charging session in different words: an OCPP session
|
||
// snapshot counts a meter in Wh, a snapshot read from the charger counts the
|
||
// session's own energy. Both land in the same tile.
|
||
const ctlMeterKwh = computed(() => {
|
||
const s = ctl.value?.status || {};
|
||
const wh = ctlReadsDevice.value ? s.sessionWh : s.meterWh;
|
||
return ((wh || 0) / 1000).toFixed(2);
|
||
});
|
||
const ctlStatusLabel = computed(() => {
|
||
const s = ctl.value?.status || {};
|
||
return (ctlReadsDevice.value ? s.statusDesc : s.connectorStatus) || "—";
|
||
});
|
||
|
||
// --- The charger's own snapshot, grouped for reading ---
|
||
//
|
||
// Reading the charger directly reports far more than OCPP does: one poll carries
|
||
// metering, the control settings and the charger's identity. Shown as a flat
|
||
// list that is a wall of forty numbers, so it is sorted the way it gets asked
|
||
// about — what the charger is doing, what it is set to, and what it is.
|
||
const dev = computed(() => (ctlReadsDevice.value && ctl.value?.status) || {});
|
||
|
||
const isSet = (v) => v !== undefined && v !== null;
|
||
const unit = (v, digits, u) => (isSet(v) ? `${Number(v).toFixed(digits)} ${u}` : null);
|
||
const yesNo = (v) => (isSet(v) ? (v ? t("common.yes") : t("common.no")) : null);
|
||
const label = (key) => t(`charging.modbus.${key}`);
|
||
|
||
// An enum the charger reports as a number, named through the catalogue so it
|
||
// translates; an unlisted value falls back to the number rather than a blank.
|
||
const enumLabel = (prefix, v) => {
|
||
if (!isSet(v)) return null;
|
||
const key = `charging.modbus.${prefix}${v}`;
|
||
const text = t(key);
|
||
return text === key ? String(v) : text;
|
||
};
|
||
|
||
// The operational mode the charger is in, in the integration's own vocabulary.
|
||
// The cloud path is the only one that derives it — it is the only transport that
|
||
// can see the boost flag and the countdowns the mode depends on.
|
||
function modeLabel(slug) {
|
||
if (!slug) return null;
|
||
const key = `settings.integrations.modes.${slug}`;
|
||
const text = t(key);
|
||
return text === key ? slug.replace(/_/g, " ") : text;
|
||
}
|
||
|
||
// A countdown the charger is running, as minutes and seconds. Only shown while
|
||
// it is actually running: zero is not a countdown, it is the absence of one.
|
||
function countdown(sec) {
|
||
if (!isSet(sec) || sec <= 0) return null;
|
||
const m = Math.floor(sec / 60);
|
||
const r = Math.round(sec % 60);
|
||
return m > 0 ? `${m} min ${r} s` : `${r} s`;
|
||
}
|
||
|
||
// The current range as the charger reports it: both bounds when it sends both,
|
||
// and the one it does send otherwise — "up to 32 A" is a fact, and dropping the
|
||
// row because the other half is missing hides it.
|
||
function currentRange(min, max) {
|
||
if (isSet(min) && isSet(max)) return `${min}\u2013${max} A`;
|
||
if (isSet(max)) return `${t("charging.modbus.upTo")} ${max} A`;
|
||
if (isSet(min)) return `${t("charging.modbus.from")} ${min} A`;
|
||
return null;
|
||
}
|
||
|
||
function sessionLength(sec) {
|
||
if (!isSet(sec)) return null;
|
||
const h = Math.floor(sec / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
return h > 0 ? `${h} h ${m} min` : `${m} min`;
|
||
}
|
||
|
||
// What the car is drawing and how long it has been, beside the buttons that
|
||
// start and stop it — the two numbers you look at to see whether pressing one
|
||
// did anything. Both are Modbus registers; the OCPP status carries neither, so
|
||
// there the tiles are absent rather than empty, which is also what happens on a
|
||
// charger whose firmware does not report them.
|
||
const ctlPower = computed(() => unit(dev.value.powerTotal, 0, "W"));
|
||
const ctlSessionTime = computed(() => sessionLength(dev.value.sessionSeconds));
|
||
|
||
// Pairs with no value drop out: a charger on older firmware, or one that refused
|
||
// the control block, should show a shorter list rather than a column of dashes.
|
||
const rows = (pairs) =>
|
||
pairs.filter(([, v]) => isSet(v) && v !== "").map(([k, v]) => ({ label: label(k), value: v }));
|
||
|
||
const deviceLive = computed(() => {
|
||
const s = dev.value;
|
||
return rows([
|
||
// What the charger says it is doing, and the session's own energy. Both are
|
||
// registers of their own, and both were readable only from the tiles in the
|
||
// control card — a readout that leaves out the two numbers the page puts in
|
||
// front of you is not the full readout it claims to be.
|
||
["chargingStatus", s.statusDesc],
|
||
["mode", modeLabel(s.mode)],
|
||
["power", unit(s.powerTotal, 0, "W")],
|
||
["sessionDuration", sessionLength(s.sessionSeconds)],
|
||
["sessionEnergy", isSet(s.sessionWh) ? `${(s.sessionWh / 1000).toFixed(2)} kWh` : null],
|
||
["plugCountdown", countdown(s.plugCountdownSeconds)],
|
||
["startCountdown", countdown(s.startCountdownSeconds)],
|
||
["cpSignal", s.cpSignalDesc],
|
||
["cpVoltage", unit(s.cpVoltage, 2, "V")],
|
||
["plugged", yesNo(s.plugged)],
|
||
// Where the charge is coming from. The reference marks this reading
|
||
// uncertain, and an unlisted value falls back to its number rather than
|
||
// borrowing the name of a neighbouring one.
|
||
["chargingSource", enumLabel("chargingSource", s.chargingSource)],
|
||
["chargingWindow", sessionLength(s.chargingWindowSeconds)],
|
||
["sessionStarted", s.sessionStartedAt ? formatDateTime(new Date(s.sessionStartedAt * 1000)) : null],
|
||
["orderId", isSet(s.orderId) ? String(s.orderId) : null],
|
||
["phaseMode", enumLabel("phaseMode", s.phaseMode)],
|
||
["relayTemps",
|
||
isSet(s.relay1TempC) && isSet(s.relay2TempC)
|
||
? `${s.relay1TempC.toFixed(1)} / ${s.relay2TempC.toFixed(1)} °C`
|
||
: unit(s.relay1TempC, 1, "°C")],
|
||
["pwm", yesNo(s.pwmEnabled)],
|
||
// Two streams, two clocks: telemetry flows only inside a trigger window,
|
||
// the settings arrive with a command. A reading is worth as much as its
|
||
// age, so each half says when it last spoke.
|
||
["liveStream", yesNo(s.live)],
|
||
["telemetryAt", s.telemetryAt ? formatDateTime(s.telemetryAt) : null],
|
||
["settingsAt", s.settingsAt ? formatDateTime(s.settingsAt) : null],
|
||
]);
|
||
});
|
||
|
||
const deviceSettings = computed(() => {
|
||
const s = dev.value;
|
||
const set = s.settings || {};
|
||
return rows([
|
||
["maxCurrentSet", unit(set.maxCurrentA, 1, "A")],
|
||
["timeout", isSet(set.timeoutSeconds) ? `${set.timeoutSeconds} s` : null],
|
||
["phaseSetting", enumLabel("phaseSet", set.phaseSetting)],
|
||
// The two transports name the same thing differently: the control block has
|
||
// a boost register that was written, the cloud reports a boost that is
|
||
// running. Either answers "is it boosting".
|
||
["boostSet", yesNo(isSet(set.boost) ? set.boost : s.boostMode)],
|
||
["autoStart", yesNo(set.autoStart)],
|
||
["scheduleWindow", set.weekStart && set.weekEnd ? `${set.weekStart}–${set.weekEnd}` : null],
|
||
["lastCommand", enumLabel("command", set.lastCommand)],
|
||
["chargingMode", enumLabel("chargingMode", s.chargingMode)],
|
||
["loadBalancing", yesNo(s.loadBalancing)],
|
||
["solarBalancing", yesNo(s.solarBalancing)],
|
||
["ledBrightness", isSet(s.ledBrightness) ? `${s.ledBrightness} %` : null],
|
||
// The rest of the settings group, which only the cloud transport reports:
|
||
// the register map has no address for any of them.
|
||
["plugLock", yesNo(set.plugLock)],
|
||
["autoRestart", yesNo(set.autoRestart)],
|
||
["randomDelay", yesNo(set.randomDelay)],
|
||
["scheduleEnabled", yesNo(set.scheduleEnabled)],
|
||
["scheduleMode", enumLabel("scheduleMode", set.scheduleMode)],
|
||
["weekendWindow", set.weekendStart && set.weekendEnd ? `${set.weekendStart}–${set.weekendEnd}` : null],
|
||
["weekendMode", enumLabel("weekendMode", set.weekendMode)],
|
||
["lightOff", yesNo(set.lightOffSchedule)],
|
||
["lightOffWindow", set.lightOffStart && set.lightOffEnd ? `${set.lightOffStart}–${set.lightOffEnd}` : null],
|
||
["mainBreakerLimit", unit(set.mainBreakerLimitA, 0, "A")],
|
||
["solarChargeMode", enumLabel("solarMode", set.solarChargeMode)],
|
||
["solarMinCurrent", unit(set.solarMinCurrentA, 0, "A")],
|
||
["autoPhaseSwitching", yesNo(set.autoPhaseSwitching)],
|
||
["swipeUp", enumLabel("gesture", s.swipeUpMode)],
|
||
["swipeDown", enumLabel("gesture", s.swipeDownMode)],
|
||
["smartTouch", enumLabel("touch", s.smartTouchMode)],
|
||
// What the two balancing features watch. The reference has not pinned down
|
||
// what the two modes and the flag select, so they are shown as the numbers
|
||
// they are rather than under names that would imply we knew.
|
||
["loadBalanceMeter", s.loadBalanceMonitorSN],
|
||
["loadBalanceMonitorMode", isSet(s.loadBalanceMonitorMode) ? String(s.loadBalanceMonitorMode) : null],
|
||
["loadBalanceMeterFlag", isSet(s.loadBalanceMeterFlag) ? String(s.loadBalanceMeterFlag) : null],
|
||
["solarMonitor", s.solarMonitorSN],
|
||
["solarMonitoringMode", isSet(s.solarMonitoringMode) ? String(s.solarMonitoringMode) : null],
|
||
]);
|
||
});
|
||
|
||
// What the charger says about its own LAN side. The cloud transport is the only
|
||
// one that can answer it — a charger whose Modbus server is off is a charger the
|
||
// Modbus transport cannot ask.
|
||
const deviceLocal = computed(() => {
|
||
const local = dev.value.local || {};
|
||
return rows([
|
||
["modbusServer", yesNo(local.modbusEnabled)],
|
||
["modbusAddress", local.host],
|
||
["modbusPort", isSet(local.port) ? String(local.port) : null],
|
||
["modbusTimeout", isSet(local.timeoutSeconds) ? `${local.timeoutSeconds} s` : null],
|
||
]);
|
||
});
|
||
|
||
// --- What can actually be set -------------------------------------------------
|
||
//
|
||
// Only the control block is writable: the current ceiling, boost, the timeout
|
||
// and the phase count (21001-21005), plus the start/stop command the control
|
||
// card sends. Everything else in the settings readback — charging mode, the two
|
||
// balancing flags, the LED — lives in the measurement registers, which the
|
||
// charger reports and the Anker app sets. So this card offers controls for the
|
||
// four and shows the rest as what they are: a readback.
|
||
const draftAmps = ref(16);
|
||
const draftSeconds = ref(120);
|
||
const draftPhase = ref(0);
|
||
|
||
// Seeded from the charger, not from the last thing typed. refreshCtl runs after
|
||
// every write, so the form ends up showing what the charger took — which is not
|
||
// always what was asked for, since it clamps the current to its own rating.
|
||
function syncSettingsDraft() {
|
||
const set = dev.value.settings || {};
|
||
if (isSet(set.maxCurrentA)) draftAmps.value = Math.round(set.maxCurrentA);
|
||
if (isSet(set.timeoutSeconds)) draftSeconds.value = set.timeoutSeconds;
|
||
if (isSet(set.phaseSetting)) draftPhase.value = set.phaseSetting;
|
||
}
|
||
|
||
const boostOn = computed(() => !!(dev.value.settings || {}).boost);
|
||
|
||
// The countdowns beside the start button, and the button that cuts one short.
|
||
// A delay can only be skipped while it is running, which is what modeOptions
|
||
// says — offering it the rest of the time would be a button that does nothing.
|
||
const ctlPlugCountdown = computed(() => countdown(dev.value.plugCountdownSeconds));
|
||
const ctlStartCountdown = computed(() => countdown(dev.value.startCountdownSeconds));
|
||
const ctlCanSkipDelay = computed(() => (dev.value.modeOptions || []).includes("skip_delay"));
|
||
|
||
// What the charger says about its own local side, when the cloud snapshot
|
||
// carries it: the address the Modbus mode has to be given by hand, discovered.
|
||
const ctlLocalAccess = computed(() => {
|
||
const local = dev.value.local;
|
||
if (!local?.modbusEnabled || !local.host) return "";
|
||
return local.port && local.port !== 502 ? `${local.host}:${local.port}` : local.host;
|
||
});
|
||
|
||
// Why nothing is connected yet, in the terms of the transport in force.
|
||
const ctlHintKey = computed(() => {
|
||
if (ctlIsModbus.value) return "charging.control.modbusHint";
|
||
if (ctlIsCloud.value) return "charging.control.cloudHint";
|
||
return "charging.control.connectHint";
|
||
});
|
||
|
||
// The charger pauses below 6 A rather than charging slowly, and the server
|
||
// refuses that case outright, so the slider does not offer it. The ceiling comes
|
||
// from the charger's own rating where it reports one.
|
||
const LIMIT_FLOOR = 6;
|
||
const limitCeiling = computed(() => Math.round(dev.value.maxCurrentA || 32));
|
||
|
||
// The timeout the charger falls back on its own strategy after. The spec's floor
|
||
// is "more than five seconds"; a minute of slack above it is a sane lower bound
|
||
// for a control that is set by hand.
|
||
const TIMEOUT_FLOOR = 6;
|
||
|
||
// The settings rows there is no register to write. Same labels and formatting as
|
||
// the readings card's settings block, minus the four that have controls above.
|
||
const deviceSettingsReported = computed(() => {
|
||
const s = dev.value;
|
||
const set = s.settings || {};
|
||
return rows([
|
||
["lastCommand", enumLabel("command", set.lastCommand)],
|
||
["chargingMode", enumLabel("chargingMode", s.chargingMode)],
|
||
["loadBalancing", yesNo(s.loadBalancing)],
|
||
["solarBalancing", yesNo(s.solarBalancing)],
|
||
["ledBrightness", isSet(s.ledBrightness) ? `${s.ledBrightness} %` : null],
|
||
]);
|
||
});
|
||
|
||
const deviceIdentity = computed(() => {
|
||
const s = dev.value;
|
||
return rows([
|
||
["model", s.model],
|
||
["serial", s.serial],
|
||
["firmware", s.firmware],
|
||
["controllerVersion", s.controllerVersion],
|
||
["hardware", s.hardware],
|
||
["productNumber", isSet(s.productNumber) ? String(s.productNumber) : null],
|
||
["ratedPower", unit(s.ratedPowerW, 0, "W")],
|
||
// The charger's own floor and ceiling. Either half on its own is still a
|
||
// bound the number beside the slider has to respect, so a charger that
|
||
// reports one and not the other says the one.
|
||
["currentRange", currentRange(s.minCurrentA, s.maxCurrentA)],
|
||
["ocppLink", enumLabel("ocpp", s.ocppStatus)],
|
||
["mqttLink", enumLabel("mqtt", s.mqttStatus)],
|
||
]);
|
||
});
|
||
|
||
// Everything the charger sends that none of the blocks above has a name for.
|
||
// Over the cloud a message carries more fields than this integration models, and
|
||
// some of them no published map names at all — those arrive keyed by the message
|
||
// and the field byte they came in ("0410.c9"). They are shown raw: no unit, no
|
||
// scaling, no translation, because a factor and a label are part of a meaning
|
||
// Anker does not publish. Named leftovers sort first, the byte-keyed ones after,
|
||
// so the readable half is not buried under hex.
|
||
const deviceExtra = computed(() => {
|
||
const extra = dev.value.extra || {};
|
||
return Object.keys(extra)
|
||
.sort((a, b) => a.includes(".") - b.includes(".") || a.localeCompare(b))
|
||
.map((key) => ({ key, value: String(extra[key]) }));
|
||
});
|
||
|
||
// The per-phase readings are a matrix, not a list: three phases against five
|
||
// measurements. A table says that; twenty labelled pairs hide it.
|
||
const devicePhases = computed(() => {
|
||
const s = dev.value;
|
||
const cell = (v, digits, u) => (isSet(v) ? `${Number(v).toFixed(digits)} ${u}` : "—");
|
||
const any = ["voltageL1", "currentL1", "powerL1"].some((k) => isSet(s[k]));
|
||
if (!any) return [];
|
||
return [1, 2, 3].map((n) => ({
|
||
phase: `L${n}`,
|
||
volts: cell(s[`voltageL${n}`], 1, "V"),
|
||
amps: cell(s[`currentL${n}`], 2, "A"),
|
||
watts: cell(s[`powerL${n}`], 0, "W"),
|
||
reactive: cell(s[`reactiveL${n}`], 0, "var"),
|
||
apparent: cell(s[`apparentL${n}`], 0, "VA"),
|
||
sessionWh: cell(s[`sessionWhL${n}`], 0, "Wh"),
|
||
}));
|
||
});
|
||
|
||
// The session's energy per phase is a cloud reading — a session is a cloud idea,
|
||
// and no register counts one — so the column joins the matrix when the charger
|
||
// reports it rather than standing as three dashes on the other transport.
|
||
const devicePhasesHaveSessionWh = computed(() => {
|
||
const s = dev.value;
|
||
return [1, 2, 3].some((n) => isSet(s[`sessionWhL${n}`]));
|
||
});
|
||
|
||
// Reactive and apparent power are registers of their own, and the cloud has no
|
||
// message carrying either — so on that transport the two columns could only ever
|
||
// be three dashes each. They appear when the charger actually reports them,
|
||
// which also covers a Modbus charger whose firmware leaves them out.
|
||
const devicePhasesHaveVA = computed(() => {
|
||
const s = dev.value;
|
||
return [1, 2, 3].some((n) => isSet(s[`reactiveL${n}`]) || isSet(s[`apparentL${n}`]));
|
||
});
|
||
|
||
// Line-to-line voltages only mean anything on a three-phase supply, so they are
|
||
// shown when the charger reports one rather than as three more zeroes.
|
||
const deviceLineVoltages = computed(() => {
|
||
const s = dev.value;
|
||
const pairs = [
|
||
["L1–L2", s.voltageL1L2],
|
||
["L2–L3", s.voltageL2L3],
|
||
["L3–L1", s.voltageL3L1],
|
||
].filter(([, v]) => isSet(v) && v > 10);
|
||
return pairs.map(([name, v]) => `${name} ${v.toFixed(1)} V`);
|
||
});
|
||
|
||
// The spec defers the alarm bits to a list it does not publish, so the words are
|
||
// shown as they arrive: which one is set is still the thing to report.
|
||
const modbusAlarms = computed(() => {
|
||
const s = dev.value;
|
||
if (!s.alarm || !Array.isArray(s.alarms)) return [];
|
||
return s.alarms
|
||
.map((w, i) => ({ n: i + 1, hex: "0x" + w.toString(16).toUpperCase().padStart(4, "0"), set: w !== 0 }))
|
||
.filter((w) => w.set);
|
||
});
|
||
|
||
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 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
|
||
// 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;
|
||
loadChargerDetails();
|
||
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(" · ");
|
||
}
|
||
|
||
// The service's own field names, given the names the rest of this card uses.
|
||
// Anker documents none of these, so only the fields whose meaning is plain from
|
||
// the value are named here — each says which group it belongs in and what to
|
||
// call it. A field whose meaning would be a guess stays out and keeps its own
|
||
// key in the box below, where the key is the only honest label it has.
|
||
const NAMED_ATTRS = {
|
||
alias_name: { group: "device", label: "nickname" },
|
||
product_code: { group: "device", label: "productCode" },
|
||
ms_device_type: { group: "device", label: "deviceType" },
|
||
charge: { group: "status", label: "charging", bool: true },
|
||
chargerStatus: { group: "status", label: "statusCode" },
|
||
ocpp_connect_status: { group: "status", label: "ocppLink" },
|
||
// The box on the wall is on two networks, and the service says more about
|
||
// both than the typed fields carry.
|
||
wifi_online: { group: "network", label: "wifiOnline", bool: true },
|
||
bt_ble_id: { group: "network", label: "bleId", sameAs: "bleMac" },
|
||
blue_password: { group: "network", label: "blePassword" },
|
||
owner_user_id: { group: "account", label: "ownerId" },
|
||
};
|
||
|
||
// Fields that repeat, in the service's own words, a row the card already draws:
|
||
// the same serial, the same firmware, the same Wi-Fi, the same picture. Shown
|
||
// twice they make the card longer without making it say more, so they are
|
||
// dropped instead.
|
||
const ECHOED_ATTRS = new Set([
|
||
"deviceName",
|
||
"device_name",
|
||
"deviceSn",
|
||
"device_sn",
|
||
"device_sw_version",
|
||
"img_url",
|
||
"link_time",
|
||
"rssi",
|
||
"time_zone",
|
||
"wifi_mac",
|
||
"wifi_name",
|
||
"bt_ble_mac",
|
||
]);
|
||
|
||
// relate_type arrives indexed — relate_type[0], relate_type[1] — and is the
|
||
// list the "Reachable by" row is built from.
|
||
function isEchoedAttr(key) {
|
||
return ECHOED_ATTRS.has(key) || key.startsWith("relate_type[");
|
||
}
|
||
|
||
// Two values that are the same fact written two ways: 7C:E9:13:73:C2:38 is the
|
||
// address 7CE91373C238 with colons in it.
|
||
function sameAttrValue(a, b) {
|
||
const norm = (v) => String(v ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
return norm(a) !== "" && norm(a) === norm(b);
|
||
}
|
||
|
||
// A flag the service sends as true/false or 1/0, read out in words. Anything
|
||
// else is relayed as it arrived rather than forced into a yes.
|
||
function attrBoolLabel(v) {
|
||
const s = String(v).toLowerCase();
|
||
if (s === "true" || s === "1") return t("common.yes");
|
||
if (s === "false" || s === "0") return t("common.no");
|
||
return v;
|
||
}
|
||
|
||
// The named fields that belong in one group, for a charger whose service sent
|
||
// them. Unlike the rows above, a field missing here draws nothing: these are one
|
||
// service's fields, and a row of dashes on a charger from another service would
|
||
// say a field is absent when it was never a field at all.
|
||
function namedAttrRows(groupId, attrs, live) {
|
||
return Object.entries(NAMED_ATTRS)
|
||
.filter(([key, spec]) => spec.group === groupId && isSet(attrs[key]) && attrs[key] !== "")
|
||
.filter(([key, spec]) => !(spec.sameAs && sameAttrValue(attrs[key], live[spec.sameAs])))
|
||
.map(([key, spec]) => ({
|
||
key,
|
||
label: t(`charging.info.${spec.label}`),
|
||
value: spec.bool ? attrBoolLabel(attrs[key]) : attrs[key],
|
||
}));
|
||
}
|
||
|
||
// Everything the card can say about one charger, in groups: what the box is,
|
||
// what it is doing, how it is connected, and how it sits on the account. Four
|
||
// short lists under headings, one box each the way the readings card draws its
|
||
// own, beat one long list nobody can find a field in. 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 chargerInfoGroups(c) {
|
||
const live = liveFor(c) || {};
|
||
const groups = [
|
||
["device", [
|
||
["vendor", c.vendor],
|
||
["model", c.model],
|
||
["firmware", live.firmware],
|
||
["serial", c.serial],
|
||
["power", c.powerKw ? `${c.powerKw} kW` : ""],
|
||
["connector", c.connector],
|
||
]],
|
||
["status", [
|
||
["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)],
|
||
]],
|
||
// The box on the wall, as opposed to the charging: the networks it is on.
|
||
["network", [
|
||
["wifiName", live.wifiName],
|
||
["wifiMac", live.wifiMac],
|
||
["signal", isSet(live.wifiRssi) ? `${live.wifiRssi} dBm` : ""],
|
||
["bleMac", live.bleMac],
|
||
["relatedBy", (live.relatedBy || []).join(" · ")],
|
||
]],
|
||
// Where it thinks it is, and how the account came to know it.
|
||
["account", [
|
||
["site", c.siteName || live.siteName],
|
||
["siteId", live.siteId],
|
||
["sources", sourcesLabel(live.sources)],
|
||
["timeZone", live.timeZone],
|
||
["linked", live.linkedAt ? formatDateTime(new Date(live.linkedAt * 1000)) : ""],
|
||
["providerId", c.providerChargerId],
|
||
["added", c.created ? formatDateTime(c.created) : ""],
|
||
]],
|
||
];
|
||
const attrs = live.attrs || {};
|
||
return groups.map(([id, rows]) => ({
|
||
id,
|
||
label: t(`charging.info.groups.${id}`),
|
||
rows: [
|
||
...rows.map(([key, value]) => ({
|
||
key,
|
||
label: t(`charging.info.${key}`),
|
||
value: value === "" || value == null ? "—" : value,
|
||
})),
|
||
// The fields the service sent under its own names that this group has a
|
||
// name for, after the ones DriverVault stores itself.
|
||
...namedAttrRows(id, attrs, live),
|
||
],
|
||
}));
|
||
}
|
||
|
||
// The keys the views below answer with, in the card's words. Anker documents
|
||
// none of these payloads either, but a field like page_size or create_time says
|
||
// what it is once its key is read out, and those are named here rather than left
|
||
// as keys. Keyed by the field with any list index taken out, so list[0].name and
|
||
// list[3].name are one field asked about two records. Anything absent from this
|
||
// table keeps its own key, for the same reason the box above does: a name
|
||
// invented here would be a meaning invented here.
|
||
const VIEW_FIELDS = {
|
||
// What the account has counted for this charger.
|
||
"total_stats.charge_count": "sessions",
|
||
"total_stats.charge_time": "chargeTime",
|
||
"total_stats.charge_total": "energy",
|
||
"total_stats.co2_saving": "co2Saved",
|
||
"total_stats.cost": "cost",
|
||
"total_stats.cost_saving": "costSaved",
|
||
"total_stats.cost_unit": "currency",
|
||
"total_stats.mile_age": "mileage",
|
||
// How much of a list the view answered with — a page of a history that is
|
||
// empty is still the answer "there is nothing to page through".
|
||
page: "page",
|
||
page_num: "page",
|
||
page_size: "perPage",
|
||
total: "records",
|
||
total_count: "records",
|
||
start_use_time: { label: "from", time: true },
|
||
// The backend the charger is pointed at, and when it last said so.
|
||
source: "source",
|
||
time_zone: "timeZone",
|
||
timestamp: { label: "updated", time: true },
|
||
create_time: { label: "added", time: true },
|
||
// The records a list view answers with: one OCPP endpoint, one RFID card.
|
||
"list[].address": "address",
|
||
"list[].name": "name",
|
||
"list[].source": "source",
|
||
"list[].alias_name": "cardName",
|
||
"list[].card_number": "cardNumber",
|
||
"list[].create_time": { label: "added", time: true },
|
||
// Who else the charger is shared with, one person per record.
|
||
email: "email",
|
||
device_sn: "serial",
|
||
member_id: "memberId",
|
||
member_type: "memberType",
|
||
user_id: "userId",
|
||
status: "status",
|
||
max_invite_members_count: "inviteLimit",
|
||
};
|
||
|
||
// The name a record carries for itself, in the order the views use one: an RFID
|
||
// card is its alias, an endpoint its name, a person their address.
|
||
const VIEW_ITEM_TITLES = ["alias_name", "name", "email"];
|
||
|
||
// list[0].card_number split into the list, which record, and which field.
|
||
const VIEW_LIST_KEY = /^([A-Za-z0-9_.]+)\[(\d+)\]\.(.+)$/;
|
||
|
||
// A unix second the cloud sent as a bare number, read as a date. Zero is how
|
||
// these views say "never" rather than 1970, so it is left as it arrived.
|
||
function viewTimeValue(v) {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n) || n <= 0) return v;
|
||
return formatDateTime(new Date(n * 1000));
|
||
}
|
||
|
||
// One field of one view: named when the table above has a name for it, and
|
||
// keyed by the cloud's own key when it does not. The key is kept either way —
|
||
// it is what the row is drawn under and what makes it unique.
|
||
function viewRow(key, lookup, value) {
|
||
// A field inside a list is looked up under its own list first and under the
|
||
// bare field name second, so an email is an email whichever list it came in.
|
||
const spec = VIEW_FIELDS[lookup] ?? VIEW_FIELDS[lookup.split("].").pop()];
|
||
const label = typeof spec === "string" ? spec : spec?.label;
|
||
return {
|
||
key,
|
||
label: label ? t(`charging.info.fields.${label}`) : key,
|
||
named: !!label,
|
||
value: typeof spec === "object" && spec?.time ? viewTimeValue(value) : value,
|
||
};
|
||
}
|
||
|
||
// One view's fields, with the lists it answered with grouped a record at a time.
|
||
// list[0].* and list[1].* are two RFID cards, two endpoints, two anything: read
|
||
// as one alphabetical run of indexed keys they are unreadable, and as a block
|
||
// each — under the record's own name, when it has one — they are the list the
|
||
// view actually sent. The record's name becomes the heading rather than a row,
|
||
// so it is said once.
|
||
function viewRowGroups(attrs) {
|
||
const rows = [];
|
||
const items = new Map();
|
||
for (const key of Object.keys(attrs).sort()) {
|
||
const m = VIEW_LIST_KEY.exec(key);
|
||
if (!m) {
|
||
rows.push(viewRow(key, key, attrs[key]));
|
||
continue;
|
||
}
|
||
const [, list, index, field] = m;
|
||
const id = `${list}[${index}]`;
|
||
if (!items.has(id)) {
|
||
const titleKey = VIEW_ITEM_TITLES.map((f) => `${id}.${f}`).find((k) => attrs[k]) || "";
|
||
const label = attrs[titleKey] || `#${Number(index) + 1}`;
|
||
items.set(id, { key: id, index: Number(index), label, titleKey, rows: [] });
|
||
}
|
||
const item = items.get(id);
|
||
if (key === item.titleKey) continue;
|
||
item.rows.push(viewRow(key, `${list}[].${field}`, attrs[key]));
|
||
}
|
||
return { rows, items: [...items.values()].sort((a, b) => a.index - b.index) };
|
||
}
|
||
|
||
// --- 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 };
|
||
// The account has just been asked; whatever a write read back is now the
|
||
// older answer of the two.
|
||
if (rfidWritten.value[sn]) {
|
||
const { [sn]: _dropped, ...rest } = rfidWritten.value;
|
||
rfidWritten.value = rest;
|
||
}
|
||
} 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. Note is the exception, and the server
|
||
// says which: a number the account elsewhere gives a meaning to, resolved.
|
||
//
|
||
// Every view the server asked for is drawn, the ones that answered with nothing
|
||
// included. A standalone charger has no station record and no site; that its
|
||
// site views are empty is the answer to "which of these does this account hold",
|
||
// and a box that quietly disappears cannot say it.
|
||
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 || "",
|
||
note: view.note || "",
|
||
...viewRowGroups(view.attrs || {}),
|
||
}));
|
||
});
|
||
|
||
// The RFID cards authorised on the charger, out of the view that answers with
|
||
// them. A card of its own: a list of physical cards somebody holds in their hand
|
||
// is worth more than a block in a diagnostic dump. The information card keeps
|
||
// relaying this view as well — it relays every view the account answers, and a
|
||
// hole in that list would be the one thing it cannot say.
|
||
const rfidView = computed(() => chargerDetailViews.value.find((v) => v.id === "rfid") || null);
|
||
|
||
// The cards on the charger being looked at, as objects rather than as the
|
||
// flattened keys the view answers with: this card needs a number to delete by,
|
||
// and a row of text is not a number. Both sources — the view and what a write
|
||
// read back — carry the same three fields, so one shape reads both.
|
||
const rfidWritten = ref({}); // serial → the list as the last write found it
|
||
|
||
const detailSn = computed(() => {
|
||
const c = selectedHomeCharger.value;
|
||
return c?.providerChargerId || c?.serial || "";
|
||
});
|
||
|
||
function rfidCardRow(c) {
|
||
const number = String(c.card_number ?? "").trim();
|
||
return {
|
||
number,
|
||
name: String(c.alias_name ?? "").trim() || number,
|
||
added: viewTimeValue(String(c.create_time ?? "")),
|
||
};
|
||
}
|
||
|
||
function rfidCardsFrom(attrs) {
|
||
const by = new Map();
|
||
for (const [key, value] of Object.entries(attrs || {})) {
|
||
const m = /^list\[(\d+)\]\.(alias_name|card_number|create_time)$/.exec(key);
|
||
if (!m) continue;
|
||
const i = Number(m[1]);
|
||
if (!by.has(i)) by.set(i, { index: i });
|
||
by.get(i)[m[2]] = value;
|
||
}
|
||
return [...by.values()].sort((a, b) => a.index - b.index).map(rfidCardRow);
|
||
}
|
||
|
||
// What the card draws: the list a write last read back when there is one, and
|
||
// the account's own view of it otherwise. A refresh drops the write's copy, so
|
||
// the server's answer is always what wins in the end.
|
||
const rfidCards = computed(() => {
|
||
const sn = detailSn.value;
|
||
const written = rfidWritten.value[sn];
|
||
if (written) return written.map(rfidCardRow);
|
||
const view = (chargerDetails.value[sn]?.views || []).find((v) => v.id === "rfid");
|
||
return rfidCardsFrom(view?.attrs);
|
||
});
|
||
|
||
const rfidBusy = ref(""); // the card being written, "new", or "scan" while the reader is open
|
||
const rfidCountdown = ref(0); // seconds left of the reader's window, while it is open
|
||
const rfidError = ref("");
|
||
const newCardNumber = ref("");
|
||
const newCardName = ref("");
|
||
|
||
// Adding a card, and then believing the list rather than the answer: Anker's
|
||
// write endpoint is undocumented, so a 200 from it proves nothing on its own.
|
||
// The card is only cleared out of the form once the account says it is there.
|
||
async function addRfidCard() {
|
||
const sn = detailSn.value;
|
||
const number = newCardNumber.value.trim();
|
||
if (!sn || !number || rfidBusy.value) return;
|
||
rfidBusy.value = "new";
|
||
rfidError.value = "";
|
||
try {
|
||
const res = await api.saveAnkerRfidCard(sn, number, newCardName.value.trim());
|
||
rfidWritten.value = { ...rfidWritten.value, [sn]: res?.cards || [] };
|
||
if (res?.present === false) {
|
||
rfidError.value = t("charging.rfid.notAdded");
|
||
} else {
|
||
newCardNumber.value = "";
|
||
newCardName.value = "";
|
||
}
|
||
} catch (e) {
|
||
rfidError.value = e.message;
|
||
} finally {
|
||
rfidBusy.value = "";
|
||
}
|
||
}
|
||
|
||
// Asking the charger to read a card, which is what the Anker app's second way of
|
||
// adding one does: the reader opens for twenty seconds, and whatever is held
|
||
// against it comes back as a number. Nothing is written by this — the card lands
|
||
// in the form, and adding it is still a decision.
|
||
async function scanRfidCard() {
|
||
const sn = detailSn.value;
|
||
if (!sn || rfidBusy.value) return;
|
||
rfidBusy.value = "scan";
|
||
rfidError.value = "";
|
||
rfidCountdown.value = 20;
|
||
const tick = setInterval(() => {
|
||
rfidCountdown.value = Math.max(0, rfidCountdown.value - 1);
|
||
}, 1000);
|
||
try {
|
||
const res = await api.scanAnkerRfidCard(sn);
|
||
if (res?.tapped && res.card) {
|
||
newCardNumber.value = res.card;
|
||
} else {
|
||
rfidError.value = t("charging.rfid.tapNone");
|
||
}
|
||
} catch (e) {
|
||
rfidError.value = e.message;
|
||
} finally {
|
||
clearInterval(tick);
|
||
rfidCountdown.value = 0;
|
||
rfidBusy.value = "";
|
||
}
|
||
}
|
||
|
||
// Removing one asks first — a card that is gone can only be put back by whoever
|
||
// still has it in their hand.
|
||
async function removeRfidCard(card) {
|
||
const sn = detailSn.value;
|
||
if (!sn || !card.number || rfidBusy.value) return;
|
||
if (!(await askConfirm(t("charging.rfid.removeConfirm", { name: card.name })))) return;
|
||
rfidBusy.value = card.number;
|
||
rfidError.value = "";
|
||
try {
|
||
const res = await api.deleteAnkerRfidCard(sn, card.number);
|
||
rfidWritten.value = { ...rfidWritten.value, [sn]: res?.cards || [] };
|
||
if (res?.present) rfidError.value = t("charging.rfid.notRemoved");
|
||
} catch (e) {
|
||
rfidError.value = e.message;
|
||
} finally {
|
||
rfidBusy.value = "";
|
||
}
|
||
}
|
||
|
||
// 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
|
||
// honest label, and translating or renaming one would be inventing a meaning
|
||
// for it. Sorted so the same charger reads the same way on every refresh.
|
||
function chargerAttrRows(c) {
|
||
const attrs = liveFor(c)?.attrs || {};
|
||
return Object.keys(attrs)
|
||
.filter((key) => !isEchoedAttr(key) && !NAMED_ATTRS[key])
|
||
.sort()
|
||
.map((key) => ({ key, value: attrs[key] }));
|
||
}
|
||
|
||
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;
|
||
syncSettingsDraft();
|
||
} 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 = "";
|
||
}
|
||
}
|
||
|
||
// Rebooting the charger. Two transports can: OCPP sends a reset, and the cloud
|
||
// sends the charger's own restart message — which is the only way to reboot a
|
||
// charger that is on neither a CSMS nor the local network. The register map has
|
||
// no such register, so the button is absent in Modbus mode rather than failing
|
||
// when pressed.
|
||
//
|
||
// Either way the server gates it behind an explicit confirmation AND a password
|
||
// re-authentication (step-up). Reveal the inline password prompt; the actual
|
||
// call happens in confirmReset().
|
||
const ctlCanRestart = computed(() => !ctlReadsDevice.value || ctlIsCloud.value);
|
||
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). They drag into
|
||
either order, like a car's tabs and the garage. -->
|
||
<p v-if="tabOrderError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ tabOrderError }}</p>
|
||
<div class="mb-6 flex gap-2 border-b border-subtle">
|
||
<button
|
||
v-for="tab in tabKeys"
|
||
:key="tab"
|
||
:draggable="canArrangeTabs"
|
||
:title="canArrangeTabs ? t('charging.tabs.dragHint') : ''"
|
||
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',
|
||
canArrangeTabs ? 'cursor-grab active:cursor-grabbing' : '',
|
||
dragTab === tab ? 'opacity-50' : '',
|
||
]"
|
||
@click="selectTab(tab)"
|
||
@dragstart="onTabDragStart(tab, $event)"
|
||
@dragenter="onTabDragEnter(tab)"
|
||
@dragover.prevent
|
||
@drop.prevent="commitTabOrder"
|
||
@dragend="commitTabOrder"
|
||
>
|
||
{{ 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">
|
||
<p
|
||
v-if="cardOrderError"
|
||
class="rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger"
|
||
style="order: -1"
|
||
>
|
||
{{ cardOrderError }}
|
||
</p>
|
||
<!-- Acting on the charger — first, because it is what the page is
|
||
opened for. It appears only once there is a connection to act
|
||
over, which the card below is where you set up. -->
|
||
<div
|
||
v-if="ctlActive && ctlConnected"
|
||
class="dh-card p-4 transition-shadow duration-150"
|
||
:class="dropCard === 'control' ? 'ring-2 ring-accent' : ''"
|
||
:style="{ order: cardOrder('control') }"
|
||
@dragenter.prevent="onCardDragEnter('control')"
|
||
@dragover.prevent
|
||
@drop.prevent="commitCardOrder"
|
||
>
|
||
<button
|
||
type="button"
|
||
:draggable="canArrangeCards"
|
||
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
|
||
class="flex w-full items-center justify-between gap-3 text-left"
|
||
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'control' ? 'opacity-50' : '']"
|
||
:aria-expanded="isOpen('control')"
|
||
@click="toggleCard('control')"
|
||
@dragstart="onCardDragStart('control', $event)"
|
||
@dragend="commitCardOrder"
|
||
>
|
||
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
|
||
<svg
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('control') ? '' : '-rotate-90'"
|
||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||
</button>
|
||
|
||
<div v-show="isOpen('control')">
|
||
|
||
<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 v-if="ctlPower" class="rounded-control bg-sunken px-3 py-2">
|
||
<div class="data text-sm font-semibold text-strong">{{ ctlPower }}</div>
|
||
<div class="text-[11px] text-muted">{{ t("charging.modbus.power") }}</div>
|
||
</div>
|
||
<div v-if="ctlSessionTime" class="rounded-control bg-sunken px-3 py-2">
|
||
<div class="data text-sm font-semibold text-strong">{{ ctlSessionTime }}</div>
|
||
<div class="text-[11px] text-muted">{{ t("charging.modbus.sessionDuration") }}</div>
|
||
</div>
|
||
<!-- A charger told to start can sit in "preparing" for a good while,
|
||
and these say why: it is waiting for a plug, or counting down a
|
||
start delay. Only the cloud path can see them. -->
|
||
<div v-if="ctlPlugCountdown" class="rounded-control bg-sunken px-3 py-2">
|
||
<div class="data text-sm font-semibold text-strong">{{ ctlPlugCountdown }}</div>
|
||
<div class="text-[11px] text-muted">{{ t("charging.modbus.plugCountdown") }}</div>
|
||
</div>
|
||
<div v-if="ctlStartCountdown" class="rounded-control bg-sunken px-3 py-2">
|
||
<div class="data text-sm font-semibold text-strong">{{ ctlStartCountdown }}</div>
|
||
<div class="text-[11px] text-muted">{{ t("charging.modbus.startCountdown") }}</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>
|
||
|
||
<!-- The limit, everywhere but the local path. There the settings card
|
||
owns it: it is the charger's own ceiling register, read back and
|
||
written, and the same slider in two cards was the same register
|
||
twice. Neither OCPP nor the cloud has a settings card to move it to
|
||
— a charging profile is not a setting the charger reports — so here
|
||
it stays. Clearing it is OCPP's alone: the cloud sets a ceiling and
|
||
has no message for "no ceiling". -->
|
||
<div v-if="!ctlIsModbus" 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="LIMIT_FLOOR"
|
||
:max="limitCeiling"
|
||
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>
|
||
<button
|
||
v-if="!ctlIsCloud"
|
||
class="dh-btn dh-btn-ghost grow"
|
||
:disabled="ctlBusy === 'clear-limit'"
|
||
@click="doAction('clear-limit')"
|
||
>
|
||
{{ t("charging.control.clearLimit") }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Boost lasts for the current session only, and is a command the
|
||
charger itself takes — over the register map or over the cloud,
|
||
but never over OCPP. -->
|
||
<button
|
||
v-if="ctlReadsDevice"
|
||
class="dh-btn dh-btn-ghost mt-3 w-full"
|
||
:disabled="ctlBusy === 'boost'"
|
||
@click="doAction('boost', { on: true })"
|
||
>
|
||
{{ t("charging.control.boost") }}
|
||
</button>
|
||
|
||
<!-- Skipping a start delay is only offered while one is running, and
|
||
only the cloud path knows that it is. -->
|
||
<button
|
||
v-if="ctlCanSkipDelay"
|
||
class="dh-btn dh-btn-ghost mt-3 w-full"
|
||
:disabled="ctlBusy === 'skip-delay'"
|
||
@click="doAction('skip-delay')"
|
||
>
|
||
{{ t("charging.control.skipDelay") }}
|
||
</button>
|
||
|
||
<!-- Rebooting the charger: an OCPP reset, or the cloud's own restart
|
||
message, which is the way to reach a charger that is on neither a
|
||
CSMS nor the LAN. No register does it, so Modbus is the one mode
|
||
without the button. -->
|
||
<button
|
||
v-if="ctlCanRestart && !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>
|
||
<!-- Over the cloud there is nothing to confirm it with: the charger
|
||
that would answer is the one rebooting. -->
|
||
<p v-if="ctlIsCloud" class="mt-1 text-[11px] text-danger">{{ t("charging.control.restartCloudHint") }}</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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- The cards that may start a charge without a phone. Directly under
|
||
the control card because it is the same question — who may use this
|
||
charger — asked of a person rather than of a button. It reads the
|
||
list the account holds; adding and removing them is the Anker app's
|
||
job, and the card says so rather than offering a button that cannot
|
||
work. -->
|
||
<div
|
||
v-if="selectedHomeCharger"
|
||
class="dh-card p-4 transition-shadow duration-150"
|
||
:class="dropCard === 'rfid' ? 'ring-2 ring-accent' : ''"
|
||
:style="{ order: cardOrder('rfid') }"
|
||
@dragenter.prevent="onCardDragEnter('rfid')"
|
||
@dragover.prevent
|
||
@drop.prevent="commitCardOrder"
|
||
>
|
||
<div class="flex items-center justify-between gap-2">
|
||
<button
|
||
type="button"
|
||
:draggable="canArrangeCards"
|
||
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
|
||
class="flex grow items-center gap-3 text-left"
|
||
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'rfid' ? 'opacity-50' : '']"
|
||
:aria-expanded="isOpen('rfid')"
|
||
@click="toggleCard('rfid')"
|
||
@dragstart="onCardDragStart('rfid', $event)"
|
||
@dragend="commitCardOrder"
|
||
>
|
||
<p class="text-sm font-semibold text-strong">{{ t("charging.rfid.title") }}</p>
|
||
<span v-if="rfidCards.length" class="dh-badge dh-badge-neutral">{{ rfidCards.length }}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="dh-btn dh-btn-ghost !px-2 !py-1 text-xs"
|
||
:disabled="chargerDetailsLoading !== ''"
|
||
@click="loadChargerDetails(true)"
|
||
>
|
||
{{ chargerDetailsLoading ? t("common.loading") : t("charging.info.refresh") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="flex shrink-0 items-center"
|
||
:aria-expanded="isOpen('rfid')"
|
||
:aria-label="t('charging.rfid.title')"
|
||
@click="toggleCard('rfid')"
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('rfid') ? '' : '-rotate-90'"
|
||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-show="isOpen('rfid')">
|
||
<p class="mt-1 text-[11px] text-muted">{{ selectedHomeCharger.name }}</p>
|
||
<p v-if="rfidError" class="mt-3 rounded-control bg-danger-soft px-3 py-2 text-xs font-medium text-danger">
|
||
{{ rfidError }}
|
||
</p>
|
||
<!-- A service that does not answer for cards, an account that may
|
||
not read them, and a charger with none on it are three different
|
||
answers, and each is said in its own words. -->
|
||
<p v-if="!rfidView && !rfidCards.length" class="mt-3 text-xs text-muted">{{ t("charging.rfid.unsupported") }}</p>
|
||
<p v-else-if="rfidView?.error && !rfidCards.length" class="mt-3 text-xs text-muted">{{ rfidView.error }}</p>
|
||
<p v-else-if="!rfidCards.length" class="mt-3 text-xs text-muted">{{ t("charging.rfid.none") }}</p>
|
||
<div v-else class="mt-3 flex flex-col gap-2">
|
||
<div v-for="card in rfidCards" :key="card.number" class="rounded-control bg-sunken p-3">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<p class="text-sm font-semibold text-strong">{{ card.name }}</p>
|
||
<button
|
||
type="button"
|
||
class="dh-btn dh-btn-ghost shrink-0 !px-2 !py-1 text-xs text-danger"
|
||
:disabled="rfidBusy !== ''"
|
||
@click="removeRfidCard(card)"
|
||
>
|
||
{{ rfidBusy === card.number ? t("common.loading") : t("charging.rfid.remove") }}
|
||
</button>
|
||
</div>
|
||
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||
<dt class="text-[11px] text-muted">{{ t("charging.info.fields.cardNumber") }}</dt>
|
||
<dd class="data break-all text-[11px] text-body">{{ card.number }}</dd>
|
||
<template v-if="card.added">
|
||
<dt class="text-[11px] text-muted">{{ t("charging.info.fields.added") }}</dt>
|
||
<dd class="data break-all text-[11px] text-body">{{ card.added }}</dd>
|
||
</template>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Adding one. The number is the card itself, so it is the only
|
||
field that is required; a card added without a name gets the one
|
||
the Anker app would have given it. -->
|
||
<form class="mt-3 rounded-control bg-sunken p-3" @submit.prevent="addRfidCard">
|
||
<p class="eyebrow">{{ t("charging.rfid.addTitle") }}</p>
|
||
<div class="mt-2 flex flex-col gap-2 sm:flex-row">
|
||
<input
|
||
v-model="newCardNumber"
|
||
class="dh-input data grow"
|
||
:placeholder="t('charging.rfid.numberPlaceholder')"
|
||
:aria-label="t('charging.info.fields.cardNumber')"
|
||
/>
|
||
<input
|
||
v-model="newCardName"
|
||
class="dh-input grow"
|
||
:placeholder="t('charging.rfid.namePlaceholder')"
|
||
:aria-label="t('charging.info.fields.cardName')"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
class="dh-btn dh-btn-primary shrink-0"
|
||
:disabled="!newCardNumber.trim() || rfidBusy !== ''"
|
||
>
|
||
{{ rfidBusy === "new" ? t("common.loading") : t("charging.rfid.add") }}
|
||
</button>
|
||
</div>
|
||
<!-- The other way to fill that field in: hold the card against the
|
||
charger. The reader opens for twenty seconds and the number
|
||
arrives on its own, which beats reading it off the card. -->
|
||
<button
|
||
type="button"
|
||
class="dh-btn dh-btn-ghost mt-2 w-full text-xs"
|
||
:disabled="rfidBusy !== ''"
|
||
@click="scanRfidCard"
|
||
>
|
||
{{ rfidBusy === "scan" ? t("charging.rfid.tapping", { n: rfidCountdown }) : t("charging.rfid.tap") }}
|
||
</button>
|
||
<p v-if="rfidBusy === 'scan'" class="mt-1 text-[11px] text-muted">{{ t("charging.rfid.tapHint") }}</p>
|
||
</form>
|
||
<p class="mt-3 text-[11px] text-muted">{{ t("charging.rfid.inferred") }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- What the charger is set to, as it reports it back. Its own card
|
||
under the control one: these are the values those buttons write, so
|
||
they are read right after pressing them, and they were buried at
|
||
the bottom of a long readings card. -->
|
||
<div
|
||
v-if="ctlIsModbus && ctlConnected && deviceSettings.length"
|
||
class="dh-card p-4 transition-shadow duration-150"
|
||
:class="dropCard === 'settings' ? 'ring-2 ring-accent' : ''"
|
||
:style="{ order: cardOrder('settings') }"
|
||
@dragenter.prevent="onCardDragEnter('settings')"
|
||
@dragover.prevent
|
||
@drop.prevent="commitCardOrder"
|
||
>
|
||
<button
|
||
type="button"
|
||
:draggable="canArrangeCards"
|
||
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
|
||
class="flex w-full items-center justify-between gap-3 text-left"
|
||
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'settings' ? 'opacity-50' : '']"
|
||
:aria-expanded="isOpen('settings')"
|
||
@click="toggleCard('settings')"
|
||
@dragstart="onCardDragStart('settings', $event)"
|
||
@dragend="commitCardOrder"
|
||
>
|
||
<p class="text-sm font-semibold text-strong">{{ t("charging.modbus.settingsTitle") }}</p>
|
||
<svg
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('settings') ? '' : '-rotate-90'"
|
||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||
</button>
|
||
|
||
<div v-show="isOpen('settings')">
|
||
<!-- Current limit. The slider says what it will do at the floor,
|
||
because 6 A is a pause and not a slow charge. -->
|
||
<div class="mt-3 rounded-control bg-sunken p-3">
|
||
<label class="dh-label flex justify-between">
|
||
<span>{{ t("charging.modbus.maxCurrentSet") }}</span>
|
||
<span class="data text-body">{{ draftAmps }} A</span>
|
||
</label>
|
||
<input
|
||
v-model.number="draftAmps"
|
||
type="range"
|
||
:min="LIMIT_FLOOR"
|
||
:max="limitCeiling"
|
||
step="1"
|
||
class="w-full accent-[var(--accent)]"
|
||
/>
|
||
<div class="mt-2 flex items-center gap-2">
|
||
<p class="grow text-[11px] text-muted">{{ t("charging.modbus.limitFloorHint", { amps: LIMIT_FLOOR }) }}</p>
|
||
<button
|
||
class="dh-btn dh-btn-ghost shrink-0"
|
||
:disabled="ctlBusy === 'limit'"
|
||
@click="doAction('limit', { amps: draftAmps })"
|
||
>
|
||
{{ t("charging.modbus.apply") }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Phase count and boost both write a single register, so they are
|
||
sent on the change itself rather than through an Apply. -->
|
||
<div class="mt-2 rounded-control bg-sunken p-3">
|
||
<label class="dh-label" for="phase-setting">{{ t("charging.modbus.phaseSetting") }}</label>
|
||
<select
|
||
id="phase-setting"
|
||
v-model.number="draftPhase"
|
||
class="dh-input"
|
||
:disabled="ctlBusy === 'phase'"
|
||
@change="doAction('phase', { phase: draftPhase })"
|
||
>
|
||
<option :value="0">{{ t("charging.modbus.phaseSet0") }}</option>
|
||
<option :value="1">{{ t("charging.modbus.phaseSet1") }}</option>
|
||
<option :value="2">{{ t("charging.modbus.phaseSet2") }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="mt-2 flex items-center justify-between gap-3 rounded-control bg-sunken p-3">
|
||
<div class="min-w-0">
|
||
<p class="text-xs font-semibold text-strong">{{ t("charging.modbus.boostSet") }}</p>
|
||
<p class="mt-0.5 text-[11px] text-muted">{{ t("charging.modbus.boostHint") }}</p>
|
||
</div>
|
||
<button
|
||
class="dh-btn shrink-0"
|
||
:class="boostOn ? 'dh-btn-primary' : 'dh-btn-ghost'"
|
||
:disabled="ctlBusy === 'boost'"
|
||
@click="doAction('boost', { on: !boostOn })"
|
||
>
|
||
{{ boostOn ? t("charging.modbus.turnOff") : t("charging.modbus.turnOn") }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- The charger falls back to its own strategy when nothing writes
|
||
within this, so it is a setting worth reaching. -->
|
||
<div class="mt-2 rounded-control bg-sunken p-3">
|
||
<label class="dh-label" for="control-timeout">{{ t("charging.modbus.timeout") }}</label>
|
||
<div class="flex gap-2">
|
||
<input
|
||
id="control-timeout"
|
||
v-model.number="draftSeconds"
|
||
type="number"
|
||
:min="TIMEOUT_FLOOR"
|
||
max="65535"
|
||
class="dh-input grow"
|
||
@keyup.enter="doAction('timeout', { seconds: draftSeconds })"
|
||
/>
|
||
<button
|
||
class="dh-btn dh-btn-ghost shrink-0"
|
||
:disabled="ctlBusy === 'timeout' || draftSeconds < TIMEOUT_FLOOR"
|
||
@click="doAction('timeout', { seconds: draftSeconds })"
|
||
>
|
||
{{ t("charging.modbus.apply") }}
|
||
</button>
|
||
</div>
|
||
<p class="mt-1 text-[11px] text-muted">{{ t("charging.modbus.timeoutHint", { n: TIMEOUT_FLOOR }) }}</p>
|
||
</div>
|
||
|
||
<!-- The rest of the settings block. The charger reports these, but
|
||
the register map has nothing to write them with. -->
|
||
<div v-if="deviceSettingsReported.length" class="mt-2 rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.modbus.settingsReported") }}</h4>
|
||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<template v-for="r in deviceSettingsReported" :key="r.label">
|
||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
<p class="mt-2 text-[11px] text-muted">{{ t("charging.modbus.settingsReportedHint") }}</p>
|
||
</div>
|
||
|
||
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Which charger, and how to reach it. Below the controls: it is
|
||
touched once and then left alone. -->
|
||
<div
|
||
v-if="ctlActive"
|
||
class="dh-card p-4 transition-shadow duration-150"
|
||
:class="dropCard === 'connection' ? 'ring-2 ring-accent' : ''"
|
||
:style="{ order: cardOrder('connection') }"
|
||
@dragenter.prevent="onCardDragEnter('connection')"
|
||
@dragover.prevent
|
||
@drop.prevent="commitCardOrder"
|
||
>
|
||
<button
|
||
type="button"
|
||
:draggable="canArrangeCards"
|
||
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
|
||
class="flex w-full items-center justify-between gap-3 text-left"
|
||
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'connection' ? 'opacity-50' : '']"
|
||
:aria-expanded="isOpen('connection')"
|
||
@click="toggleCard('connection')"
|
||
@dragstart="onCardDragStart('connection', $event)"
|
||
@dragend="commitCardOrder"
|
||
>
|
||
<p class="text-sm font-semibold text-strong">{{ t("charging.control.connectionTitle") }}</p>
|
||
<!-- Whether the charger is reachable is the one thing worth seeing
|
||
with the card folded, so the badge rides in the header. -->
|
||
<span class="flex shrink-0 items-center gap-2">
|
||
<span class="dh-badge" :class="ctlConnected ? 'dh-badge-success' : 'dh-badge-warning'">
|
||
{{ ctlConnected ? t("charging.control.connected") : t("charging.control.disconnected") }}
|
||
</span>
|
||
<svg
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('connection') ? '' : '-rotate-90'"
|
||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||
</span>
|
||
</button>
|
||
|
||
<div v-show="isOpen('connection')">
|
||
|
||
<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>
|
||
|
||
<!-- The cloud path has nothing to set up: the account is the
|
||
credential, and it is entered in Settings. What it does have to
|
||
say is what the charger reports about its own local side, which is
|
||
the address the Modbus mode would otherwise have to be told. -->
|
||
<div v-if="ctlIsCloud" class="mt-3 rounded-control bg-sunken p-3">
|
||
<p class="text-[11px] text-muted">{{ t("charging.control.cloudNote") }}</p>
|
||
<p v-if="ctlLocalAccess" class="data mt-2 text-[11px] text-body">
|
||
{{ t("charging.control.cloudLocalFound", { address: ctlLocalAccess }) }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Why there is nothing to control yet. Reading the charger directly,
|
||
the server has already tried and says what it found, which beats a
|
||
generic hint. -->
|
||
<p v-if="!ctlConnected" class="mt-3 text-xs text-muted">
|
||
{{ ctl?.detail || t(ctlHintKey) }}
|
||
</p>
|
||
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- What the charger reports about itself, over whichever transport
|
||
reads it. Its own card rather than a tail on the control one:
|
||
control is for acting on the charger, and this is a long read that
|
||
pushed the buttons off the screen. -->
|
||
<div
|
||
v-if="ctlReadsDevice && ctlConnected"
|
||
class="dh-card p-4 transition-shadow duration-150"
|
||
:class="dropCard === 'readings' ? 'ring-2 ring-accent' : ''"
|
||
:style="{ order: cardOrder('readings') }"
|
||
@dragenter.prevent="onCardDragEnter('readings')"
|
||
@dragover.prevent
|
||
@drop.prevent="commitCardOrder"
|
||
>
|
||
<button
|
||
type="button"
|
||
:draggable="canArrangeCards"
|
||
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
|
||
class="flex w-full items-center justify-between gap-3 text-left"
|
||
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'readings' ? 'opacity-50' : '']"
|
||
:aria-expanded="isOpen('readings')"
|
||
@click="toggleCard('readings')"
|
||
@dragstart="onCardDragStart('readings', $event)"
|
||
@dragend="commitCardOrder"
|
||
>
|
||
<p class="text-sm font-semibold text-strong">{{ t("charging.modbus.title") }}</p>
|
||
<svg
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('readings') ? '' : '-rotate-90'"
|
||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||
</button>
|
||
|
||
<div v-show="isOpen('readings')">
|
||
<div class="mt-3 flex flex-col gap-2">
|
||
<section v-if="devicePhases.length" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.modbus.phases") }}</h4>
|
||
<div class="mt-2 overflow-x-auto">
|
||
<table class="w-full text-xs">
|
||
<thead>
|
||
<tr class="text-muted">
|
||
<th class="py-1 text-left font-medium">{{ t("charging.modbus.phase") }}</th>
|
||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.voltage") }}</th>
|
||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.current") }}</th>
|
||
<th class="py-1 text-right font-medium">{{ t("charging.modbus.activePower") }}</th>
|
||
<th v-if="devicePhasesHaveVA" class="py-1 text-right font-medium">{{ t("charging.modbus.reactivePower") }}</th>
|
||
<th v-if="devicePhasesHaveVA" class="py-1 text-right font-medium">{{ t("charging.modbus.apparentPower") }}</th>
|
||
<th v-if="devicePhasesHaveSessionWh" class="py-1 text-right font-medium">{{ t("charging.modbus.sessionEnergy") }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="data">
|
||
<tr v-for="p in devicePhases" :key="p.phase" class="border-t border-subtle">
|
||
<td class="py-1 text-left text-muted">{{ p.phase }}</td>
|
||
<td class="py-1 text-right text-strong">{{ p.volts }}</td>
|
||
<td class="py-1 text-right text-strong">{{ p.amps }}</td>
|
||
<td class="py-1 text-right text-strong">{{ p.watts }}</td>
|
||
<td v-if="devicePhasesHaveVA" class="py-1 text-right text-strong">{{ p.reactive }}</td>
|
||
<td v-if="devicePhasesHaveVA" class="py-1 text-right text-strong">{{ p.apparent }}</td>
|
||
<td v-if="devicePhasesHaveSessionWh" class="py-1 text-right text-strong">{{ p.sessionWh }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p v-if="deviceLineVoltages.length" class="data mt-2 text-[11px] text-muted">
|
||
{{ t("charging.modbus.lineToLine") }}: {{ deviceLineVoltages.join(" · ") }}
|
||
</p>
|
||
</section>
|
||
|
||
<section v-if="deviceLive.length" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.modbus.live") }}</h4>
|
||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<template v-for="r in deviceLive" :key="r.label">
|
||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
</section>
|
||
|
||
<section v-if="deviceSettings.length" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.modbus.settings") }}</h4>
|
||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<template v-for="r in deviceSettings" :key="r.label">
|
||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
</section>
|
||
|
||
<!-- The charger's own LAN side, which only the cloud transport can
|
||
report: whether its Modbus server is on, and where. -->
|
||
<section v-if="deviceLocal.length" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.modbus.localTitle") }}</h4>
|
||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<template v-for="r in deviceLocal" :key="r.label">
|
||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
</section>
|
||
|
||
<section v-if="deviceIdentity.length" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.modbus.device") }}</h4>
|
||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<template v-for="r in deviceIdentity" :key="r.label">
|
||
<dt class="text-xs text-muted">{{ r.label }}</dt>
|
||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
</section>
|
||
|
||
<!-- What the charger sends beyond what we model. It appears only
|
||
when there is something in it, so a transport that reports
|
||
nothing unnamed draws no empty block. -->
|
||
<section v-if="deviceExtra.length" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.modbus.extra") }}</h4>
|
||
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
|
||
<template v-for="r in deviceExtra" :key="r.key">
|
||
<dt class="data break-all text-xs text-muted">{{ r.key }}</dt>
|
||
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
<p class="mt-2 text-[11px] text-muted">{{ t("charging.modbus.extraHint") }}</p>
|
||
</section>
|
||
|
||
<!-- Alarms, when any word is non-zero. Which register is set is
|
||
reportable even though the bit list is not published. -->
|
||
<section v-if="modbusAlarms.length" class="rounded-control border border-warning/40 bg-warning-soft p-3">
|
||
<h4 class="eyebrow" style="color: var(--warning-600)">{{ t("charging.modbus.alarms") }}</h4>
|
||
<p class="data mt-2 text-xs text-strong">
|
||
<span v-for="a in modbusAlarms" :key="a.n" class="mr-3 inline-block">
|
||
{{ t("charging.modbus.alarmWord", { n: a.n }) }} {{ a.hex }}
|
||
</span>
|
||
</p>
|
||
<p class="mt-1 text-[11px] text-muted">{{ t("charging.modbus.alarmsHint") }}</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</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 transition-shadow duration-150"
|
||
:class="dropCard === 'info' ? 'ring-2 ring-accent' : ''"
|
||
:style="{ order: cardOrder('info') }"
|
||
@dragenter.prevent="onCardDragEnter('info')"
|
||
@dragover.prevent
|
||
@drop.prevent="commitCardOrder"
|
||
>
|
||
<!-- The fold arrow belongs hard against the right edge, where every
|
||
other card puts it — a card with a header button must not be the
|
||
one whose arrow sits somewhere else. So the arrow is its own
|
||
control at the end of the row and the refresh button takes the
|
||
place beside it; both still fold the card, and the heading is
|
||
still the drag handle. -->
|
||
<div class="flex items-center justify-between gap-2">
|
||
<button
|
||
type="button"
|
||
:draggable="canArrangeCards"
|
||
:title="canArrangeCards ? t('charging.cards.dragHint') : ''"
|
||
class="flex grow items-center gap-3 text-left"
|
||
:class="[canArrangeCards ? 'cursor-grab active:cursor-grabbing' : '', dragCard === 'info' ? 'opacity-50' : '']"
|
||
:aria-expanded="isOpen('info')"
|
||
@click="toggleCard('info')"
|
||
@dragstart="onCardDragStart('info', $event)"
|
||
@dragend="commitCardOrder"
|
||
>
|
||
<p class="text-sm font-semibold text-strong">{{ t("charging.info.title") }}</p>
|
||
</button>
|
||
<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>
|
||
<button
|
||
type="button"
|
||
class="flex shrink-0 items-center"
|
||
:aria-expanded="isOpen('info')"
|
||
:aria-label="t('charging.info.title')"
|
||
@click="toggleCard('info')"
|
||
>
|
||
<svg
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||
class="h-4 w-4 shrink-0 text-muted transition-transform" :class="isOpen('info') ? '' : '-rotate-90'"
|
||
><path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" /></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-show="isOpen('info')">
|
||
<!-- The charger picked in the list beside this card, and only it. Its
|
||
fields are split into a box each, the way the readings card draws
|
||
its groups: one list of everything the service knows is harder to
|
||
find a field in than several short ones under headings. -->
|
||
<div v-if="selectedHomeCharger" class="mt-3 flex flex-col gap-2">
|
||
<div class="flex items-center justify-between gap-2">
|
||
<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. -->
|
||
<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>
|
||
<section
|
||
v-for="group in chargerInfoGroups(selectedHomeCharger)"
|
||
:key="group.id"
|
||
class="rounded-control bg-sunken p-3"
|
||
>
|
||
<h4 class="eyebrow">{{ group.label }}</h4>
|
||
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||
<template v-for="row in group.rows" :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>
|
||
</section>
|
||
|
||
<!-- The rest of what the service knows, in the service's own words.
|
||
It only appears when there is something in it, which is also how
|
||
a charger the cloud says nothing more about stays quiet. -->
|
||
<section v-if="chargerAttrRows(selectedHomeCharger).length" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t("charging.info.rawTitle") }}</h4>
|
||
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||
<template v-for="row in chargerAttrRows(selectedHomeCharger)" :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>
|
||
<p class="mt-2 text-[11px] text-muted">{{ t("charging.info.rawHint") }}</p>
|
||
</section>
|
||
|
||
<!-- The views that answer per charger rather than per account. Each
|
||
says what it knows, 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 — or that it
|
||
answered with nothing, which is equally an answer. -->
|
||
<section v-for="view in chargerDetailViews" :key="view.id" class="rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t(`charging.info.views.${view.id}`) }}</h4>
|
||
<p v-if="view.error" class="mt-2 text-[11px] text-muted">{{ view.error }}</p>
|
||
<p v-else-if="!view.rows.length && !view.items.length" class="mt-2 text-[11px] text-muted">
|
||
{{ t("charging.info.viewEmpty") }}
|
||
</p>
|
||
<div v-else class="mt-2 flex flex-col gap-2">
|
||
<!-- What the view says about itself. A field the card has a name
|
||
for is drawn like every other named row; one it does not is
|
||
drawn under its own key, in the key's own typeface, so the
|
||
two are never mistaken for each other. -->
|
||
<dl v-if="view.rows.length" class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||
<template v-for="row in view.rows" :key="row.key">
|
||
<dt class="break-all text-[11px] text-muted" :class="row.named ? '' : 'data'">{{ row.label }}</dt>
|
||
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
<!-- And the records it answered with, a block each under the
|
||
record's own name. -->
|
||
<div v-for="item in view.items" :key="item.key" class="rounded-control bg-card p-2">
|
||
<p class="text-[11px] font-semibold text-strong">{{ item.label }}</p>
|
||
<dl class="mt-1 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||
<template v-for="row in item.rows" :key="row.key">
|
||
<dt class="break-all text-[11px] text-muted" :class="row.named ? '' : 'data'">{{ row.label }}</dt>
|
||
<dd class="data break-all text-[11px] text-body">{{ row.value }}</dd>
|
||
</template>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
<!-- The one line in DriverVault's own words: a number this view
|
||
reports that another view gives an address. -->
|
||
<p v-if="view.note" class="data mt-2 text-[11px] text-muted">{{ view.note }}</p>
|
||
</section>
|
||
</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>
|
||
</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>
|