Both are a place on a short, known range, which is what the slider row added for the current limit is for. Typing 70 into a box that only accepts tens was the worse way to say it. The solar minimum gets no floor note under it. The current limit's says that below six amps the charger pauses rather than charging slowly, which is a sentence about a ceiling; this is the least a solar charge will draw, and the same words would be wrong about it. Main breaker limit stays a box. Ten to five hundred amps is too wide a range to aim at with a slider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3162 lines
145 KiB
Vue
3162 lines
145 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) || "—";
|
||
});
|
||
|
||
// And named for whichever of the two it is showing. The tile was called
|
||
// "Connector" from when OCPP was the only thing it read: an OCPP connector state
|
||
// is what that word means. Reading the charger's own snapshot it holds the
|
||
// charger's own status — the same statusDesc the readings card shows as
|
||
// "Charging status", so it takes that card's name for it rather than a second
|
||
// name for one value.
|
||
const ctlStatusTitle = computed(() =>
|
||
ctlReadsDevice.value ? t("charging.modbus.chargingStatus") : t("charging.control.status")
|
||
);
|
||
|
||
// The energy tile has the same two readings and the same problem: "Energy" is
|
||
// what an OCPP meter total is, and the charger's own snapshot counts this
|
||
// session's energy instead. Named from the same card as the status above it.
|
||
const ctlMeterTitle = computed(() =>
|
||
ctlReadsDevice.value ? t("charging.modbus.sessionEnergy") : t("charging.control.meter")
|
||
);
|
||
|
||
// --- 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;
|
||
syncMqttSettings();
|
||
}
|
||
|
||
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],
|
||
]);
|
||
});
|
||
|
||
// --- What the cloud transport can be told -------------------------------------
|
||
//
|
||
// Modbus has four writable registers. The cloud has the charger's whole settings
|
||
// group: everything the Anker app can set on it short of the card list. They are
|
||
// the same names a settings write takes and the same ones the snapshot reports
|
||
// them under, so every control here is seeded from the charger, edited, and sent
|
||
// back by name.
|
||
//
|
||
// A table rather than two dozen hand-written controls, because the charger's own
|
||
// commands own *sets* of fields: a command is taken whole, and a schedule that
|
||
// arrives carrying only its switch is a schedule whose times have just been set
|
||
// to midnight. A block is one write, and that stays true as fields are added to
|
||
// it only if the blocks are data.
|
||
//
|
||
// `at` is where the value is read back from in the snapshot - the settings
|
||
// object for most, the top level for the ones the charger reports outside it,
|
||
// and `local` for the Modbus server switch. `max: null` means the ceiling is the
|
||
// charger's own rating rather than a constant.
|
||
const MQTT_SETTING_BLOCKS = [
|
||
{
|
||
id: "charging",
|
||
title: "blockCharging",
|
||
fields: [
|
||
// A slider rather than a box, like the control card had and the Modbus
|
||
// settings card still has: it is the same value they set, and a ceiling
|
||
// is a thing you slide between two known ends rather than type.
|
||
{ key: "maxCurrentA", at: "settings.maxCurrentA", label: "maxCurrentSet", type: "slider", min: LIMIT_FLOOR, max: null, step: 1, unit: "A", hint: "limitFloorHint" },
|
||
{ key: "autoStart", at: "settings.autoStart", label: "autoStart", type: "switch" },
|
||
{ key: "randomDelay", at: "settings.randomDelay", label: "randomDelay", type: "switch" },
|
||
{ key: "plugLock", at: "settings.plugLock", label: "plugLock", type: "switch" },
|
||
{ key: "autoRestart", at: "settings.autoRestart", label: "autoRestart", type: "switch" },
|
||
],
|
||
},
|
||
{
|
||
id: "schedule",
|
||
title: "blockSchedule",
|
||
fields: [
|
||
{ key: "scheduleEnabled", at: "settings.scheduleEnabled", label: "scheduleEnabled", type: "switch" },
|
||
{ key: "scheduleMode", at: "settings.scheduleMode", label: "scheduleMode", type: "option", enum: "scheduleMode", values: [0, 1] },
|
||
{ type: "window", label: "scheduleWindow", from: "weekStart", to: "weekEnd", at: ["settings.weekStart", "settings.weekEnd"] },
|
||
{ key: "weekendMode", at: "settings.weekendMode", label: "weekendMode", type: "option", enum: "weekendMode", values: [1, 2] },
|
||
{ type: "window", label: "weekendWindow", from: "weekendStart", to: "weekendEnd", at: ["settings.weekendStart", "settings.weekendEnd"] },
|
||
],
|
||
},
|
||
{
|
||
id: "balancing",
|
||
title: "blockBalancing",
|
||
fields: [
|
||
{ key: "loadBalancing", at: "loadBalancing", label: "loadBalancing", type: "switch" },
|
||
{ key: "mainBreakerLimitA", at: "settings.mainBreakerLimitA", label: "mainBreakerLimit", type: "number", min: 10, max: 500, step: 1, unit: "A" },
|
||
],
|
||
},
|
||
{
|
||
id: "solar",
|
||
title: "blockSolar",
|
||
fields: [
|
||
{ key: "solarBalancing", at: "solarBalancing", label: "solarBalancing", type: "switch" },
|
||
{ key: "solarChargeMode", at: "settings.solarChargeMode", label: "solarChargeMode", type: "option", enum: "solarMode", values: [0, 1] },
|
||
// A slider, like the current limit it shares a floor with. No floor note
|
||
// under it though: this is the least a solar charge will draw, not a
|
||
// ceiling, so the limit slider's hint would be saying the wrong thing.
|
||
{ key: "solarMinCurrentA", at: "settings.solarMinCurrentA", label: "solarMinCurrent", type: "slider", min: LIMIT_FLOOR, max: 32, step: 1, unit: "A" },
|
||
// This command offers automatic and single-phase only. The three-phase
|
||
// setting is a Modbus register, and offering it here would be offering a
|
||
// write that comes back refused.
|
||
{ key: "phaseMode", at: "phaseMode", label: "phaseSetting", type: "option", enum: "phaseSet", values: [0, 1] },
|
||
{ key: "autoPhaseSwitching", at: "settings.autoPhaseSwitching", label: "autoPhaseSwitching", type: "switch" },
|
||
],
|
||
},
|
||
{
|
||
id: "panel",
|
||
title: "blockPanel",
|
||
fields: [
|
||
// A slider, like the current limit: a brightness is a place on a range,
|
||
// and typing 70 into a box that only takes tens is a worse way to say it.
|
||
{ key: "ledBrightness", at: "ledBrightness", label: "ledBrightness", type: "slider", min: 0, max: 100, step: 10, unit: "%" },
|
||
{ key: "lightOffSchedule", at: "settings.lightOffSchedule", label: "lightOff", type: "switch" },
|
||
{ type: "window", label: "lightOffWindow", from: "lightOffStart", to: "lightOffEnd", at: ["settings.lightOffStart", "settings.lightOffEnd"] },
|
||
{ key: "swipeUpMode", at: "swipeUpMode", label: "swipeUp", type: "option", enum: "gesture", values: [0, 1, 2, 3] },
|
||
{ key: "swipeDownMode", at: "swipeDownMode", label: "swipeDown", type: "option", enum: "gesture", values: [0, 1, 2, 3] },
|
||
{ key: "smartTouchMode", at: "smartTouchMode", label: "smartTouch", type: "option", enum: "touch", values: [0, 1] },
|
||
],
|
||
},
|
||
{
|
||
id: "local",
|
||
title: "blockLocal",
|
||
// The one setting here that can cost you a control mode: with the server off
|
||
// the charger stops answering on the LAN, and Modbus mode has nothing left
|
||
// to dial. Said next to the switch rather than after it has been thrown.
|
||
warning: "modbusOffWarning",
|
||
fields: [{ key: "modbusEnabled", at: "local.modbusEnabled", label: "modbusServer", type: "switch" }],
|
||
},
|
||
];
|
||
|
||
// A field is one setting, or for a window the two ends of one.
|
||
const fieldKeys = (f) => (f.type === "window" ? [f.from, f.to] : [f.key]);
|
||
const fieldEntries = (f) =>
|
||
f.type === "window"
|
||
? [
|
||
[f.from, f.at[0]],
|
||
[f.to, f.at[1]],
|
||
]
|
||
: [[f.key, f.at]];
|
||
|
||
const snapshotValue = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), dev.value);
|
||
|
||
// The number input's ceiling: the charger's own rating where the field's is null.
|
||
const fieldMax = (f) => (f.max === null ? limitCeiling.value : f.max);
|
||
|
||
// What the controls hold, and what the charger last said, so a block can tell
|
||
// whether it has anything to send and can be put back if it has not sent it.
|
||
const mqttDraft = ref({});
|
||
const mqttBase = ref({});
|
||
const mqttBusy = ref("");
|
||
|
||
// The settings the charger has reported, kept per serial across reads.
|
||
//
|
||
// The two halves of a snapshot arrive on different messages: the telemetry comes
|
||
// from the trigger, the settings only when the charger has something to say
|
||
// about itself. A read can land with the first and not the second — most often
|
||
// the first read after a reconnect, before any settings frame has arrived — and
|
||
// seeding the controls from that answer alone emptied the card of everything the
|
||
// charger had already told us. Which is not the state of the charger; it is the
|
||
// state of one message.
|
||
//
|
||
// So a value the charger has reported stays until it reports another. The values
|
||
// are its own last word either way, and the same ones the server fills a grouped
|
||
// command's siblings from when a caller leaves them out.
|
||
const mqttSeen = ref({}); // serial → the last value each setting was reported with
|
||
|
||
function syncMqttSettings() {
|
||
const sn = ctlSerial.value.trim();
|
||
const seen = { ...(mqttSeen.value[sn] || {}) };
|
||
for (const block of MQTT_SETTING_BLOCKS) {
|
||
for (const f of block.fields) {
|
||
for (const [key, at] of fieldEntries(f)) {
|
||
const v = snapshotValue(at);
|
||
if (isSet(v) && v !== "") seen[key] = v;
|
||
}
|
||
}
|
||
}
|
||
mqttSeen.value = { ...mqttSeen.value, [sn]: seen };
|
||
mqttDraft.value = { ...seen };
|
||
mqttBase.value = { ...seen };
|
||
}
|
||
|
||
// Only the settings the charger has actually reported get a control. A value it
|
||
// has not sent is one nothing here could seed a control from, and a blank box
|
||
// that writes whatever it was left at is worse than no box: several of these
|
||
// travel as siblings on one command, where an invented value is not ignored but
|
||
// applied.
|
||
const mqttSettingBlocks = computed(() => {
|
||
const base = mqttBase.value;
|
||
return MQTT_SETTING_BLOCKS.map((b) => ({
|
||
...b,
|
||
fields: b.fields.filter((f) => fieldKeys(f).every((k) => k in base)),
|
||
})).filter((b) => b.fields.length);
|
||
});
|
||
|
||
const blockDirty = (block) =>
|
||
block.fields.some((f) => fieldKeys(f).some((k) => mqttDraft.value[k] !== mqttBase.value[k]));
|
||
|
||
// An empty number box reads as NaN, which would travel as null and come back as
|
||
// a parse error from the server. The block simply cannot be applied until it
|
||
// holds a number again.
|
||
const blockValid = (block) =>
|
||
block.fields.every((f) => fieldKeys(f).every((k) => !Number.isNaN(mqttDraft.value[k])));
|
||
|
||
// The options a select offers: the ones this command accepts, plus whatever the
|
||
// charger actually reported if that is not among them. The phase field is why —
|
||
// the charger reports the phase it is *running* on, which can be the three-phase
|
||
// setting the solar command has no value for. Showing the reported value keeps
|
||
// the select from silently reading as something the charger did not say; sending
|
||
// it earns a refusal from the server, which is the honest outcome for a value
|
||
// this command cannot carry.
|
||
function fieldOptions(f) {
|
||
const reported = mqttBase.value[f.key];
|
||
return isSet(reported) && !f.values.includes(reported) ? [...f.values, reported] : f.values;
|
||
}
|
||
|
||
function resetMqttBlock(block) {
|
||
const draft = { ...mqttDraft.value };
|
||
for (const f of block.fields) {
|
||
for (const k of fieldKeys(f)) draft[k] = mqttBase.value[k];
|
||
}
|
||
mqttDraft.value = draft;
|
||
}
|
||
|
||
// Applying one block. The whole block goes, not only what changed: several of
|
||
// these are one command on the wire, and the charger takes a command as the new
|
||
// truth for every field it carries, so the siblings travel back with the change.
|
||
// The server would refill them from the charger's last report anyway; sending
|
||
// what is on screen means what is on screen is what gets written.
|
||
async function applyMqttBlock(block) {
|
||
const sn = ctlSerial.value.trim();
|
||
if (!sn || mqttBusy.value) return;
|
||
const settings = {};
|
||
for (const f of block.fields) {
|
||
for (const k of fieldKeys(f)) {
|
||
if (isSet(mqttDraft.value[k]) && mqttDraft.value[k] !== "") settings[k] = mqttDraft.value[k];
|
||
}
|
||
}
|
||
if (!Object.keys(settings).length) return;
|
||
mqttBusy.value = block.id;
|
||
ctlError.value = "";
|
||
try {
|
||
await api.ankerControlAction(sn, "settings", { settings });
|
||
// refreshCtl reseeds every control from the charger, so a value it clamped
|
||
// or refused shows as what it took rather than as what was asked for.
|
||
await refreshCtl();
|
||
} catch (e) {
|
||
ctlError.value = e.message;
|
||
} finally {
|
||
mqttBusy.value = "";
|
||
}
|
||
}
|
||
|
||
// What the cloud reports in the settings group but has no command to write: the
|
||
// meter and the monitor the two balancing features watch. The reference has not
|
||
// pinned down what the modes and the flag select, which is also why there is no
|
||
// control for them - a control would imply knowing what the values mean.
|
||
const mqttSettingsReported = computed(() => {
|
||
const s = dev.value;
|
||
return rows([
|
||
["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],
|
||
]);
|
||
});
|
||
|
||
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;
|
||
}
|
||
|
||
// --- The charger the buttons act on, as a picture and a name ------------------
|
||
//
|
||
// The control card drives whichever serial is in force, which is not always the
|
||
// record highlighted in the list beside it, so it identifies its charger by that
|
||
// serial rather than by the selection. Two sources carry the same product shot:
|
||
// the account's own charger list, and the live half held per provider. Either
|
||
// will do; the account's is the one that arrives without the home tab having
|
||
// been opened.
|
||
//
|
||
// It lives here rather than up with the other ctl* values because both of its
|
||
// sources do, and a lookup reads better next to the map it reads from.
|
||
function accountCharger(sn) {
|
||
if (!sn) return null;
|
||
return chargers.value.find((c) => c.sn === sn) || chargerLive.value[sn] || null;
|
||
}
|
||
|
||
const ctlImageUrl = computed(() => accountCharger(ctlSerial.value.trim())?.imageUrl || "");
|
||
const ctlChargerName = computed(() => accountCharger(ctlSerial.value.trim())?.name || "");
|
||
|
||
// A product shot is a URL from the service, and a URL can 404. Remembered per
|
||
// URL rather than per charger, so the picture simply stops being drawn and comes
|
||
// back on its own if the service starts answering for it again — no reset to
|
||
// forget when the serial changes.
|
||
const imageFailed = ref({});
|
||
|
||
// 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;
|
||
}
|
||
// The charger's own list was read against the account list that has just
|
||
// been replaced. Comparing it against the new one would be comparing two
|
||
// answers from different moments, so it is dropped and asked for again.
|
||
if (chargerCards.value[sn]) {
|
||
const { [sn]: _stale, ...kept } = chargerCards.value;
|
||
chargerCards.value = kept;
|
||
}
|
||
} 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 {
|
||
await writeCard(sn, number);
|
||
} catch (e) {
|
||
rfidError.value = e.message;
|
||
} finally {
|
||
rfidBusy.value = "";
|
||
}
|
||
}
|
||
|
||
// Writing one card and then reading the list back, which is the only thing that
|
||
// says whether the write landed. Shared by both ways of adding one, so the tap
|
||
// and the typed number cannot end up judging their answers differently.
|
||
//
|
||
// The name is whatever is in the box, and an empty box is not a missing name: it
|
||
// is the server's own convention — "RFID" and the card's last four digits — and
|
||
// leaving it to the server is what keeps the two ways of adding a card from
|
||
// drifting into two naming conventions.
|
||
async function writeCard(sn, number) {
|
||
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");
|
||
return false;
|
||
}
|
||
newCardNumber.value = "";
|
||
newCardName.value = "";
|
||
return true;
|
||
}
|
||
|
||
// The reader's own twenty seconds, as a value rather than as a side effect on
|
||
// the form: one caller wants the number in the box, the other wants to write it.
|
||
// The caller owns rfidBusy, so the tap-and-save button can hold it across the
|
||
// write that follows and nothing re-enables between the two.
|
||
async function readCardAtCharger(sn) {
|
||
rfidCountdown.value = 20;
|
||
const tick = setInterval(() => {
|
||
rfidCountdown.value = Math.max(0, rfidCountdown.value - 1);
|
||
}, 1000);
|
||
try {
|
||
const res = await api.scanAnkerRfidCard(sn);
|
||
return res?.tapped && res.card ? res.card : "";
|
||
} finally {
|
||
clearInterval(tick);
|
||
rfidCountdown.value = 0;
|
||
}
|
||
}
|
||
|
||
// 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 = "";
|
||
try {
|
||
const card = await readCardAtCharger(sn);
|
||
if (card) newCardNumber.value = card;
|
||
else rfidError.value = t("charging.rfid.tapNone");
|
||
} catch (e) {
|
||
rfidError.value = e.message;
|
||
} finally {
|
||
rfidBusy.value = "";
|
||
}
|
||
}
|
||
|
||
// The same tap, carried through to the end: the reader opens, and whatever is
|
||
// held against it is written without a second press. Enrolling a card happens at
|
||
// the charger with the card in your hand — the walk back to the keyboard to
|
||
// press Add was the whole cost of the two-step version.
|
||
//
|
||
// Nothing new is written by this that the two buttons above could not write
|
||
// between them; it is the same read and the same write, with nothing to do in
|
||
// between. The number lands in the box on the way past, so a write that fails
|
||
// leaves something to look at and retry rather than a card nobody can name.
|
||
async function tapAndSaveRfidCard() {
|
||
const sn = detailSn.value;
|
||
if (!sn || rfidBusy.value) return;
|
||
rfidBusy.value = "tapSave";
|
||
rfidError.value = "";
|
||
try {
|
||
const card = await readCardAtCharger(sn);
|
||
if (!card) {
|
||
rfidError.value = t("charging.rfid.tapNone");
|
||
return;
|
||
}
|
||
newCardNumber.value = card;
|
||
await writeCard(sn, card);
|
||
} catch (e) {
|
||
rfidError.value = e.message;
|
||
} finally {
|
||
rfidBusy.value = "";
|
||
}
|
||
}
|
||
|
||
// The charger's own list, asked of the device rather than of the account.
|
||
//
|
||
// Every add and remove writes both halves, and they can still come apart: the
|
||
// account write is inferred and the charger's is not, so either can be the one
|
||
// that landed. A card the account has forgotten still opens the charger until
|
||
// the device is told otherwise, and no other view on this page would say so.
|
||
// The device answers with numbers and no names — it has no field for one.
|
||
const chargerCards = ref({}); // serial → the numbers the device answered with
|
||
|
||
// Two numbers are the same card when they are the same hex; people and services
|
||
// write them with spaces, dashes or colons, and the charger writes them with
|
||
// none. The server normalizes what it stores, so this only has to agree with it.
|
||
function cardKey(number) {
|
||
return String(number || "").replace(/[^0-9A-Za-z]/g, "").toUpperCase();
|
||
}
|
||
|
||
const chargerCardList = computed(() => chargerCards.value[detailSn.value] || null);
|
||
|
||
// What the two lists disagree about, once the device has answered. Named from
|
||
// the list each card is missing from, because that is what has to be fixed:
|
||
// a card only on the charger opens it without the account knowing, and a card
|
||
// only on the account is one the charger will not open for.
|
||
const cardsOnlyOnCharger = computed(() => {
|
||
const held = chargerCardList.value;
|
||
if (!held) return [];
|
||
const account = new Set(rfidCards.value.map((c) => cardKey(c.number)));
|
||
return held.filter((n) => !account.has(n));
|
||
});
|
||
|
||
const cardsOnlyOnAccount = computed(() => {
|
||
const held = chargerCardList.value;
|
||
if (!held) return [];
|
||
return rfidCards.value.filter((c) => !held.includes(cardKey(c.number))).map((c) => c.number);
|
||
});
|
||
|
||
async function readChargerCards() {
|
||
const sn = detailSn.value;
|
||
if (!sn || rfidBusy.value) return;
|
||
rfidBusy.value = "charger";
|
||
rfidError.value = "";
|
||
try {
|
||
const res = await api.getAnkerChargerCards(sn);
|
||
// An empty answer is an answer — a charger with no cards on it — so the
|
||
// list is stored either way, and the card draws it rather than the button.
|
||
chargerCards.value = { ...chargerCards.value, [sn]: (res?.cards || []).map(cardKey) };
|
||
} catch (e) {
|
||
rfidError.value = e.message;
|
||
} finally {
|
||
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')">
|
||
|
||
<!-- Which charger these buttons act on. The card said nothing about
|
||
that before: the name is two cards further down, and the picture
|
||
is the fastest way to tell two chargers on one account apart. -->
|
||
<div
|
||
v-if="ctlImageUrl || ctlChargerName"
|
||
class="mt-3 flex items-center gap-3 rounded-control bg-sunken p-3"
|
||
>
|
||
<img
|
||
v-if="ctlImageUrl && !imageFailed[ctlImageUrl]"
|
||
:src="ctlImageUrl"
|
||
alt=""
|
||
class="h-16 w-16 shrink-0 rounded object-contain"
|
||
@error="imageFailed[ctlImageUrl] = true"
|
||
/>
|
||
<div class="min-w-0">
|
||
<p v-if="ctlChargerName" class="truncate text-sm font-semibold text-strong">{{ ctlChargerName }}</p>
|
||
<p class="data truncate text-[11px] text-muted">{{ ctlSerial }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<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">{{ ctlStatusTitle }}</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">{{ ctlMeterTitle }}</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, only where nothing else owns it. Both transports that
|
||
read the charger have a settings card now, and on both the limit is
|
||
the charger's own ceiling — the same register over Modbus, the same
|
||
wire field over the cloud, where "limit" and the maxCurrentA
|
||
setting are built from one table. The same slider in two cards was
|
||
that one value twice.
|
||
|
||
OCPP is the exception and keeps it: a charging profile is not a
|
||
setting the charger reports, so there is no settings card to move
|
||
it to. Clearing it is OCPP's alone too — the cloud sets a ceiling
|
||
and has no message for "no ceiling". -->
|
||
<div v-if="!ctlReadsDevice" 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>
|
||
|
||
<!-- What the charger itself holds. Everything above is the account's
|
||
copy; this asks the device, which is the half that actually
|
||
decides whether a card opens the charger. -->
|
||
<button
|
||
type="button"
|
||
class="dh-btn dh-btn-ghost mt-3 w-full text-xs"
|
||
:disabled="rfidBusy !== ''"
|
||
@click="readChargerCards"
|
||
>
|
||
{{ rfidBusy === "charger" ? t("common.loading") : t("charging.rfid.readCharger") }}
|
||
</button>
|
||
<div v-if="chargerCardList" class="mt-2 rounded-control bg-sunken p-3">
|
||
<p class="eyebrow">{{ t("charging.rfid.chargerTitle") }}</p>
|
||
<p v-if="!chargerCardList.length" class="mt-1 text-[11px] text-muted">
|
||
{{ t("charging.rfid.chargerNone") }}
|
||
</p>
|
||
<p v-else class="data mt-1 break-all text-[11px] text-body">{{ chargerCardList.join(", ") }}</p>
|
||
<!-- Only drawn when the two lists actually disagree: agreement is
|
||
the ordinary case and does not need saying twice. -->
|
||
<section
|
||
v-if="cardsOnlyOnCharger.length || cardsOnlyOnAccount.length"
|
||
class="mt-2 rounded-control border border-warning/40 bg-warning-soft p-3"
|
||
>
|
||
<h4 class="eyebrow" style="color: var(--warning-600)">{{ t("charging.rfid.driftTitle") }}</h4>
|
||
<p v-if="cardsOnlyOnCharger.length" class="mt-1 text-[11px] text-body">
|
||
{{ t("charging.rfid.onlyOnCharger", { cards: cardsOnlyOnCharger.join(", ") }) }}
|
||
</p>
|
||
<p v-if="cardsOnlyOnAccount.length" class="mt-1 text-[11px] text-body">
|
||
{{ t("charging.rfid.onlyOnAccount", { cards: cardsOnlyOnAccount.join(", ") }) }}
|
||
</p>
|
||
</section>
|
||
<p class="mt-2 text-[11px] text-muted">{{ t("charging.rfid.chargerHint") }}</p>
|
||
</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>
|
||
<!-- The same tap without the second press. Primary, because it is
|
||
the one somebody standing at the charger wants; the button
|
||
above stays for the times the number is wanted without the
|
||
card being added. -->
|
||
<button
|
||
type="button"
|
||
class="dh-btn dh-btn-primary mt-2 w-full text-xs"
|
||
:disabled="rfidBusy !== ''"
|
||
@click="tapAndSaveRfidCard"
|
||
>
|
||
{{ rfidBusy === "tapSave" ? t("charging.rfid.tapping", { n: rfidCountdown }) : t("charging.rfid.tapSave") }}
|
||
</button>
|
||
<p v-if="rfidBusy === 'scan' || rfidBusy === 'tapSave'" class="mt-1 text-[11px] text-muted">
|
||
{{ t("charging.rfid.tapHint") }}
|
||
</p>
|
||
<p v-else class="mt-1 text-[11px] text-muted">{{ t("charging.rfid.tapSaveHint") }}</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.
|
||
|
||
Both transports that read the charger can also be told things, so
|
||
both get this card - but they can be told very different things.
|
||
Modbus has four registers; the cloud has the charger's whole
|
||
settings group, which is why that half is drawn from a table. -->
|
||
<div
|
||
v-if="ctlConnected && ((ctlIsModbus && deviceSettings.length) || (ctlIsCloud && mqttSettingBlocks.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')">
|
||
<template v-if="ctlIsModbus">
|
||
<!-- 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>
|
||
</template>
|
||
|
||
<!-- The cloud's half. One section per block, and one write per
|
||
section: the charger takes a command whole, so its fields are
|
||
sent together and Apply is per block rather than per control. -->
|
||
<template v-else-if="ctlIsCloud">
|
||
<p class="mt-3 text-[11px] text-muted">{{ t("charging.modbus.cloudSettingsHint") }}</p>
|
||
|
||
<section v-for="block in mqttSettingBlocks" :key="block.id" class="mt-2 rounded-control bg-sunken p-3">
|
||
<h4 class="eyebrow">{{ t(`charging.modbus.${block.title}`) }}</h4>
|
||
<div class="mt-2 flex flex-col gap-2">
|
||
<template v-for="f in block.fields" :key="f.key || f.from">
|
||
<!-- A slider needs the width, so its row stacks: the label and
|
||
the value it is at on one line, the track under them. -->
|
||
<div v-if="f.type === 'slider'">
|
||
<label class="dh-label flex justify-between" :for="`set-${f.key}`">
|
||
<span>{{ t(`charging.modbus.${f.label}`) }}</span>
|
||
<span class="data text-body">{{ mqttDraft[f.key] }} {{ f.unit }}</span>
|
||
</label>
|
||
<input
|
||
:id="`set-${f.key}`"
|
||
v-model.number="mqttDraft[f.key]"
|
||
type="range"
|
||
:min="f.min"
|
||
:max="fieldMax(f)"
|
||
:step="f.step"
|
||
class="w-full accent-[var(--accent)]"
|
||
/>
|
||
<p v-if="f.hint" class="mt-1 text-[11px] text-muted">
|
||
{{ t(`charging.modbus.${f.hint}`, { amps: f.min }) }}
|
||
</p>
|
||
</div>
|
||
|
||
<div v-else class="flex items-center justify-between gap-3">
|
||
<label class="dh-label !mb-0 min-w-0 grow" :for="`set-${f.key || f.from}`">
|
||
{{ t(`charging.modbus.${f.label}`) }}
|
||
</label>
|
||
|
||
<!-- A switch reads as what it is set to, not as a verb: the
|
||
card is a form, and the button says the value it will
|
||
send rather than the action it would take. -->
|
||
<button
|
||
v-if="f.type === 'switch'"
|
||
:id="`set-${f.key}`"
|
||
type="button"
|
||
class="dh-btn shrink-0 !px-3 !py-1 text-xs"
|
||
:class="mqttDraft[f.key] ? 'dh-btn-primary' : 'dh-btn-ghost'"
|
||
:aria-pressed="!!mqttDraft[f.key]"
|
||
@click="mqttDraft[f.key] = !mqttDraft[f.key]"
|
||
>
|
||
{{ mqttDraft[f.key] ? t("common.yes") : t("common.no") }}
|
||
</button>
|
||
|
||
<select
|
||
v-else-if="f.type === 'option'"
|
||
:id="`set-${f.key}`"
|
||
v-model.number="mqttDraft[f.key]"
|
||
class="dh-input w-48 shrink-0"
|
||
>
|
||
<option v-for="v in fieldOptions(f)" :key="v" :value="v">{{ enumLabel(f.enum, v) }}</option>
|
||
</select>
|
||
|
||
<span v-else-if="f.type === 'number'" class="flex shrink-0 items-center gap-1">
|
||
<input
|
||
:id="`set-${f.key}`"
|
||
v-model.number="mqttDraft[f.key]"
|
||
type="number"
|
||
:min="f.min"
|
||
:max="fieldMax(f)"
|
||
:step="f.step"
|
||
class="dh-input w-24"
|
||
/>
|
||
<span class="text-[11px] text-muted">{{ f.unit }}</span>
|
||
</span>
|
||
|
||
<span v-else class="flex shrink-0 items-center gap-1">
|
||
<input
|
||
:id="`set-${f.from}`"
|
||
v-model="mqttDraft[f.from]"
|
||
type="time"
|
||
class="dh-input w-28"
|
||
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowStart')}`"
|
||
/>
|
||
<span class="text-[11px] text-muted">–</span>
|
||
<input
|
||
v-model="mqttDraft[f.to]"
|
||
type="time"
|
||
class="dh-input w-28"
|
||
:aria-label="`${t(`charging.modbus.${f.label}`)} — ${t('charging.modbus.windowEnd')}`"
|
||
/>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<p v-if="block.warning" class="mt-2 text-[11px]" style="color: var(--warning-600)">
|
||
{{ t(`charging.modbus.${block.warning}`) }}
|
||
</p>
|
||
|
||
<!-- Nothing to apply until something differs from what the
|
||
charger reported, so the button says so by being off. -->
|
||
<div class="mt-2 flex items-center justify-end gap-2">
|
||
<button
|
||
v-if="blockDirty(block)"
|
||
type="button"
|
||
class="dh-btn dh-btn-ghost !px-2 !py-1 text-xs"
|
||
:disabled="mqttBusy !== ''"
|
||
@click="resetMqttBlock(block)"
|
||
>
|
||
{{ t("charging.modbus.reset") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="dh-btn !px-3 !py-1 text-xs"
|
||
:class="blockDirty(block) ? 'dh-btn-primary' : 'dh-btn-ghost'"
|
||
:disabled="mqttBusy !== '' || !blockDirty(block) || !blockValid(block)"
|
||
@click="applyMqttBlock(block)"
|
||
>
|
||
{{ mqttBusy === block.id ? t("common.loading") : t("charging.modbus.apply") }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- The settings group's remainder: reported, and with no command
|
||
to write them. -->
|
||
<div v-if="mqttSettingsReported.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 mqttSettingsReported" :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.cloudSettingsReportedHint") }}</p>
|
||
</div>
|
||
</template>
|
||
|
||
<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>
|