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:
tajniak81andClaude Opus 4.8 committed 2026-07-18 16:40:22 +02:00
1 parent 367113f538
commit a1519f6e89
22 files changed
+2850 -11

No files matched your search

+14
View File
@@ -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) }),
+33 -1
View File
@@ -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": {
+120 -2
View File
@@ -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)">
+89 -2
View File
@@ -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>