Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:
- off monitoring only (default, unchanged behavior)
- own DriverVault is the charger's Central System (full control)
- proxy DriverVault relays to Anker's cloud and injects commands
New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.
The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.
Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
367113f538
commit
a1519f6e89
22 files changed
+2850
-11
No files matched your search
@@ -239,6 +239,20 @@ export const api = {
|
||||
saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }),
|
||||
testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }),
|
||||
|
||||
// Anker Solix OCPP control (per charger). getAnkerControl returns the control
|
||||
// mode, connection status, provisioning endpoint + token, and a live status
|
||||
// snapshot; ankerControlToken (re)generates the per-charger token the operator
|
||||
// installs into the charger; ankerControlAction issues one OCPP command
|
||||
// (start/stop/limit/clear-limit/availability/reset/unlock/trigger/config).
|
||||
getAnkerControl: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control`),
|
||||
ankerControlToken: (sn) =>
|
||||
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "POST" }),
|
||||
ankerControlAction: (sn, action, body = {}) =>
|
||||
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// Settings — advanced / danger zone
|
||||
exportData: () => requestBlob("/me/export"),
|
||||
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -47,6 +47,22 @@
|
||||
"idle": "Not charging",
|
||||
"idleHint": "Plug in at a station to start a session."
|
||||
},
|
||||
"control": {
|
||||
"title": "Charger control",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Offline",
|
||||
"serialPlaceholder": "Charger serial (e.g. A5191-XXXXXXXX)",
|
||||
"refresh": "Refresh",
|
||||
"status": "Connector",
|
||||
"meter": "Energy",
|
||||
"start": "Start charging",
|
||||
"stop": "Stop charging",
|
||||
"limit": "Current limit",
|
||||
"applyLimit": "Apply limit",
|
||||
"clearLimit": "Clear limit",
|
||||
"reset": "Reset charger",
|
||||
"connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations)."
|
||||
},
|
||||
"stations": {
|
||||
"heading": "Nearby",
|
||||
"count": {
|
||||
@@ -222,7 +238,23 @@
|
||||
"ankerEmail": "Anker account email",
|
||||
"ankerPassword": "Anker account password",
|
||||
"country": "Country",
|
||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US)."
|
||||
"countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US).",
|
||||
"controlMode": "Control mode",
|
||||
"controlModeHint": "How DriverVault controls the charger over OCPP.",
|
||||
"controlOff": "Off (monitoring only)",
|
||||
"controlOwn": "Own CSMS (full control)",
|
||||
"controlProxy": "Proxy CSMS (relay + control)",
|
||||
"controlTitle": "Charger control (OCPP)",
|
||||
"controlOwnHint": "The charger connects directly to DriverVault as its Central System. Point the charger's OCPP backend at the endpoint below.",
|
||||
"controlProxyHint": "DriverVault relays to Anker's cloud and can inject commands. Point the charger's OCPP backend at the endpoint below.",
|
||||
"controlProvisionSteps": "In the Anker app (or the charger's OCPP settings), set the OCPP backend URL to the endpoint and the authorization key to the token above.",
|
||||
"chargerSerial": "Charger serial",
|
||||
"controlCheck": "Check status",
|
||||
"controlGenerate": "Generate token",
|
||||
"controlEndpoint": "OCPP endpoint",
|
||||
"controlToken": "Auth token",
|
||||
"controlConnected": "Connected to control backend",
|
||||
"controlDisconnected": "Not connected"
|
||||
},
|
||||
|
||||
"privacy": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { t } from "../i18n";
|
||||
import { api } from "../api";
|
||||
|
||||
// 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
|
||||
@@ -43,6 +44,65 @@ const session = {
|
||||
const sessionMetrics = computed(() =>
|
||||
session.metrics.map(([key, value]) => ({ label: t(`charging.session.${key}`), value }))
|
||||
);
|
||||
|
||||
// --- Real OCPP control (Anker Solix), gated by the per-user control mode ---
|
||||
// The demo session card above is presentational; this card drives a real charger
|
||||
// via the control endpoints when the user has picked Own/Proxy CSMS in Settings.
|
||||
const ctlMode = ref("off");
|
||||
const ctlSerial = ref(localStorage.getItem("dv_ctl_serial") || "");
|
||||
const ctl = ref(null); // { connected, status, controlMode, ... }
|
||||
const ctlError = ref("");
|
||||
const ctlBusy = ref(""); // action name currently in flight
|
||||
const limitAmps = ref(16);
|
||||
|
||||
const ctlActive = computed(() => ctlMode.value !== "off");
|
||||
const ctlConnected = computed(() => !!ctl.value?.connected);
|
||||
const ctlMeterKwh = computed(() => ((ctl.value?.status?.meterWh || 0) / 1000).toFixed(2));
|
||||
|
||||
async function loadCtlMode() {
|
||||
try {
|
||||
const v = await api.getAnkerSolix();
|
||||
ctlMode.value = v?.controlMode || "off";
|
||||
} catch {
|
||||
ctlMode.value = "off";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (e) {
|
||||
ctlError.value = e.message;
|
||||
ctl.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCtlMode();
|
||||
await refreshCtl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,7 +165,65 @@ const sessionMetrics = computed(() =>
|
||||
|
||||
<!-- Right column -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Active session -->
|
||||
<!-- Real OCPP control — only when a control mode (Own/Proxy CSMS) is active -->
|
||||
<div v-if="ctlActive" class="dh-card p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-semibold text-strong">{{ t("charging.control.title") }}</p>
|
||||
<span class="dh-badge" :class="ctlConnected ? 'dh-badge-success' : 'dh-badge-warning'">
|
||||
{{ ctlConnected ? t("charging.control.connected") : t("charging.control.disconnected") }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<input v-model="ctlSerial" class="dh-input" :placeholder="t('charging.control.serialPlaceholder')" autocomplete="off" />
|
||||
<button class="dh-btn dh-btn-ghost shrink-0" @click="refreshCtl">{{ t("charging.control.refresh") }}</button>
|
||||
</div>
|
||||
|
||||
<template v-if="ctlConnected">
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-control bg-sunken px-3 py-2">
|
||||
<div class="data text-sm font-semibold text-strong">{{ ctl.status?.connectorStatus || "—" }}</div>
|
||||
<div class="text-[11px] text-muted">{{ t("charging.control.status") }}</div>
|
||||
</div>
|
||||
<div class="rounded-control bg-sunken px-3 py-2">
|
||||
<div class="data text-sm font-semibold text-strong">{{ ctlMeterKwh }} kWh</div>
|
||||
<div class="text-[11px] text-muted">{{ t("charging.control.meter") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button class="dh-btn dh-btn-primary grow" :disabled="ctlBusy === 'start'" @click="doAction('start')">
|
||||
{{ t("charging.control.start") }}
|
||||
</button>
|
||||
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'stop'" @click="doAction('stop')">
|
||||
{{ t("charging.control.stop") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="dh-label flex justify-between">
|
||||
<span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span>
|
||||
</label>
|
||||
<input v-model.number="limitAmps" type="range" min="6" max="32" step="1" class="w-full accent-[var(--accent)]" />
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'limit'" @click="doAction('limit', { amps: limitAmps })">
|
||||
{{ t("charging.control.applyLimit") }}
|
||||
</button>
|
||||
<button class="dh-btn dh-btn-ghost grow" :disabled="ctlBusy === 'clear-limit'" @click="doAction('clear-limit')">
|
||||
{{ t("charging.control.clearLimit") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="dh-btn dh-btn-ghost mt-3 w-full" :disabled="ctlBusy === 'reset'" @click="doAction('reset', { hard: false })">
|
||||
{{ t("charging.control.reset") }}
|
||||
</button>
|
||||
</template>
|
||||
<p v-else class="mt-3 text-xs text-muted">{{ t("charging.control.connectHint") }}</p>
|
||||
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 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)">
|
||||
|
||||
@@ -405,7 +405,7 @@ async function testToyotaConnection() {
|
||||
|
||||
const anker = ref(null); // resolved view from the server
|
||||
const ankerScope = ref("user"); // "user" | "org" (org admins only)
|
||||
const ankerForm = ref({ email: "", password: "", country: "" });
|
||||
const ankerForm = ref({ email: "", password: "", country: "", controlMode: "off" });
|
||||
const ankerSaving = ref(false);
|
||||
const ankerSaved = ref(false);
|
||||
const ankerError = ref("");
|
||||
@@ -440,9 +440,46 @@ function fillAnkerForm() {
|
||||
email: f.email?.locked ? "" : f.email?.own || "",
|
||||
password: "",
|
||||
country: f.country?.locked ? "" : f.country?.own || "",
|
||||
// controlMode isn't secret, so show the effective value when it's locked.
|
||||
controlMode: f.controlMode?.locked ? f.controlMode?.effective || "off" : f.controlMode?.own || "off",
|
||||
};
|
||||
}
|
||||
|
||||
// Effective OCPP control mode (off | own | proxy) — gates the control panel.
|
||||
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
|
||||
|
||||
// --- Anker Solix OCPP control (per-charger provisioning + connection status) ---
|
||||
const ankerCtlSerial = ref("");
|
||||
const ankerCtl = ref(null); // { endpoint, token, connected, status, ... }
|
||||
const ankerCtlLoading = ref(false);
|
||||
const ankerCtlError = ref("");
|
||||
|
||||
async function loadAnkerControl() {
|
||||
const sn = ankerCtlSerial.value.trim();
|
||||
if (!sn) return;
|
||||
ankerCtlError.value = "";
|
||||
ankerCtlLoading.value = true;
|
||||
try {
|
||||
ankerCtl.value = await api.getAnkerControl(sn);
|
||||
} catch (e) {
|
||||
ankerCtlError.value = e.message;
|
||||
} finally {
|
||||
ankerCtlLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAnkerToken() {
|
||||
const sn = ankerCtlSerial.value.trim();
|
||||
if (!sn) return;
|
||||
ankerCtlError.value = "";
|
||||
try {
|
||||
await api.ankerControlToken(sn);
|
||||
await loadAnkerControl();
|
||||
} catch (e) {
|
||||
ankerCtlError.value = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function applyAnkerView(body) {
|
||||
anker.value = body;
|
||||
if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user";
|
||||
@@ -479,7 +516,7 @@ async function saveAnkerSettings() {
|
||||
ankerSaving.value = true;
|
||||
ankerSaved.value = false;
|
||||
const config = {};
|
||||
for (const k of ["email", "password", "country"]) {
|
||||
for (const k of ["email", "password", "country", "controlMode"]) {
|
||||
if (ankerLocked(k)) continue;
|
||||
if (k === "password" && !ankerForm.value.password) continue;
|
||||
config[k] = ankerForm.value[k];
|
||||
@@ -1045,6 +1082,16 @@ onBeforeUnmount(() => {
|
||||
<p v-if="ankerField('country').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('country') }}</p>
|
||||
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.countryHint") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">{{ t("settings.integrations.controlMode") }}</label>
|
||||
<select v-model="ankerForm.controlMode" class="dh-input" :disabled="ankerLocked('controlMode')">
|
||||
<option value="off">{{ t("settings.integrations.controlOff") }}</option>
|
||||
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
|
||||
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
|
||||
</select>
|
||||
<p v-if="ankerField('controlMode').locked" class="mt-1 text-xs text-muted">{{ ankerSourceLabel('controlMode') }}</p>
|
||||
<p v-else class="mt-1 text-xs text-muted">{{ t("settings.integrations.controlModeHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
@@ -1068,6 +1115,46 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
{{ ankerHealth.detail || ankerHealth.status }}
|
||||
</p>
|
||||
|
||||
<!-- OCPP control provisioning (only when a control mode is active) -->
|
||||
<div v-if="ankerControlMode !== 'off'" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">
|
||||
<p class="text-sm font-semibold text-strong">{{ t("settings.integrations.controlTitle") }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ ankerControlMode === 'own' ? t("settings.integrations.controlOwnHint") : t("settings.integrations.controlProxyHint") }}
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-end gap-2">
|
||||
<div class="grow">
|
||||
<label class="dh-label">{{ t("settings.integrations.chargerSerial") }}</label>
|
||||
<input v-model="ankerCtlSerial" class="dh-input" placeholder="A5191-XXXXXXXX" autocomplete="off" />
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-ghost" :disabled="!ankerCtlSerial.trim() || ankerCtlLoading" @click="loadAnkerControl">
|
||||
{{ ankerCtlLoading ? t("settings.integrations.testing") : t("settings.integrations.controlCheck") }}
|
||||
</button>
|
||||
<button class="dh-btn dh-btn-primary" :disabled="!ankerCtlSerial.trim()" @click="generateAnkerToken">
|
||||
{{ t("settings.integrations.controlGenerate") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ankerCtl" class="mt-3 grid gap-2 text-sm">
|
||||
<div>
|
||||
<span class="text-muted">{{ t("settings.integrations.controlEndpoint") }}: </span>
|
||||
<code class="data break-all text-body">{{ ankerCtl.endpoint }}</code>
|
||||
</div>
|
||||
<div v-if="ankerCtl.token">
|
||||
<span class="text-muted">{{ t("settings.integrations.controlToken") }}: </span>
|
||||
<code class="data break-all text-body">{{ ankerCtl.token }}</code>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="dh-badge" :class="ankerCtl.connected ? 'dh-badge-success' : 'dh-badge-warning'">
|
||||
{{ ankerCtl.connected ? t("settings.integrations.controlConnected") : t("settings.integrations.controlDisconnected") }}
|
||||
</span>
|
||||
<span v-if="ankerCtl.status?.connectorStatus" class="text-muted">{{ ankerCtl.status.connectorStatus }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted">{{ t("settings.integrations.controlProvisionSteps") }}</p>
|
||||
</div>
|
||||
<p v-if="ankerCtlError" class="mt-2 text-sm text-danger">{{ ankerCtlError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="ankerError" class="mt-2 text-sm text-danger">{{ ankerError }}</p>
|
||||
|
||||
Reference in new issue
Block a user