Go the way the owner's phone already goes

Control had two transports and neither fitted the ordinary customer. OCPP waits
for the charger to dial in, which needs a public endpoint it can reach, a
certificate, and a firmware willing to talk to our CSMS. Modbus TCP dials the
charger, which needs the server on the charger's own network. Between them they
cover a charger we host and a charger we stand next to; the common case is a
charger behind someone else's router, and that had nothing.

It was never unreachable, though. The charger holds a connection open to Anker's
own broker — it is how the mobile app drives it from anywhere, and it is the
mqttStatus register the Modbus snapshot has been reporting all along. So a third
control mode joins that broker as the account: get_user_mqtt_info issues a client
certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same
topics the app publishes on. Nothing on the customer's side has to be forwarded,
addressed or certificated.

What travels is not an API call. The payload is a JSON envelope around a base64
binary frame the device itself speaks — marker, little-endian length, message
type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec
rather than a client, written from the message maps in anker-solix-api and
anchored on the one frame that project documents byte for byte. A frame whose
fields do not tile exactly up to the checksum is refused rather than half-read:
these arrive over a link we do not control, and a truncated frame must not read
as a charger reporting zeros.

Two of the charger's habits shape the rest. It publishes nothing unless asked, so
a status read arms a telemetry trigger and waits for the next frame, and a poll
inside that window answers from what has since arrived. And a broker connection
costs a fetched certificate and a TLS handshake while the plugin manager builds a
throwaway instance per request — so the connection lives on the account's shared
session beside the auth token, for exactly the reason the token lives there, and
closes itself after five idle minutes.

The transport also sees two signals no other one does: the boost flag, and the
plug and start countdowns. The package doc has said since the first commit that
they are never set and the derived mode must do without them. Here they are set,
so a charger that has been told to start and is counting down a delay says so
rather than sitting in "preparing", and "skip the delay" is offered only while
there is a delay to skip.

The clients generalise instead of growing a second layout. Both snapshots name
the same quantities the same way, so what was Modbus-only in the readouts is now
whichever transport read the charger — ModbusStatus becomes ChargerStatus on the
phone, mb becomes dev on the web. What each transport can be *told* still
differs, and the buttons branch on that: reset and clear-limit stay with OCPP,
the timeout and phase registers with Modbus, skip-delay with the cloud. A command
a transport has no equivalent for is refused by name, saying which one has it.

The cost is worth saying plainly. This leans on Anker's cloud being up and on an
unofficial protocol the app may change under us, where Modbus leans on nothing
but the LAN. And it is checked against the reference implementation's own worked
example rather than against hardware — there is no charger on this end to point
it at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-09-02 16:47:10 +02:00
1 parent b27ca3ee19
commit 576df58776
28 files changed
+3186 -171

No files matched your search

+11 -7
View File
@@ -332,13 +332,15 @@ export const api = {
// Anker Solix control (per charger), over whichever transport the user's
// control mode selects. getAnkerControl returns the control mode, connection
// status, and a live status snapshot — an OCPP session snapshot in own/proxy
// mode, a Modbus register snapshot in modbus mode.
// mode, the charger's own snapshot in modbus and mqtt mode.
//
// The two modes are provisioned differently, and each has its own pair here:
// OCPP needs a token the operator installs into the charger (ankerControlToken
// / ankerControlRevoke), Modbus needs the charger's address on the local
// network (ankerControlAddress / ankerControlForgetAddress). A charger may
// hold both; setting one leaves the other alone.
// The modes are provisioned differently. OCPP needs a token the operator
// installs into the charger (ankerControlToken / ankerControlRevoke); Modbus
// needs the charger's address on the local network (ankerControlAddress /
// ankerControlForgetAddress); the Anker cloud mode needs neither, because it
// signs in as the account and reaches the charger through Anker's own broker —
// which is why it is the mode for a charger the server cannot route to. A
// charger may hold both bindings; setting one leaves the other alone.
getAnkerControl: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control`),
ankerControlToken: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "POST" }),
@@ -353,7 +355,9 @@ export const api = {
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/address`, { method: "DELETE" }),
// One control command. Over OCPP: start, stop, limit, clear-limit,
// availability, reset, unlock, trigger, config. Over Modbus TCP: start, stop,
// limit, boost, phase, timeout, status.
// limit, boost, phase, timeout, status. Over the Anker cloud: start, stop,
// limit, boost, skip-delay, status. A command a transport cannot send is
// refused by name, saying which transport can.
ankerControlAction: (sn, action, body = {}) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/${action}`, {
method: "POST",
+15
View File
@@ -136,6 +136,11 @@
"lineToLine": "Mellem faser",
"power": "Samlet effekt",
"sessionDuration": "Sessionens længde",
"mode": "Tilstand",
"plugCountdown": "Venter på stik",
"startCountdown": "Starter om",
"autoStart": "Start automatisk",
"scheduleWindow": "Ladevindue",
"chargingStatus": "Ladestatus",
"sessionEnergy": "Sessionsenergi",
"cpSignal": "Control pilot",
@@ -410,10 +415,12 @@
"controlOff": "Fra (kun overvågning)",
"controlOwn": "Eget CSMS (fuld styring)",
"controlProxy": "Proxy-CSMS (videresendelse + styring)",
"controlCloud": "Anker-sky (virker overalt)",
"controlModbus": "Modbus TCP (lokalt netværk)",
"controlTitle": "Laderstyring (OCPP)",
"controlOwnHint": "Laderen forbinder direkte til DriverVault som sit centralsystem. Peg laderens OCPP-backend på endepunktet nedenfor.",
"controlProxyHint": "DriverVault videresender til Ankers sky og kan indsætte kommandoer. Peg laderens OCPP-backend på endepunktet nedenfor.",
"controlCloudHint": "DriverVault sender kommandoer gennem din Anker-kontos egen skyforbindelse til laderen, så laderen slet ikke behøver at kunne nås: intet at videresende, ingen adresse, intet token. Det er tilstanden til en lader på et andet netværk. Den virker kun, så længe Ankers sky gør.",
"controlModbusHint": "DriverVault kontakter selv laderen på dit lokale netværk, så der er ingen adresse at pege laderen mod her. Slå Modbus TCP til i Anker-appen, og indtast den adresse, den viser, ved siden af laderens betjening på Opladning-siden.",
"controlProvisionSteps": "I Anker-appen (eller laderens OCPP-indstillinger) sættes OCPP-backend-URL'en til endepunktet og autorisationsnøglen til tokenet ovenfor.",
"chargerSerial": "Laderens serienummer",
@@ -435,6 +442,14 @@
"chargerOnline": "Online",
"chargerOffline": "Offline",
"chargerUse": "Brug denne",
"modes": {
"start_charge": "Lader",
"stop_charge": "Stoppet",
"skip_delay": "Spring forsinkelse over",
"boost_charge": "Boost",
"wait_plug": "Venter på stik",
"wait_start": "Venter på start"
},
"states": {
"standby": "Standby",
"preparing": "Forbereder",
+15
View File
@@ -122,6 +122,11 @@
"lineToLine": "Line to line",
"power": "Total power",
"sessionDuration": "Session length",
"mode": "Mode",
"plugCountdown": "Waiting for a plug",
"startCountdown": "Starting in",
"autoStart": "Start automatically",
"scheduleWindow": "Charging window",
"chargingStatus": "Charging status",
"sessionEnergy": "Session energy",
"cpSignal": "Control pilot",
@@ -409,10 +414,12 @@
"controlOff": "Off (monitoring only)",
"controlOwn": "Own CSMS (full control)",
"controlProxy": "Proxy CSMS (relay + control)",
"controlCloud": "Anker cloud (works anywhere)",
"controlModbus": "Modbus TCP (local network)",
"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.",
"controlCloudHint": "DriverVault sends commands through your Anker account’s own cloud connection to the charger, so the charger needs no reachability at all: nothing to forward, no address, no token. This is the mode for a charger on someone else’s network. It works only while Anker’s cloud does.",
"controlModbusHint": "DriverVault dials the charger on your local network, so there is nothing to point at an endpoint here. Enable Modbus TCP in the Anker app, then enter the address it shows beside the charger's controls on the Charging page.",
"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",
@@ -434,6 +441,14 @@
"chargerOnline": "Online",
"chargerOffline": "Offline",
"chargerUse": "Use this one",
"modes": {
"start_charge": "Charging",
"stop_charge": "Stopped",
"skip_delay": "Skip delay",
"boost_charge": "Boost",
"wait_plug": "Waiting for a plug",
"wait_start": "Waiting to start"
},
"states": {
"standby": "Standby",
"preparing": "Preparing",
+15
View File
@@ -138,6 +138,11 @@
"lineToLine": "Międzyfazowe",
"power": "Moc całkowita",
"sessionDuration": "Czas sesji",
"mode": "Tryb",
"plugCountdown": "Czeka na podłączenie",
"startCountdown": "Start za",
"autoStart": "Start automatyczny",
"scheduleWindow": "Okno ładowania",
"chargingStatus": "Status ładowania",
"sessionEnergy": "Energia sesji",
"cpSignal": "Control pilot",
@@ -414,10 +419,12 @@
"controlOff": "Wyłączone (tylko monitorowanie)",
"controlOwn": "Własny CSMS (pełne sterowanie)",
"controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)",
"controlCloud": "Chmura Anker (działa wszędzie)",
"controlModbus": "Modbus TCP (sieć lokalna)",
"controlTitle": "Sterowanie ładowarką (OCPP)",
"controlOwnHint": "Ładowarka łączy się bezpośrednio z DriverVault jako swoim systemem centralnym. Ustaw backend OCPP ładowarki na poniższy adres.",
"controlProxyHint": "DriverVault przekazuje ruch do chmury Anker i może wysyłać własne polecenia. Ustaw backend OCPP ładowarki na poniższy adres.",
"controlCloudHint": "DriverVault wysyła polecenia przez połączenie chmurowe Twojego konta Anker z ładowarką, więc ładowarka w ogóle nie musi być osiągalna: nic do przekierowania, żaden adres, żaden token. To tryb dla ładowarki w cudzej sieci. Działa tylko wtedy, gdy działa chmura Ankera.",
"controlModbusHint": "DriverVault sam łączy się z ładowarką w sieci lokalnej, więc nie ma tu adresu, który trzeba jej podać. Włącz Modbus TCP w aplikacji Anker, a potem wpisz pokazany adres obok sterowania ładowarką na stronie Ładowanie.",
"controlProvisionSteps": "W aplikacji Anker (lub w ustawieniach OCPP ładowarki) ustaw adres backendu OCPP na powyższy endpoint, a klucz autoryzacji na powyższy token.",
"chargerSerial": "Numer seryjny ładowarki",
@@ -439,6 +446,14 @@
"chargerOnline": "Online",
"chargerOffline": "Offline",
"chargerUse": "Użyj tej",
"modes": {
"start_charge": "Ładowanie",
"stop_charge": "Zatrzymana",
"skip_delay": "Pomiń opóźnienie",
"boost_charge": "Boost",
"wait_plug": "Czeka na podłączenie",
"wait_start": "Czeka na start"
},
"states": {
"standby": "Czuwanie",
"preparing": "Przygotowanie",
+172 -66
View File
@@ -295,31 +295,43 @@ const ctlActive = computed(() => ctlMode.value !== "off");
const ctlConnected = computed(() => !!ctl.value?.connected);
// Modbus is the local path: we dial the charger rather than wait for it to dial
// us, so what the card asks for is an address rather than a token, and what it
// can offer differs — boost is a Modbus command, reset and clear-limit are OCPP
// ones the register map has no equivalent for.
// us, so what the card asks for is an address rather than a token.
const ctlIsModbus = computed(() => ctlMode.value === "modbus");
// The two transports report a charging session in different words: an OCPP
// session snapshot counts a meter in Wh, a Modbus snapshot counts the session's
// own energy. Both land in the same tile.
// 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 = ctlIsModbus.value ? s.sessionWh : s.meterWh;
const wh = ctlReadsDevice.value ? s.sessionWh : s.meterWh;
return ((wh || 0) / 1000).toFixed(2);
});
const ctlStatusLabel = computed(() => {
const s = ctl.value?.status || {};
return (ctlIsModbus.value ? s.statusDesc : s.connectorStatus) || "—";
return (ctlReadsDevice.value ? s.statusDesc : s.connectorStatus) || "—";
});
// --- The Modbus snapshot, grouped for reading ---
// --- The charger's own snapshot, grouped for reading ---
//
// The local path reports far more than the OCPP one: 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 mb = computed(() => (ctlIsModbus.value && ctl.value?.status) || {});
// 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);
@@ -335,6 +347,25 @@ const enumLabel = (prefix, v) => {
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`;
}
function sessionLength(sec) {
if (!isSet(sec)) return null;
const h = Math.floor(sec / 3600);
@@ -347,25 +378,28 @@ function sessionLength(sec) {
// 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(mb.value.powerTotal, 0, "W"));
const ctlSessionTime = computed(() => sessionLength(mb.value.sessionSeconds));
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 modbusLive = computed(() => {
const s = mb.value;
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")],
["phaseMode", enumLabel("phaseMode", s.phaseMode)],
@@ -377,14 +411,19 @@ const modbusLive = computed(() => {
]);
});
const modbusSettings = computed(() => {
const s = mb.value;
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)],
["boostSet", yesNo(set.boost)],
// 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)],
@@ -409,19 +448,41 @@ const draftPhase = ref(0);
// 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 = mb.value.settings || {};
const set = dev.value.settings || {};
if (isSet(set.maxCurrentA)) draftAmps.value = Math.round(set.maxCurrentA);
if (isSet(set.timeoutSeconds)) draftSeconds.value = set.timeoutSeconds;
if (isSet(set.phaseSetting)) draftPhase.value = set.phaseSetting;
}
const boostOn = computed(() => !!(mb.value.settings || {}).boost);
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(mb.value.maxCurrentA || 32));
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
@@ -430,8 +491,8 @@ 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 modbusSettingsReported = computed(() => {
const s = mb.value;
const deviceSettingsReported = computed(() => {
const s = dev.value;
const set = s.settings || {};
return rows([
["lastCommand", enumLabel("command", set.lastCommand)],
@@ -442,8 +503,8 @@ const modbusSettingsReported = computed(() => {
]);
});
const modbusDevice = computed(() => {
const s = mb.value;
const deviceIdentity = computed(() => {
const s = dev.value;
return rows([
["model", s.model],
["serial", s.serial],
@@ -460,8 +521,8 @@ const modbusDevice = computed(() => {
// 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 modbusPhases = computed(() => {
const s = mb.value;
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 [];
@@ -477,8 +538,8 @@ const modbusPhases = computed(() => {
// 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 modbusLineVoltages = computed(() => {
const s = mb.value;
const deviceLineVoltages = computed(() => {
const s = dev.value;
const pairs = [
["L1–L2", s.voltageL1L2],
["L2–L3", s.voltageL2L3],
@@ -490,7 +551,7 @@ const modbusLineVoltages = computed(() => {
// 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 = mb.value;
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 }))
@@ -1087,6 +1148,17 @@ onMounted(async () => {
<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">
@@ -1098,22 +1170,31 @@ onMounted(async () => {
</button>
</div>
<!-- The limit, on the OCPP path only. On the local one the settings
card owns it: there it is the charger's own ceiling register, read
back and written, and the same slider in two cards was the same
register twice. OCPP has no settings card to move it to — a
charging profile is not a setting the charger reports — so here it
stays, with the clear that only OCPP can send. -->
<!-- The limit, everywhere but the local path. There the settings card
owns it: it is the charger's own ceiling register, read back and
written, and the same slider in two cards was the same register
twice. Neither OCPP nor the cloud has a settings card to move it to
— a charging profile is not a setting the charger reports — so here
it stays. Clearing it is OCPP's alone: the cloud sets a ceiling and
has no message for "no ceiling". -->
<div v-if="!ctlIsModbus" class="mt-3">
<label class="dh-label flex justify-between">
<span>{{ t("charging.control.limit") }}</span><span class="data text-body">{{ limitAmps }} A</span>
</label>
<input v-model.number="limitAmps" type="range" min="6" max="32" step="1" class="w-full accent-[var(--accent)]" />
<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')"
@@ -1123,9 +1204,11 @@ onMounted(async () => {
</div>
</div>
<!-- Boost is a Modbus command, and lasts for the current session only. -->
<!-- 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="ctlIsModbus"
v-if="ctlReadsDevice"
class="dh-btn dh-btn-ghost mt-3 w-full"
:disabled="ctlBusy === 'boost'"
@click="doAction('boost', { on: true })"
@@ -1133,10 +1216,21 @@ onMounted(async () => {
{{ t("charging.control.boost") }}
</button>
<!-- Reset reboots the charger over OCPP; the register map has no
equivalent, so the button is not offered on the local path. -->
<!-- Skipping a start delay is only offered while one is running, and
only the cloud path knows that it is. -->
<button
v-if="!ctlIsModbus && !resetPrompt"
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>
<!-- Reset reboots the charger over OCPP; neither the register map nor
the cloud has an equivalent, so the button is not offered there. -->
<button
v-if="!ctlReadsDevice && !resetPrompt"
class="dh-btn dh-btn-ghost mt-3 w-full"
:disabled="ctlBusy === 'reset'"
@click="askReset"
@@ -1169,7 +1263,7 @@ onMounted(async () => {
they are read right after pressing them, and they were buried at
the bottom of a long readings card. -->
<div
v-if="ctlIsModbus && ctlConnected && modbusSettings.length"
v-if="ctlIsModbus && ctlConnected && deviceSettings.length"
class="dh-card p-4 transition-shadow duration-150"
:class="dropCard === 'settings' ? 'ring-2 ring-accent' : ''"
:style="{ order: cardOrder('settings') }"
@@ -1282,10 +1376,10 @@ onMounted(async () => {
<!-- The rest of the settings block. The charger reports these, but
the register map has nothing to write them with. -->
<div v-if="modbusSettingsReported.length" class="mt-2 rounded-control bg-sunken p-3">
<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 modbusSettingsReported" :key="r.label">
<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>
@@ -1402,21 +1496,33 @@ onMounted(async () => {
</div>
</div>
<!-- Why there is nothing to control yet. In Modbus mode the server has
already tried to reach the charger and says what it found, which
beats a generic hint. -->
<!-- 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(ctlIsModbus ? "charging.control.modbusHint" : "charging.control.connectHint") }}
{{ ctl?.detail || t(ctlHintKey) }}
</p>
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
</div>
</div>
<!-- What the charger reports over Modbus. 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. -->
<!-- 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="ctlIsModbus && ctlConnected"
v-if="ctlReadsDevice && ctlConnected"
class="dh-card p-4 transition-shadow duration-150"
:class="dropCard === 'readings' ? 'ring-2 ring-accent' : ''"
:style="{ order: cardOrder('readings') }"
@@ -1444,7 +1550,7 @@ onMounted(async () => {
<div v-show="isOpen('readings')">
<div class="mt-3 flex flex-col gap-2">
<section v-if="modbusPhases.length" class="rounded-control bg-sunken p-3">
<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">
@@ -1459,7 +1565,7 @@ onMounted(async () => {
</tr>
</thead>
<tbody class="data">
<tr v-for="p in modbusPhases" :key="p.phase" class="border-t border-subtle">
<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>
@@ -1470,35 +1576,35 @@ onMounted(async () => {
</tbody>
</table>
</div>
<p v-if="modbusLineVoltages.length" class="data mt-2 text-[11px] text-muted">
{{ t("charging.modbus.lineToLine") }}: {{ modbusLineVoltages.join(" · ") }}
<p v-if="deviceLineVoltages.length" class="data mt-2 text-[11px] text-muted">
{{ t("charging.modbus.lineToLine") }}: {{ deviceLineVoltages.join(" · ") }}
</p>
</section>
<section v-if="modbusLive.length" class="rounded-control bg-sunken p-3">
<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 modbusLive" :key="r.label">
<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="modbusSettings.length" class="rounded-control bg-sunken p-3">
<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 modbusSettings" :key="r.label">
<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>
<section v-if="modbusDevice.length" class="rounded-control bg-sunken p-3">
<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 modbusDevice" :key="r.label">
<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>
+18 -6
View File
@@ -533,11 +533,13 @@ function fillAnkerForm() {
};
}
// Effective OCPP control mode (off | own | proxy) — gates the control panel.
// The effective control mode (off | mqtt | modbus | own | proxy) — it gates the
// control panel.
const ankerControlMode = computed(() => anker.value?.controlMode || "off");
// Only the OCPP modes are provisioned here. Modbus has no token and no endpoint
// to hand the charger — the one thing it needs is the charger's own address,
// which is asked for beside the controls on the Charging page.
// Only the OCPP modes are provisioned here. Neither of the others hands the
// charger anything: Modbus wants the charger's own address, asked for beside the
// controls on the Charging page, and the cloud wants nothing at all beyond the
// account credentials already on this screen.
const ankerControlIsOcpp = computed(
() => ankerControlMode.value === "own" || ankerControlMode.value === "proxy",
);
@@ -1551,9 +1553,10 @@ onBeforeUnmount(() => {
<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="mqtt">{{ t("settings.integrations.controlCloud") }}</option>
<option value="modbus">{{ t("settings.integrations.controlModbus") }}</option>
<option value="own">{{ t("settings.integrations.controlOwn") }}</option>
<option value="proxy">{{ t("settings.integrations.controlProxy") }}</option>
<option value="modbus">{{ t("settings.integrations.controlModbus") }}</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>
@@ -1638,13 +1641,22 @@ onBeforeUnmount(() => {
</div>
<!-- Modbus is reached rather than provisioned, so this card gives
the address it wants instead of an endpoint to point it at. -->
the address it wants instead of an endpoint to point it at. The
cloud mode asks for nothing beyond the account above, which is
the whole point of it — so it says so rather than showing an
empty setup card. -->
<p
v-if="ankerControlMode === 'modbus'"
class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4 text-xs text-muted"
>
{{ t("settings.integrations.controlModbusHint") }}
</p>
<p
v-else-if="ankerControlMode === 'mqtt'"
class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4 text-xs text-muted"
>
{{ t("settings.integrations.controlCloudHint") }}
</p>
<!-- OCPP control provisioning (only in the modes the charger dials) -->
<div v-if="ankerControlIsOcpp" class="mt-5 rounded-control border border-subtle bg-sunken/40 p-4">