From 4ff6242c8f592cb1baa8b6e5be49fcac945e0fc2 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:15:12 +0200 Subject: [PATCH] The last message in the map, and it reboots the charger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0108 was the one thing in the MQTT inventory nobody had wired: the device power mode, whose single documented value restarts the charger. It is the only way to reboot a charger that is on neither a CSMS nor the local network — which is most of them — so the cloud transport sends it now, and "reset" reaches it too, since that is what the OCPP path has always called the same act. Nothing waits for a confirmation: the device that would send it is the device rebooting, so the command answers at once and says the charger drops off the cloud for about a minute. The gate is unchanged and now covers both spellings — an explicit confirm plus a password step-up, audited either way. Modbus still refuses, because no register does this, but its refusal now names both transports that can rather than only the CSMS. Both clients already had the reset button and its password prompt; they were hidden in every mode that reads the device, which is why the cloud never showed one. Modbus is now the only mode without it. Co-Authored-By: Claude Opus 5 --- .../api/integrations_ankersolix_control.go | 7 +++- .../api/integrations_ankersolix_modbus.go | 8 +++- .../api/integrations_ankersolix_mqtt.go | 17 +++++--- .../api/integrations_ankersolix_mqtt_test.go | 16 +++++++- .../plugins/builtin/ankersolix/ankersolix.go | 2 +- .../plugins/builtin/ankersolix/cloudmqtt.go | 19 +++++++++ .../plugins/builtin/ankersolix/mqttframe.go | 5 +++ .../builtin/ankersolix/mqttframe_test.go | 41 +++++++++++++++++++ .../builtin/ankersolix/mqttsnapshot.go | 20 ++++++++- Phone App/assets/i18n/da.json | 1 + Phone App/assets/i18n/en.json | 1 + Phone App/assets/i18n/pl.json | 1 + Phone App/lib/screens/charging_screen.dart | 15 +++++-- Web App/web/src/i18n/da.json | 1 + Web App/web/src/i18n/en.json | 1 + Web App/web/src/i18n/pl.json | 1 + Web App/web/src/views/Charging.vue | 24 ++++++++--- 17 files changed, 159 insertions(+), 21 deletions(-) diff --git a/API Server/internal/api/integrations_ankersolix_control.go b/API Server/internal/api/integrations_ankersolix_control.go index 74e93f9..fb3ac32 100644 --- a/API Server/internal/api/integrations_ankersolix_control.go +++ b/API Server/internal/api/integrations_ankersolix_control.go @@ -604,7 +604,7 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request operative = *body.Operative } status, err = sess.ChangeAvailability(ctx, body.ConnectorID, operative) - case "reset": + case "reset", "restart": status, err = sess.Reset(ctx, body.Hard) case "unlock": status, err = sess.UnlockConnector(ctx, body.ConnectorID) @@ -649,7 +649,10 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request // releases the cable lock — actions that require an explicit confirm:true and a // password re-authentication. func isDestructiveAction(action string) bool { - return action == "reset" || action == "unlock" + // "restart" is the cloud's name for the same act as OCPP's "reset": both + // reboot the charger, so both pass through the confirmation and the password + // step-up rather than one slipping past because it is spelled differently. + return action == "reset" || action == "restart" || action == "unlock" } // reauthenticate verifies the caller's password against PocketBase (a sudo-style diff --git a/API Server/internal/api/integrations_ankersolix_modbus.go b/API Server/internal/api/integrations_ankersolix_modbus.go index bc17d1b..ab1a2f2 100644 --- a/API Server/internal/api/integrations_ankersolix_modbus.go +++ b/API Server/internal/api/integrations_ankersolix_modbus.go @@ -43,7 +43,13 @@ func (s *Server) ankerModbusAction(w http.ResponseWriter, r *http.Request, who * // Actions the register map has no equivalent for. Saying which transport is // missing them beats a bare "unknown action" the caller cannot act on. switch action { - case "reset", "unlock", "availability", "trigger", "config": + case "reset", "restart": + // No register reboots the charger. Both of the other transports can, so + // the refusal names them rather than only the CSMS. + writeError(w, http.StatusBadRequest, + "no register reboots the charger; the local Modbus connection cannot restart it. Switch the control mode to Anker cloud (MQTT) or a CSMS mode to use it.") + return + case "unlock", "availability", "trigger", "config": writeError(w, http.StatusBadRequest, "\""+action+"\" is an OCPP command; the local Modbus connection cannot send it. Switch the control mode to a CSMS mode to use it.") return diff --git a/API Server/internal/api/integrations_ankersolix_mqtt.go b/API Server/internal/api/integrations_ankersolix_mqtt.go index d891f64..e6df957 100644 --- a/API Server/internal/api/integrations_ankersolix_mqtt.go +++ b/API Server/internal/api/integrations_ankersolix_mqtt.go @@ -18,11 +18,11 @@ package api // or certificated on the customer's side; what it costs instead is a dependency // on Anker's cloud being up, and on an unofficial protocol. // -// The command set is the charger's, not OCPP's: start, stop, boost, skip-delay -// and a current limit, plus the one thing neither other transport can do at all -// — writing the charger's own configuration, which is what "settings" is for. -// Everything the register map or the CSMS can do that this cannot is refused by -// name rather than as an unknown action. +// The command set is the charger's, not OCPP's: start, stop, boost, skip-delay, +// a current limit and a restart, plus the one thing neither other transport can +// do at all — writing the charger's own configuration, which is what "settings" +// is for. Everything the register map or the CSMS can do that this cannot is +// refused by name rather than as an unknown action. import ( "context" @@ -59,7 +59,7 @@ func (s *Server) ankerMqttAction(w http.ResponseWriter, r *http.Request, who *ca // Actions this transport has no equivalent for. Naming the transport that // does have them beats a bare "unknown action" the caller cannot act on. switch action { - case "reset", "unlock", "availability", "trigger", "config": + case "unlock", "availability", "trigger", "config": writeError(w, http.StatusBadRequest, "\""+action+"\" is an OCPP command; the Anker cloud connection cannot send it. Switch the control mode to a CSMS mode to use it.") return @@ -96,6 +96,11 @@ func (s *Server) ankerMqttAction(w http.ResponseWriter, r *http.Request, who *ca case "limit": params["amps"] = body.Amps payload["command"], payload["amps"] = "limit", body.Amps + case "reset", "restart": + // The charger's own restart, the cloud's answer to the OCPP reset. It has + // already been through the confirmation and the password step-up upstairs, + // like any other reboot. + payload["command"] = "restart" case "settings": // The values themselves are audited, not just the fact of a write: a // setting that changes what the charger will draw, or whether it answers on diff --git a/API Server/internal/api/integrations_ankersolix_mqtt_test.go b/API Server/internal/api/integrations_ankersolix_mqtt_test.go index 1837127..112c844 100644 --- a/API Server/internal/api/integrations_ankersolix_mqtt_test.go +++ b/API Server/internal/api/integrations_ankersolix_mqtt_test.go @@ -24,7 +24,6 @@ func refuse(t *testing.T, action string, body ankerControlBody) *httptest.Respon // unknown. func TestAnkerMqttActionNamesTheTransportThatCan(t *testing.T) { for _, tc := range []struct{ action, want string }{ - {"reset", "CSMS"}, {"unlock", "CSMS"}, {"availability", "CSMS"}, {"config", "CSMS"}, @@ -43,6 +42,21 @@ func TestAnkerMqttActionNamesTheTransportThatCan(t *testing.T) { } } +// A reboot is a reboot under either name and over any transport: both go through +// the confirmation and the password step-up, and nothing else does. +func TestBothNamesForARebootAreGated(t *testing.T) { + for _, action := range []string{"reset", "restart", "unlock"} { + if !isDestructiveAction(action) { + t.Errorf("%s should need a confirmation and a password", action) + } + } + for _, action := range []string{"start", "stop", "limit", "boost", "settings", "status"} { + if isDestructiveAction(action) { + t.Errorf("%s should not demand a password", action) + } + } +} + func TestAnkerMqttActionRejectsUnknownActions(t *testing.T) { rec := refuse(t, "explode", ankerControlBody{}) if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "unknown control action") { diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index d31c770..8b5f9bb 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -303,7 +303,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor { {ID: "message-devices", Method: "POST", Endpoint: epMessageSNList, Description: "Which devices produce notifications at all."}, {ID: "tamper-records", Method: "POST", Endpoint: epTamperRecords, Description: "Tamper records for one device (needs sn; optional page, pageSize)."}, {ID: "mqtt-status", Method: "POST", Endpoint: epMqttInfo, Description: "Live state of one charger over Anker's cloud MQTT broker — the path to a charger the server cannot reach (needs sn)."}, - {ID: "mqtt-command", Method: "POST", Endpoint: epMqttInfo, Description: "Control one charger over Anker's cloud MQTT broker: start, stop, boost, skip-delay, limit (with amps) or trigger (needs sn and command)."}, + {ID: "mqtt-command", Method: "POST", Endpoint: epMqttInfo, Description: "Control one charger over Anker's cloud MQTT broker: start, stop, boost, skip-delay, limit (with amps), trigger or restart (needs sn and command)."}, {ID: "mqtt-settings", Method: "POST", Endpoint: epMqttInfo, Description: "Write one charger's settings over Anker's cloud MQTT broker — current ceiling, switches, schedules, load balancing and solar charging (needs sn and settings)."}, }, ConfigFields: []plugins.ConfigField{ diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go index af99af1..0a6b567 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -773,6 +773,25 @@ func (p *Plugin) mqttSetMode(ctx context.Context, c *mqttConn, model, sn, mode s return c.publishFrame(ctx, model, sn, frame, mqttEncodingMode) } +// mqttRestart reboots the charger. It is the cloud's answer to the OCPP reset — +// the one thing the phone can do to a charger that no register holds and no CSMS +// reaches when the charger is not on one. The charger goes away and comes back, +// so nothing confirms it: the acknowledgement would have to arrive from a device +// that is rebooting. +func (p *Plugin) mqttRestart(ctx context.Context, c *mqttConn, model, sn string) error { + frame, err := encodeFrame(msgEVPowerMode, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, powerModeRestart), + timestampField(time.Now()), + }) + if err != nil { + return err + } + // The same encoding_type the mode command carries: the charger expects the + // field on these two messages and on no others. + return c.publishFrame(ctx, model, sn, frame, mqttEncodingMode) +} + // mqttSetMaxCurrent sets the charging current ceiling, in amps. The limit is // checked by the same rule the Modbus path uses, because the rule is the // charger's: the transport differs, the charger does not. diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go index 680e904..dc85ae6 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -75,11 +75,16 @@ const ( msgEVSchedule = "0106" // the charging schedule: switch, mode and times msgEVBalancing = "010c" // load balancing and the main breaker limit msgEVSolar = "010e" // solar charging + msgEVPowerMode = "0108" // the device power mode: the one value restarts it msgEVTelemetry = "0410" // fast telemetry, only while a trigger is live msgEVParams = "0405" // settings and identity, sent after a command msgEVParamsAlt = "0840" // the same fields, in answer to a status request msgEVConfirm = "0900" // the same fields again, confirming a control change msgEVCharging = "0403" // a couple of charging parameters + + // powerModeRestart is the only value the power-mode command is known to take. + // The map documents 5 and nothing else, so nothing else is sent. + powerModeRestart uint8 = 5 ) // mqttField is one named value inside a device message. factor scales the raw diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go index 00b9d2a..878b857 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go @@ -71,6 +71,47 @@ func TestStatusRequestSendsItsClockWithoutAValueType(t *testing.T) { } } +// The restart carries the one value the map documents for the power-mode +// command, opened and closed like every other command. +func TestRestartFrameCarriesThePowerModeValue(t *testing.T) { + got, err := encodeFrame(msgEVPowerMode, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, powerModeRestart), + timestampField(time.Unix(1756813256, 0)), + }) + if err != nil { + t.Fatalf("encodeFrame: %v", err) + } + want := "ff09180003000f0108" + // header: marker, length 24, send pattern, type 0108 + "a10122" + // a1: the opener, no value type + "a2020105" + // a2: ui 5 — restart + "fe0503c8d7b668" // fe: var — the sender's clock + if h := encodeHex(got); h[:len(want)] != want { + t.Fatalf("frame = %s / want %s + checksum", h, want) + } + var sum byte + for _, b := range got { + sum ^= b + } + if sum != 0 { + t.Errorf("checksum does not close the frame: %02x", sum) + } +} + +// Both spellings reach the restart; nothing else does. +func TestIsRestartTakesEitherName(t *testing.T) { + for _, name := range []string{"restart", "reset"} { + if !isRestart(name) { + t.Errorf("%q should ask for a restart", name) + } + } + for _, name := range []string{"reboot", "start", "stop", "trigger", ""} { + if isRestart(name) { + t.Errorf("%q should not ask for a restart", name) + } + } +} + // A frame is only self-consistent if XORing every byte, checksum included, // comes to zero — which is exactly what the decoder checks. func TestEncodeFrameChecksumClosesToZero(t *testing.T) { diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go index c4a20cf..19b3357 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go @@ -274,6 +274,12 @@ var mqttCommands = map[string]string{ modeSkipDelay: modeSkipDelay, } +// isRestart reports whether a command asks for a reboot. Both names answer to +// it: "restart" is what this transport calls the message, and "reset" is what +// the OCPP path has always called the same act, so a caller that knows one is +// not told the charger cannot do the other. +func isRestart(command string) bool { return command == "restart" || command == "reset" } + // mqttCommand issues one control command over the cloud. func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float64) (json.RawMessage, error) { // Validate before touching the cloud: a mistyped command should not cost a @@ -287,8 +293,9 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float return nil, err } case command == "trigger": + case isRestart(command): default: - return nil, fmt.Errorf("anker-solix: %q is not a cloud command (start, stop, boost, skip-delay, limit, trigger)", command) + return nil, fmt.Errorf("anker-solix: %q is not a cloud command (start, stop, boost, skip-delay, limit, trigger, restart)", command) } model, err := p.chargerModel(ctx, sn) @@ -311,6 +318,8 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float err = p.mqttSetMode(ctx, conn, model, sn, mode) case command == "limit": err = p.mqttSetMaxCurrent(ctx, conn, model, sn, amps) + case isRestart(command): + err = p.mqttRestart(ctx, conn, model, sn) default: err = p.mqttTrigger(ctx, conn, model, sn, triggerWindow) } @@ -318,6 +327,15 @@ func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float return nil, err } + // A restart is the one command with nothing to wait for: the charger that + // would send the confirmation is the charger that is rebooting. Saying so + // beats waiting five seconds to report an unconfirmed command as if that + // were news. + if isRestart(command) { + return json.Marshal(mqttCommandDoc{Serial: sn, Command: command, Status: "accepted", + Detail: "sent; the charger reboots rather than confirming, and drops off the cloud for about a minute"}) + } + // The charger answers a control change with a settings message. Waiting for // it turns "published" into "the charger has it". confirmed, waitErr := conn.waitFor(ctx, sn, func(st *deviceState) bool { diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index a083613..15bd4e5 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -147,6 +147,7 @@ "reset": "Genstart laderen", "resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.", "resetPassword": "Din kontoadgangskode", + "restartCloudHint": "Laderen genstarter i stedet for at svare og forsvinder fra skyen i cirka et minut.", "connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer).", "connectionTitle": "Laderforbindelse", "enterSerial": "Indtast et serienummer i stedet", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index e765f7f..6da812b 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -147,6 +147,7 @@ "reset": "Reset charger", "resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.", "resetPassword": "Your account password", + "restartCloudHint": "The charger reboots rather than answering, and drops off the cloud for about a minute.", "connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations).", "connectionTitle": "Charger connection", "enterSerial": "Enter a serial instead", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index f645e82..b7f197e 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -149,6 +149,7 @@ "reset": "Zrestartuj ładowarkę", "resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.", "resetPassword": "Hasło do Twojego konta", + "restartCloudHint": "Ładowarka zrestartuje się zamiast odpowiedzieć i zniknie z chmury na około minutę.", "connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje).", "connectionTitle": "Połączenie z ładowarką", "enterSerial": "Wpisz numer seryjny zamiast tego", diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 63af1b1..c062bf5 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -1606,9 +1606,11 @@ class _HomeTabState extends State<_HomeTab> { ), ], - // Reset reboots the charger over OCPP; neither the register map nor the - // cloud has an equivalent, so the button is not offered there. - if (!_readsDevice) ...[ + // Rebooting the charger: an OCPP reset, or the cloud's own restart + // message, which is the way to reach a charger that is on neither a CSMS + // nor the LAN. No register does it, so Modbus is the one mode without + // the button. + if (!_readsDevice || _isCloud) ...[ const SizedBox(height: 12), if (!_resetPrompt) SizedBox( @@ -1639,6 +1641,13 @@ class _HomeTabState extends State<_HomeTab> { Text(t("charging.control.resetConfirm"), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: DriverVault.danger)), + // Over the cloud there is nothing to confirm it with: the + // charger that would answer is the one rebooting. + if (_isCloud) ...[ + const SizedBox(height: 4), + Text(t("charging.control.restartCloudHint"), + style: const TextStyle(fontSize: 11, color: DriverVault.danger)), + ], const SizedBox(height: 8), TextField( controller: _resetPassword, diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 62d13ba..770082c 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -97,6 +97,7 @@ "reset": "Genstart laderen", "resetConfirm": "Genstart denne lader nu? En igangværende opladning bliver afbrudt. Indtast din adgangskode igen for at bekræfte.", "resetPassword": "Din kontoadgangskode", + "restartCloudHint": "Laderen genstarter i stedet for at svare og forsvinder fra skyen i cirka et minut.", "connectHint": "Indtast laderens serienummer og opdater. Laderen skal være forbundet til DriverVaults OCPP-backend (opsættes under Indstillinger → Integrationer).", "address": "Laderens adresse på dette netværk", "addressPlaceholder": "IP-adresse (f.eks. 192.168.1.40)", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 83c2d08..83bb68f 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -83,6 +83,7 @@ "reset": "Reset charger", "resetConfirm": "Reboot this charger now? Any active charging session will be interrupted. Re-enter your password to confirm.", "resetPassword": "Your account password", + "restartCloudHint": "The charger reboots rather than answering, and drops off the cloud for about a minute.", "connectHint": "Enter your charger's serial and refresh. The charger must be connected to DriverVault's OCPP backend (set up in Settings → Integrations).", "address": "Charger address on this network", "addressPlaceholder": "IP address (e.g. 192.168.1.40)", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index e18984a..2f25551 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -99,6 +99,7 @@ "reset": "Zrestartuj ładowarkę", "resetConfirm": "Zrestartować teraz tę ładowarkę? Trwająca sesja ładowania zostanie przerwana. Wpisz ponownie hasło, aby potwierdzić.", "resetPassword": "Hasło do Twojego konta", + "restartCloudHint": "Ładowarka zrestartuje się zamiast odpowiedzieć i zniknie z chmury na około minutę.", "connectHint": "Wpisz numer seryjny ładowarki i odśwież. Ładowarka musi być połączona z backendem OCPP DriverVault (konfiguracja w Ustawienia → Integracje).", "address": "Adres ładowarki w tej sieci", "addressPlaceholder": "Adres IP (np. 192.168.1.40)", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index f6617c7..5ebf96c 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -1082,9 +1082,16 @@ async function doAction(action, body) { } } -// Reset reboots the charger — a destructive action the server gates behind an -// explicit confirmation AND a password re-authentication (step-up). Reveal the -// inline password prompt; the actual call happens in confirmReset(). +// Rebooting the charger. Two transports can: OCPP sends a reset, and the cloud +// sends the charger's own restart message — which is the only way to reboot a +// charger that is on neither a CSMS nor the local network. The register map has +// no such register, so the button is absent in Modbus mode rather than failing +// when pressed. +// +// Either way the server gates it behind an explicit confirmation AND a password +// re-authentication (step-up). Reveal the inline password prompt; the actual +// call happens in confirmReset(). +const ctlCanRestart = computed(() => !ctlReadsDevice.value || ctlIsCloud.value); const resetPrompt = ref(false); const resetPassword = ref(""); @@ -1416,10 +1423,12 @@ onMounted(async () => { {{ t("charging.control.skipDelay") }} - +