Compare commits

..
2 Commits
Author SHA1 Message Date
tajniak81andClaude Opus 5 9d4aecb668 The settings card stops being a Modbus card
Both transports that read the charger can also be told things, so both get
the card now. Modbus has four writable registers and keeps the four
controls it had. The cloud has the charger's whole settings group — two
dozen of them, everything the Anker app sets short of the card list — and
they were readable in the readings card and settable nowhere.

Drawn from a table rather than written out one control at a time, because
the charger's commands own sets of fields and take a command whole: a
schedule frame carrying only its switch is a schedule whose times have
just been set to midnight. So a section is one command, its fields are
sent together, and Apply is per section. Only what the charger has
reported gets a control — a blank box that writes whatever it was left at
is worse than no box when the value travels as a sibling.

The meter and monitor the balancing features watch stay read-only: there
is no command for them, and what their modes select is undocumented. The
phase select offers the automatic and single-phase this command carries,
and shows a reported three-phase rather than reading as something the
charger did not say.

Nothing left the readings card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:19:57 +02:00
tajniak81andClaude Opus 5 3064bff8ad The charger's own card list gets a door, and says where it differs
0104 was already implemented and already in the action catalogue; nothing
routed to it, so the only way to see the device's list was as a side
effect of writing a card. It has an endpoint now, and the panel a button.

The two lists are compared where they meet: a card the charger holds and
the account has forgotten still opens it, and a card only the account
holds will not, and neither shows anywhere else. The comparison is drawn
only when they disagree, and the device's reading is dropped on a refresh
rather than measured against an account list from a later moment.

The new test asks all four card routes without a token: a capability the
plugin implements and the catalogue advertises is still unusable if
nothing routes to it, and no other test here would notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:35:27 +02:00
8 changed files with 534 additions and 4 deletions
@@ -648,7 +648,24 @@ func (s *Server) handleAnkerCardScan(w http.ResponseWriter, r *http.Request) {
s.ankerCardWrite(w, r, who, cfg, "rfid-card-scan", sn, map[string]any{"sn": sn})
}
// ankerCardGate is everything both writes need before they may run: a caller, a
// GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger — the list
// of cards the charger itself holds, asked of the device with 0104 rather than
// of the account.
//
// The two lists are written together and can still come apart: a card the
// account has forgotten still opens the charger until the device is told
// otherwise, and the account's copy is the only one every other view here
// draws. Asking the device is the only way to see the difference. It answers
// with UIDs and nothing else — the charger has no field for a card's name.
func (s *Server) handleAnkerChargerCards(w http.ResponseWriter, r *http.Request) {
who, sn, cfg, ok := s.ankerCardGate(w, r)
if !ok {
return
}
s.ankerCardWrite(w, r, who, cfg, "rfid-cards-charger", sn, map[string]any{"sn": sn})
}
// ankerCardGate is everything a card call needs before it may run: a caller, a
// serial, an integration that is on and has credentials, and a rate limit. A
// gate that is off answers 409 rather than the reads' 200-with-a-reason: a write
// that did not happen is not a state to render, it is a request that failed.
@@ -670,7 +687,7 @@ func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerI
return nil, "", nil, false
}
if !s.ctlRL.allow(who.ID + "|" + sn) {
writeError(w, http.StatusTooManyRequests, "too many card changes; please slow down")
writeError(w, http.StatusTooManyRequests, "too many card requests; please slow down")
return nil, "", nil, false
}
return who, sn, map[string]string{
@@ -3,10 +3,12 @@ package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/pb"
)
@@ -217,3 +219,25 @@ func TestOCPPEndpoint(t *testing.T) {
t.Errorf("tls endpoint = %q", got)
}
}
// The four card routes are reachable at all. A capability the plugin implements
// and the action catalogue advertises is still unusable if nothing routes to it,
// and that is not a failure any other test here would notice: the plugin's own
// tests pass, and the panel simply has no button. Each route is asked for
// without a token, so what is being checked is that the request reached the
// authentication middleware rather than a 404.
func TestAnkerCardRoutesAreRegistered(t *testing.T) {
h := New(config.Config{}, nil).Handler()
for _, tc := range []struct{ method, path string }{
{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards"},
{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/scan"},
{"GET", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/charger"},
{"DELETE", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/AABBCCDD"},
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
if rr.Code == http.StatusNotFound {
t.Errorf("%s %s is not routed", tc.method, tc.path)
}
}
}
+2
View File
@@ -55,6 +55,7 @@
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}
// GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
// GET /api/integrations/greencell/chargers
@@ -447,6 +448,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan", s.handleAnkerCardScan)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger", s.handleAnkerChargerCards)
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)
+6
View File
@@ -357,6 +357,12 @@ export const api = {
`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/${encodeURIComponent(cardNumber)}`,
{ method: "DELETE" }
),
// The list the charger itself holds, asked of the device rather than of the
// account. Both are written by every add and remove, and they can still come
// apart; this is the only call that says so. Answers with {cards} — bare
// numbers, because the device has no field for a card's name.
getAnkerChargerCards: (sn) =>
request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/rfid-cards/charger`),
// Anker Solix control (per charger), over whichever transport the user's
// control mode selects. getAnkerControl returns the control mode, connection
+19
View File
@@ -80,6 +80,13 @@
"namePlaceholder": "Navn (valgfrit)",
"notAdded": "Tjenesten tog imod anmodningen, men kortet er ikke på laderen. Kontrollér nummeret, og prøv igen.",
"notRemoved": "Tjenesten tog imod anmodningen, men kortet er stadig på laderen.",
"readCharger": "Læs laderens egen liste",
"chargerTitle": "På selve laderen",
"chargerNone": "Laderen har ingen kort.",
"chargerHint": "Spurgt laderen, ikke kontoen. Den svarer kun med numre — et korts navn hører til på kontoen.",
"driftTitle": "De to lister er ikke enige",
"onlyOnCharger": "Åbner laderen, men findes ikke på kontoen: {cards}",
"onlyOnAccount": "På kontoen, men ikke på laderen, så det åbner den ikke: {cards}",
"inferred": "Anker dokumenterer hverken tilføjelse eller fjernelse. DriverVault udleder anmodningen af de felter, kortlisten svarer med, og læser derefter listen igen — det, du ser ovenfor, er det, kontoen har."
},
"stations": {
@@ -147,6 +154,18 @@
"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.",
"blockCharging": "Opladning",
"blockSchedule": "Tidsplan",
"blockBalancing": "Belastningsbalancering",
"blockSolar": "Sol",
"blockPanel": "Panel og lys",
"blockLocal": "Lokalt netværk",
"windowStart": "Start",
"windowEnd": "Slut",
"reset": "Fortryd",
"cloudSettingsHint": "Laderens egne indstillinger, skrevet via Anker-skyen. Et afsnit er én kommando til laderen, så dets felter anvendes samlet.",
"cloudSettingsReportedHint": "Laderen rapporterer disse; ingen kommando skriver dem. Hvad de to tilstande og flaget vælger, er udokumenteret, så de vises som de tal, de er.",
"modbusOffWarning": "Med Modbus TCP-serveren slået fra svarer laderen ikke længere på det lokale netværk, og Modbus-styringstilstanden har intet at ringe op.",
"device": "Enhed",
"alarms": "Alarmer",
"phase": "Fase",
+19
View File
@@ -115,6 +115,18 @@
"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.",
"blockCharging": "Charging",
"blockSchedule": "Schedule",
"blockBalancing": "Load balancing",
"blockSolar": "Solar",
"blockPanel": "Panel and light",
"blockLocal": "Local network",
"windowStart": "Start",
"windowEnd": "End",
"reset": "Undo",
"cloudSettingsHint": "The charger's own settings, written over the Anker cloud. A section is one command to the charger, so its fields are applied together.",
"cloudSettingsReportedHint": "The charger reports these; no command writes them. What the two modes and the flag select is undocumented, so they are shown as the numbers they are.",
"modbusOffWarning": "With the Modbus TCP server off the charger stops answering on the local network, and the Modbus control mode has nothing left to dial.",
"device": "Device",
"alarms": "Alarms",
"phase": "Phase",
@@ -349,6 +361,13 @@
"namePlaceholder": "Name (optional)",
"notAdded": "The service took the request, but the card is not on the charger. Check the number and try again.",
"notRemoved": "The service took the request, but the card is still on the charger.",
"readCharger": "Read the charger's own list",
"chargerTitle": "On the charger itself",
"chargerNone": "The charger holds no cards.",
"chargerHint": "Asked of the charger, not of the account. It answers with numbers only — a card's name lives on the account.",
"driftTitle": "The two lists disagree",
"onlyOnCharger": "Opens the charger but is not on the account: {cards}",
"onlyOnAccount": "On the account but not on the charger, so it will not open it: {cards}",
"inferred": "Anker documents neither the add nor the remove endpoint. DriverVault infers the request from the fields the card list answers with, then reads the list back — what you see above is what the account holds."
},
"stations": {
+19
View File
@@ -80,6 +80,13 @@
"namePlaceholder": "Nazwa (opcjonalnie)",
"notAdded": "Usługa przyjęła żądanie, ale karty nie ma na ładowarce. Sprawdź numer i spróbuj ponownie.",
"notRemoved": "Usługa przyjęła żądanie, ale karta nadal jest na ładowarce.",
"readCharger": "Odczytaj własną listę ładowarki",
"chargerTitle": "Na samej ładowarce",
"chargerNone": "Ładowarka nie ma żadnych kart.",
"chargerHint": "Zapytana została ładowarka, nie konto. Odpowiada samymi numerami — nazwa karty jest po stronie konta.",
"driftTitle": "Obie listy się nie zgadzają",
"onlyOnCharger": "Otwiera ładowarkę, ale nie ma jej na koncie: {cards}",
"onlyOnAccount": "Jest na koncie, ale nie na ładowarce, więc jej nie otworzy: {cards}",
"inferred": "Anker nie dokumentuje ani dodawania, ani usuwania. DriverVault wnioskuje żądanie z pól, którymi odpowiada lista kart, a potem odczytuje listę ponownie — powyżej widzisz to, co ma konto."
},
"stations": {
@@ -149,6 +156,18 @@
"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.",
"blockCharging": "Ładowanie",
"blockSchedule": "Harmonogram",
"blockBalancing": "Balansowanie obciążenia",
"blockSolar": "Fotowoltaika",
"blockPanel": "Panel i podświetlenie",
"blockLocal": "Sieć lokalna",
"windowStart": "Początek",
"windowEnd": "Koniec",
"reset": "Cofnij",
"cloudSettingsHint": "Własne ustawienia ładowarki, zapisywane przez chmurę Anker. Jedna sekcja to jedno polecenie do ładowarki, więc jej pola są zapisywane razem.",
"cloudSettingsReportedHint": "Ładowarka je zgłasza, ale żadne polecenie ich nie zapisuje. Nie wiadomo, co wybierają te dwa tryby i flaga, więc pokazane są jako liczby, którymi są.",
"modbusOffWarning": "Przy wyłączonym serwerze Modbus TCP ładowarka przestaje odpowiadać w sieci lokalnej, a tryb sterowania Modbus nie ma już pod co zadzwonić.",
"device": "Urządzenie",
"alarms": "Alarmy",
"phase": "Faza",
+426 -2
View File
@@ -527,6 +527,7 @@ function syncSettingsDraft() {
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);
@@ -578,6 +579,213 @@ const deviceSettingsReported = computed(() => {
]);
});
// --- 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: [
{ key: "maxCurrentA", at: "settings.maxCurrentA", label: "maxCurrentSet", type: "number", min: LIMIT_FLOOR, max: null, step: 1, unit: "A" },
{ 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] },
{ key: "solarMinCurrentA", at: "settings.solarMinCurrentA", label: "solarMinCurrent", type: "number", 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: [
{ key: "ledBrightness", at: "ledBrightness", label: "ledBrightness", type: "number", 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("");
function syncMqttSettings() {
const draft = {};
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 !== "") draft[key] = v;
}
}
}
mqttDraft.value = draft;
mqttBase.value = { ...draft };
}
// 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([
@@ -1163,6 +1371,13 @@ async function loadChargerDetails(force = false) {
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.
@@ -1302,6 +1517,58 @@ async function scanRfidCard() {
}
}
// 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) {
@@ -1881,6 +2148,40 @@ onMounted(async () => {
</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. -->
@@ -1927,9 +2228,14 @@ onMounted(async () => {
<!-- 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. -->
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="ctlIsModbus && ctlConnected && deviceSettings.length"
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') }"
@@ -1956,6 +2262,7 @@ onMounted(async () => {
</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">
@@ -2052,6 +2359,123 @@ onMounted(async () => {
</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">
<div
v-for="f in block.fields"
:key="f.key || f.from"
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>
</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>