The settings card sets things

The card added last commit showed the charger's settings and did nothing with
them, which for a page whose whole point is acting on the charger is half a
card. It also took the settings block out of the readings card to do it, so
reading the charger top to bottom now had a hole in it.

The readings card is whole again — phases, live data, settings, device, alarms,
exactly as before. What the settings card holds is the same values with controls
on them.

Which values get a control is the register map's decision, not a design one. Six
holding registers are writable, and four of them are settings: the current
ceiling, boost, the timeout and the phase count. Charging mode, the two
balancing flags and the LED brightness sit in the measurement block, which the
charger reports over FC04 and does not accept writes on — they are set in the
Anker app. So the card is in two halves and says which is which, rather than
offering a control that would quietly do nothing.

The limits come from the same places the server's own checks do: the slider
floors at 6 A because below that the charger pauses instead of charging slowly
and ModbusSetMaxCurrent refuses it, its ceiling is the charger's reported rating,
and the timeout floors just above the spec's "more than five seconds". Phase and
boost write on the change itself, having one value each; current and timeout are
typed, so they wait for Apply.

Every write goes through the existing control action, so it is gated, rate
limited and audited like the buttons in the control card, and the card reseeds
from the snapshot afterwards — the charger clamps what it is given, and the form
should show what it took rather than what was asked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 10:00:54 +02:00
co-authored by Claude Opus 5
parent e1c063d4b1
commit 65a5c67afc
4 changed files with 182 additions and 7 deletions
+8
View File
@@ -117,6 +117,14 @@
"live": "Live data",
"settings": "Indstillinger",
"settingsTitle": "Laderindstillinger",
"apply": "Anvend",
"turnOn": "Slå til",
"turnOff": "Slå fra",
"limitFloorHint": "{amps} A er bunden — derunder holder laderen pause i stedet for at lade langsomt.",
"boostHint": "Kun den aktuelle session; laderen rydder det, når sessionen slutter.",
"timeoutHint": "Mindst {n} sekunder. Laderen falder tilbage til sin egen strategi, hvis intet skriver inden for tiden.",
"settingsReported": "Rapporteret, kan ikke indstilles",
"settingsReportedHint": "Laderen rapporterer disse; Modbus-kortet har intet register til at skrive dem. Ret dem i Anker-appen.",
"device": "Enhed",
"alarms": "Alarmer",
"phase": "Fase",
+8
View File
@@ -103,6 +103,14 @@
"live": "Live data",
"settings": "Settings",
"settingsTitle": "Charger settings",
"apply": "Apply",
"turnOn": "Turn on",
"turnOff": "Turn off",
"limitFloorHint": "{amps} A is the floor — below it the charger pauses rather than charging slowly.",
"boostHint": "The current session only; the charger clears it when the session ends.",
"timeoutHint": "At least {n} seconds. The charger falls back to its own strategy if nothing writes within it.",
"settingsReported": "Reported, not settable",
"settingsReportedHint": "The charger reports these; the Modbus map has no register to write them. Change them in the Anker app.",
"device": "Device",
"alarms": "Alarms",
"phase": "Phase",
+8
View File
@@ -119,6 +119,14 @@
"live": "Dane na żywo",
"settings": "Ustawienia",
"settingsTitle": "Ustawienia ładowarki",
"apply": "Zastosuj",
"turnOn": "Włącz",
"turnOff": "Wyłącz",
"limitFloorHint": "{amps} A to dolna granica — poniżej ładowarka wstrzymuje ładowanie, zamiast ładować wolniej.",
"boostHint": "Tylko bieżąca sesja; ładowarka kasuje to po jej zakończeniu.",
"timeoutHint": "Co najmniej {n} sekund. Bez zapisu w tym czasie ładowarka wraca do własnej strategii.",
"settingsReported": "Raportowane, nieustawialne",
"settingsReportedHint": "Ładowarka je raportuje; mapa Modbus nie ma rejestru do ich zapisu. Zmień je w aplikacji Anker.",
"device": "Urządzenie",
"alarms": "Alarmy",
"phase": "Faza",
+158 -7
View File
@@ -379,6 +379,55 @@ const modbusSettings = computed(() => {
]);
});
// --- 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 = mb.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);
// 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));
// 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 modbusSettingsReported = computed(() => {
const s = mb.value;
const set = s.settings || {};
return rows([
["lastCommand", enumLabel("command", set.lastCommand)],
["chargingMode", enumLabel("chargingMode", s.chargingMode)],
["loadBalancing", yesNo(s.loadBalancing)],
["solarBalancing", yesNo(s.solarBalancing)],
["ledBrightness", isSet(s.ledBrightness) ? `${s.ledBrightness} %` : null],
]);
});
const modbusDevice = computed(() => {
const s = mb.value;
return rows([
@@ -701,6 +750,7 @@ async function refreshCtl() {
// 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;
@@ -1121,13 +1171,104 @@ onMounted(async () => {
</button>
<div v-show="isOpen('settings')">
<!-- The card is the heading, so the group needs none of its own. -->
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-control bg-sunken p-3">
<template v-for="r in modbusSettings" :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>
<!-- 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="modbusSettingsReported.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">
<dt class="text-xs text-muted">{{ r.label }}</dt>
<dd class="data text-right text-xs text-strong">{{ r.value }}</dd>
</template>
</dl>
<p class="mt-2 text-[11px] text-muted">{{ t("charging.modbus.settingsReportedHint") }}</p>
</div>
<p v-if="ctlError" class="mt-2 text-sm text-danger">{{ ctlError }}</p>
</div>
</div>
@@ -1319,6 +1460,16 @@ onMounted(async () => {
</dl>
</section>
<section v-if="modbusSettings.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">
<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">
<h4 class="eyebrow">{{ t("charging.modbus.device") }}</h4>
<dl class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">