Compare commits
2
Commits
7176867eb3
...
9d4aecb668
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d4aecb668 | ||
|
|
3064bff8ad |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user