The last message in the map, and it reboots the charger
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b2d333a63f
commit
4ff6242c8f
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user