diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index b2236fc..acea4b3 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -154,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", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index d0cfbd4..181b9cc 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -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", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 2fe40b9..6748875 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -156,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", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index c5b6c1b..1bdf9a6 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -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([ @@ -2020,9 +2228,14 @@ onMounted(async () => { + 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. -->
{{ t("charging.modbus.settingsReportedHint") }}
{{ t("charging.modbus.cloudSettingsHint") }}
+ ++ {{ t(`charging.modbus.${block.warning}`) }} +
+ + +{{ t("charging.modbus.cloudSettingsReportedHint") }}
+{{ ctlError }}