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") }} - +