diff --git a/.gitignore b/.gitignore index 27a4712..7dab326 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,6 @@ Thumbs.db .env *.env.local -# Local reference copy of the published Anker Modbus map artifact -anker-modbus-map.html +# Local reference copies of the published Anker protocol map artifacts +# (Modbus TCP, cloud MQTT, cloud REST API, OCPP) +anker-*-map.html diff --git a/API Server/internal/api/integrations_ankersolix_control.go b/API Server/internal/api/integrations_ankersolix_control.go index 6fcb2ba..74e93f9 100644 --- a/API Server/internal/api/integrations_ankersolix_control.go +++ b/API Server/internal/api/integrations_ankersolix_control.go @@ -523,6 +523,10 @@ type ankerControlBody struct { On *bool `json:"on"` // boost PhaseMode *int `json:"phase"` // 0 automatic, 1 single, 2 three Seconds int `json:"seconds"` // Modbus control timeout + + // Settings carries the charger's own configuration for the "settings" action, + // keyed by the names the cloud snapshot reports them under. + Settings map[string]any `json:"settings"` } // handleAnkerControlAction issues one command to a charger, over whichever @@ -606,6 +610,10 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request status, err = sess.UnlockConnector(ctx, body.ConnectorID) case "trigger": status, err = sess.TriggerMessage(ctx, body.RequestedMessage, body.ConnectorID) + case "settings": + writeError(w, http.StatusBadRequest, + "\"settings\" writes the charger's own configuration, which only the Anker cloud connection can do. Switch the control mode to Anker cloud (MQTT) to use it.") + return case "config": if body.Key != "" { status, err = sess.ChangeConfiguration(ctx, body.Key, body.Value) diff --git a/API Server/internal/api/integrations_ankersolix_modbus.go b/API Server/internal/api/integrations_ankersolix_modbus.go index dad9949..bc17d1b 100644 --- a/API Server/internal/api/integrations_ankersolix_modbus.go +++ b/API Server/internal/api/integrations_ankersolix_modbus.go @@ -47,6 +47,12 @@ func (s *Server) ankerModbusAction(w http.ResponseWriter, r *http.Request, who * 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 + case "settings": + // The register map holds five control registers; the charger's own + // configuration — schedules, switches, load balancing — is not among them. + writeError(w, http.StatusBadRequest, + "\"settings\" writes the charger's own configuration, which the register map does not expose. Switch the control mode to Anker cloud (MQTT) to use it.") + return case "clear-limit": // The register takes an explicit ceiling and the charger clamps anything // above its rating, so "no limit" would mean writing a value we would have diff --git a/API Server/internal/api/integrations_ankersolix_mqtt.go b/API Server/internal/api/integrations_ankersolix_mqtt.go index cceda8c..d891f64 100644 --- a/API Server/internal/api/integrations_ankersolix_mqtt.go +++ b/API Server/internal/api/integrations_ankersolix_mqtt.go @@ -19,8 +19,10 @@ package api // 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. Everything the register map or the CSMS can do that this -// cannot is refused by name rather than as an unknown action. +// 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. import ( "context" @@ -94,6 +96,18 @@ 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 "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 + // the LAN at all, is worth being able to trace afterwards. + if len(body.Settings) == 0 { + writeError(w, http.StatusBadRequest, + "settings requires a \"settings\" object, e.g. {\"settings\":{\"ledBrightness\":50}}") + return + } + capability = "mqtt-settings" + params["settings"] = body.Settings + payload["settings"] = body.Settings case "status": capability = "mqtt-status" default: @@ -117,8 +131,9 @@ func (s *Server) ankerMqttAction(w http.ResponseWriter, r *http.Request, who *ca writeJSON(w, http.StatusOK, map[string]any{"status": outcome, "result": json.RawMessage(raw)}) return } - // The plugin answers {serial, command, status, confirmed, detail?}; relay it - // so the caller sees whether the charger acknowledged, not just that we sent. + // The plugin answers {serial, command|applied, status, confirmed, detail?}; + // relay it so the caller sees whether the charger acknowledged, not just that + // we sent. writeJSON(w, http.StatusOK, json.RawMessage(raw)) } diff --git a/API Server/internal/api/integrations_ankersolix_mqtt_test.go b/API Server/internal/api/integrations_ankersolix_mqtt_test.go index 0a9d8be..1837127 100644 --- a/API Server/internal/api/integrations_ankersolix_mqtt_test.go +++ b/API Server/internal/api/integrations_ankersolix_mqtt_test.go @@ -63,6 +63,18 @@ func TestAnkerMqttActionRefusesTurningBoostOff(t *testing.T) { } } +// A settings write with nothing to write is a mistake worth naming, not an empty +// command published to the charger. +func TestAnkerMqttActionRequiresSettingsToWrite(t *testing.T) { + rec := refuse(t, "settings", ankerControlBody{}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("empty settings returned %d, want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), "settings") { + t.Errorf("empty settings answered %q, want it to name the field it wants", rec.Body.String()) + } +} + // The cloud transport signs in as the account, so unlike Modbus it carries the // caller's resolved credentials into the plugin call. func TestAnkerCloudConfigCarriesTheResolvedCredentials(t *testing.T) { diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 51ddea1..4d71b84 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -196,6 +196,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor { {ID: "vehicle", Method: "POST", Endpoint: epVehicleDetail, Description: "Details for one registered vehicle (needs vehicleId)."}, {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-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{ // Credentials are intentionally NOT required at the global (panel) layer, @@ -292,10 +293,12 @@ type invokeParams struct { StartDate string `json:"startDate"` // YYYY-MM-DD, or YYYY-MM / YYYY for month / year EndDate string `json:"endDate"` - // The cloud MQTT actions: which command to send, and the current ceiling - // "limit" carries. - Command string `json:"command"` - Amps float64 `json:"amps"` + // The cloud MQTT actions: which command to send, the current ceiling "limit" + // carries, and the settings "mqtt-settings" writes, by the names the snapshot + // reports them under. + Command string `json:"command"` + Amps float64 `json:"amps"` + Settings map[string]any `json:"settings"` } // Invoke runs a named read-only capability. The upstream response body is @@ -326,12 +329,15 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa // The cloud MQTT actions address the charger itself over the account's broker // rather than a REST endpoint, so they route to that transport instead of the // single-endpoint dispatch below. - if action == "mqtt-status" || action == "mqtt-command" { + if action == "mqtt-status" || action == "mqtt-command" || action == "mqtt-settings" { if pp.SN == "" { return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action) } - if action == "mqtt-status" { + switch action { + case "mqtt-status": return p.mqttStatus(ctx, pp.SN) + case "mqtt-settings": + return p.mqttApplySettings(ctx, pp.SN, pp.Settings) } return p.mqttCommand(ctx, pp.SN, pp.Command, pp.Amps) } diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go index da78e97..e24e345 100644 --- a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -724,12 +724,10 @@ func (p *Plugin) mqttSetMaxCurrent(ctx context.Context, c *mqttConn, model, sn s if err := checkMaxCurrent(amps); err != nil { return err } - // The field carries deciamps, as the register does over Modbus. - frame, err := encodeFrame(msgEVSettings, []cmdField{ - rawField(0xa1, 0x22), - intField(0xa8, int16(amps*10)), - timestampField(time.Now()), - }) + // It is the same wire field the "maxCurrentA" setting writes, so it is built + // from the same table rather than encoded a second time here (mqttsettings.go). + frame, err := encodeSettingCmd(settingCmds[settingIndex["maxCurrentA"]], + map[string]any{"maxCurrentA": amps}, nil) if err != nil { return err } diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go index 2cc9ecb..3c1f8ba 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -71,6 +71,9 @@ const ( msgRealtimeTrigger = "0057" // ask for the fast telemetry stream msgEVSettings = "0100" // the settings group: current limit, brightness, … msgEVMode = "0105" // start / stop / skip delay / boost + msgEVSchedule = "0106" // the charging schedule: switch, mode and times + msgEVBalancing = "010c" // load balancing and the main breaker limit + msgEVSolar = "010e" // solar charging 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 @@ -130,6 +133,8 @@ var evParams = map[byte]mqttField{ 0xaa: {name: "ledBrightness"}, 0xac: {name: "autoRestartSwitch"}, 0xad: {name: "randomDelaySwitch"}, + 0xaf: {name: "swipeUpMode"}, + 0xb0: {name: "swipeDownMode"}, 0xb2: {name: "smartTouchMode"}, 0xb4: {name: "lightOffScheduleSwitch"}, 0xb5: {name: "lightOffStart", unsigned: true, clock: true}, @@ -141,11 +146,18 @@ var evParams = map[byte]mqttField{ 0xd0: {name: "ipAddress"}, 0xd3: {name: "loadBalancing"}, 0xd4: {name: "mainBreakerLimitA"}, + 0xd5: {name: "loadBalanceMonitorMode"}, + 0xd6: {name: "loadBalanceMeterFlag"}, + 0xd7: {name: "loadBalanceMonitorSN"}, 0xd8: {name: "solarBalancing"}, - 0xd9: {name: "chargingMode"}, + // The charger's solar charging mode (grid-assisted or solar only), not the + // charging mode the Modbus map means by that name. + 0xd9: {name: "solarChargeMode"}, 0xda: {name: "solarMinCurrentA"}, 0xdb: {name: "phaseMode"}, + 0xdc: {name: "solarMonitoringMode"}, 0xdd: {name: "autoPhaseSwitch"}, + 0xde: {name: "solarMonitorSN"}, 0xdf: {name: "boostMode"}, 0xe0: {name: "cpSignal"}, 0xe2: {name: "plugged"}, @@ -218,6 +230,22 @@ func timestampField(now time.Time) cmdField { return varField(0xfe, uint32(now.Unix())) } +// clockField builds the two-byte field the charger carries a time of day in: +// the hour and the minute, least significant byte first, which is the same +// layout the decoder reads back as "HH:MM". +func clockField(name byte, hour, minute int) cmdField { + return intField(name, int16(uint16(hour)<<8|uint16(minute))) +} + +// stringField builds a fixed-length text field, zero padded. The length is the +// charger's, not the value's: a serial field is sixteen bytes whether or not the +// serial fills them. +func stringField(name byte, s string, length int) cmdField { + b := make([]byte, length) + copy(b, s) + return cmdField{name: name, typ: typeString, value: b} +} + // encodeFrame builds one command frame for a message type. The caller supplies // every field in wire order, starting with the `a1 01 22` opener each command in // the reference maps carries and ending with the timestamp. diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsettings.go b/API Server/internal/plugins/builtin/ankersolix/mqttsettings.go new file mode 100644 index 0000000..a22885a --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsettings.go @@ -0,0 +1,509 @@ +package ankersolix + +// Writing the charger's settings over Anker's cloud. +// +// The control commands next door in cloudmqtt.go move a session along — start, +// stop, boost, skip the delay. These move the charger's own configuration: what +// it will draw, when it is allowed to, how bright its ring is, whether it speaks +// Modbus on the LAN at all. They are the same values mqttStatus already reads +// back, so a caller can read a snapshot, change one name in it, and send it. +// +// Two things about the wire format shape everything here. +// +// The first is that a setting is not a register write but a *command*, and a +// command owns a set of fields inside a message type. Most own exactly one, so +// writing them is a frame with one field in it. Five own several — the light-off +// schedule, the charging schedule's switch and times, load balancing and solar +// charging — and the charger takes the whole command as the new truth. A +// light-off schedule that arrives carrying only its switch is a schedule whose +// start and end have just been set to midnight. So a grouped write resends the +// siblings the caller did not name, using the values the charger itself last +// reported, and refuses rather than guesses when it has not reported them. +// +// The second is that those values arrive on a different message from the +// telemetry. The fast 0410 stream a realtime trigger turns on carries none of +// them; the settings come on 0405 / 0840 / 0900, which the charger sends when it +// has something to acknowledge. Which is why a grouped write may have to send a +// trigger first purely to make the charger talk about itself. +// +// Every setting below is the reference implementation's (anker-solix-api +// v3.8.1, src/anker_solix_api/mqttcmdmap.py and mqttmap.py, A5191 block) rather +// than anything Anker documents. The ranges and the two inverted switches — plug +// lock and the schedule switch, where 1 is on and 2 is off, unlike every other +// switch on the device — come from there. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +// ---- the writable settings ---------------------------------------------------- + +// settingField is one field of one setting command: where it sits in the frame, +// the name a caller gives it (empty for a field only the charger sets, which is +// carried along unchanged), the decoded state key its current value comes from, +// and how a value becomes bytes. +type settingField struct { + wire byte + key string + state string + encode func(wire byte, v any) (cmdField, error) +} + +// settingCmd is one command the charger accepts: a message type and the fields +// that go with it, in the order they go on the wire. +type settingCmd struct { + msgType string + fields []settingField +} + +// grouped reports whether writing one of this command's settings means resending +// the others. +func (c settingCmd) grouped() bool { return len(c.fields) > 1 } + +// settingCmds is every setting this transport can write, in a stable order — +// which is also the order several settings in one request are sent in. +// +// The message types are the A5191's: 0100 is the device's own settings group, +// 0106 the charging schedule, 010c load balancing and 010e solar charging. +var settingCmds = []settingCmd{ + {msgType: msgEVSettings, fields: []settingField{ + // On this one field 1 is on and 2 is off. It is the charger's convention on + // the plug lock and the schedule switch and on nothing else. + {wire: 0xa3, key: "plugLock", state: "plugLockSwitch", encode: switchOnOff(1, 2)}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xa4, key: "autoStart", state: "autoStartSwitch", encode: switchOnOff(1, 0)}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xa8, key: "maxCurrentA", state: "maxCurrentSetA", encode: maxCurrentValue}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xaa, key: "ledBrightness", state: "ledBrightness", encode: wholeValue(0, 100, 10)}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xac, key: "autoRestart", state: "autoRestartSwitch", encode: switchOnOff(1, 0)}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xad, key: "randomDelay", state: "randomDelaySwitch", encode: switchOnOff(1, 0)}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + {wire: 0xb4, key: "lightOffSchedule", state: "lightOffScheduleSwitch", encode: switchOnOff(1, 0)}, + {wire: 0xb5, key: "lightOffStart", state: "lightOffStart", encode: clockValue}, + {wire: 0xb6, key: "lightOffEnd", state: "lightOffEnd", encode: clockValue}, + }}, + {msgType: msgEVSettings, fields: []settingField{ + // Turning this off is the one setting that can cost you the Modbus control + // mode: the charger stops serving the register map on the LAN. + {wire: 0xb7, key: "modbusEnabled", state: "modbusSwitch", encode: switchOnOff(1, 0)}, + }}, + {msgType: msgEVSchedule, fields: []settingField{ + {wire: 0xa2, key: "scheduleEnabled", state: "scheduleSwitch", encode: switchOnOff(1, 2)}, + {wire: 0xa8, key: "scheduleMode", state: "scheduleMode", encode: optionValue(0, 1)}, + }}, + {msgType: msgEVSchedule, fields: []settingField{ + {wire: 0xa3, key: "weekStart", state: "weekStart", encode: clockValue}, + {wire: 0xa4, key: "weekEnd", state: "weekEnd", encode: clockValue}, + {wire: 0xa5, key: "weekendStart", state: "weekendStart", encode: clockValue}, + {wire: 0xa6, key: "weekendEnd", state: "weekendEnd", encode: clockValue}, + {wire: 0xa7, key: "weekendMode", state: "weekendMode", encode: optionValue(1, 2)}, + }}, + {msgType: msgEVBalancing, fields: []settingField{ + {wire: 0xa3, key: "mainBreakerLimitA", state: "mainBreakerLimitA", encode: signedValue(10, 500, 1, 1)}, + }}, + {msgType: msgEVBalancing, fields: []settingField{ + {wire: 0xa2, key: "loadBalancing", state: "loadBalancing", encode: switchOnOff(1, 0)}, + // The meter this charger balances against, and how it watches it. Nothing + // here can invent them, so they travel back exactly as they arrived. + {wire: 0xa4, state: "loadBalanceMonitorMode", encode: wholeValue(0, 255, 1)}, + {wire: 0xa5, state: "loadBalanceMeterFlag", encode: wholeValue(0, 255, 1)}, + {wire: 0xa6, state: "loadBalanceMonitorSN", encode: serialValue}, + }}, + {msgType: msgEVSolar, fields: []settingField{ + {wire: 0xa2, key: "solarBalancing", state: "solarBalancing", encode: switchOnOff(1, 0)}, + {wire: 0xa3, key: "solarChargeMode", state: "solarChargeMode", encode: optionValue(0, 1)}, + {wire: 0xa4, key: "solarMinCurrentA", state: "solarMinCurrentA", encode: signedValue(currentPauseFloor, 32, 1, 1)}, + // This command offers automatic and single-phase only; the three-phase + // setting the Modbus register takes has no place in it. + {wire: 0xa5, key: "phaseMode", state: "phaseMode", encode: optionValue(0, 1)}, + {wire: 0xa6, state: "solarMonitoringMode", encode: wholeValue(0, 255, 1)}, + {wire: 0xa7, key: "autoPhaseSwitching", state: "autoPhaseSwitch", encode: switchOnOff(1, 0)}, + {wire: 0xa8, state: "solarMonitorSN", encode: serialValue}, + }}, +} + +// settingIndex maps a caller's name to the command that carries it, built once +// from settingCmds so the table above stays the only place a setting is defined. +var settingIndex = func() map[string]int { + idx := map[string]int{} + for i, c := range settingCmds { + for _, f := range c.fields { + if f.key != "" { + idx[f.key] = i + } + } + } + return idx +}() + +// settingNames lists every writable setting, for the error a mistyped name gets. +func settingNames() []string { + out := make([]string, 0, len(settingIndex)) + for k := range settingIndex { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// ---- value encoders ----------------------------------------------------------- + +// switchOnOff encodes a boolean field, given the values this particular switch +// uses for on and off — which are 1 and 0 everywhere except the plug lock and +// the schedule switch, where they are 1 and 2. +func switchOnOff(on, off uint8) func(byte, any) (cmdField, error) { + return func(wire byte, v any) (cmdField, error) { + b, err := asBool(v) + if err != nil { + return cmdField{}, err + } + if b { + return uintField(wire, on), nil + } + return uintField(wire, off), nil + } +} + +// wholeValue encodes a one-byte number, checking the range and step the charger +// accepts for it. +func wholeValue(min, max, step float64) func(byte, any) (cmdField, error) { + return func(wire byte, v any) (cmdField, error) { + n, err := asNumber(v) + if err != nil { + return cmdField{}, err + } + if err := checkRange(n, min, max, step); err != nil { + return cmdField{}, err + } + return uintField(wire, uint8(n)), nil + } +} + +// signedValue encodes a two-byte number. scale is what the field carries the +// value in: 10 for the current fields, which are deciamps on the wire. +func signedValue(min, max, step, scale float64) func(byte, any) (cmdField, error) { + return func(wire byte, v any) (cmdField, error) { + n, err := asNumber(v) + if err != nil { + return cmdField{}, err + } + if err := checkRange(n, min, max, step); err != nil { + return cmdField{}, err + } + return intField(wire, int16(n*scale)), nil + } +} + +// optionValue encodes a one-byte field that takes a fixed set of values, and +// says which they were when it is given something else. +func optionValue(allowed ...uint8) func(byte, any) (cmdField, error) { + return func(wire byte, v any) (cmdField, error) { + n, err := asNumber(v) + if err != nil { + return cmdField{}, err + } + for _, a := range allowed { + if float64(a) == n { + return uintField(wire, a), nil + } + } + names := make([]string, len(allowed)) + for i, a := range allowed { + names[i] = strconv.Itoa(int(a)) + } + return cmdField{}, fmt.Errorf("anker-solix: %v is not one of %s", v, strings.Join(names, ", ")) + } +} + +// maxCurrentValue encodes the charging ceiling, applying the same rule the other +// two transports do — a limit below the charger's floor is a pause in disguise. +func maxCurrentValue(wire byte, v any) (cmdField, error) { + amps, err := asNumber(v) + if err != nil { + return cmdField{}, err + } + if err := checkMaxCurrent(amps); err != nil { + return cmdField{}, err + } + return intField(wire, int16(amps*10)), nil +} + +// clockValue encodes a time of day, written the way the charger reports it. +func clockValue(wire byte, v any) (cmdField, error) { + s, ok := v.(string) + if !ok { + return cmdField{}, fmt.Errorf("anker-solix: %v is not a time of day; write it as \"HH:MM\"", v) + } + h, m, err := parseClock(s) + if err != nil { + return cmdField{}, err + } + return clockField(wire, h, m), nil +} + +// serialValue encodes a device serial, in the sixteen bytes the charger keeps +// one in. +func serialValue(wire byte, v any) (cmdField, error) { + s, ok := v.(string) + if !ok { + return cmdField{}, fmt.Errorf("anker-solix: %v is not a device serial", v) + } + if len(s) > 16 { + return cmdField{}, fmt.Errorf("anker-solix: device serial %q is longer than the charger's 16 characters", s) + } + return stringField(wire, s, 16), nil +} + +// parseClock reads "HH:MM". +func parseClock(s string) (int, int, error) { + t, err := time.Parse("15:04", strings.TrimSpace(s)) + if err != nil { + return 0, 0, fmt.Errorf("anker-solix: %q is not a time of day; write it as \"HH:MM\"", s) + } + return t.Hour(), t.Minute(), nil +} + +// checkRange holds a number to the range and step the charger takes. +func checkRange(n, min, max, step float64) error { + if n < min || n > max { + return fmt.Errorf("anker-solix: %g is outside the range the charger accepts (%g-%g)", n, min, max) + } + if step > 1 { + if r := n - min; r != float64(int(r/step))*step { + return fmt.Errorf("anker-solix: %g is not a step of %g between %g and %g", n, step, min, max) + } + } + return nil +} + +// asBool reads a switch value. JSON gives a bool; a caller echoing a snapshot +// back may give a number, and a form may give a word. +func asBool(v any) (bool, error) { + switch t := v.(type) { + case bool: + return t, nil + case float64: + return t != 0, nil + case string: + switch strings.ToLower(strings.TrimSpace(t)) { + case "on", "true", "yes", "1": + return true, nil + case "off", "false", "no", "0": + return false, nil + } + } + return false, fmt.Errorf("anker-solix: %v is not on or off", v) +} + +// asNumber reads a numeric value, accepting the string form a query parameter or +// a form field would carry it in. +func asNumber(v any) (float64, error) { + switch t := v.(type) { + case float64: + return t, nil + case bool: + if t { + return 1, nil + } + return 0, nil + case string: + if n, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err == nil { + return n, nil + } + } + return 0, fmt.Errorf("anker-solix: %v is not a number", v) +} + +// ---- writing ------------------------------------------------------------------ + +// mqttSettingsDoc is what a settings write answers with. As with a control +// command, publishing is fire-and-forget: confirmed says the charger sent its +// settings back within the window, not that the write either succeeded or was +// the only thing that could have changed them. +type mqttSettingsDoc struct { + Serial string `json:"serial"` + Applied []string `json:"applied"` + Commands int `json:"commands"` + Status string `json:"status"` + Confirmed bool `json:"confirmed"` + Detail string `json:"detail,omitempty"` +} + +// mqttApplySettings writes one or more of the charger's settings over the cloud. +// Every value is validated before anything is published, so a request naming one +// bad setting changes nothing rather than half of what it asked for. +func (p *Plugin) mqttApplySettings(ctx context.Context, sn string, values map[string]any) (json.RawMessage, error) { + if len(values) == 0 { + return nil, fmt.Errorf("anker-solix: no settings given; send one or more of %s", strings.Join(settingNames(), ", ")) + } + // Group the request by the command that carries each setting, so two settings + // on the same command travel in one frame rather than overwriting each other. + wanted := map[int]map[string]any{} + var applied []string + for k, v := range values { + i, ok := settingIndex[k] + if !ok { + return nil, fmt.Errorf("anker-solix: %q is not a writable setting; the writable ones are %s", k, strings.Join(settingNames(), ", ")) + } + if wanted[i] == nil { + wanted[i] = map[string]any{} + } + wanted[i][k] = v + applied = append(applied, k) + } + sort.Strings(applied) + + // Encode what the caller supplied before touching the cloud: a bad value + // should cost a validation error, not a sign-in and a broker connection. + needState := false + for i, given := range wanted { + cmd := settingCmds[i] + for _, f := range cmd.fields { + if v, ok := given[f.key]; ok && f.key != "" { + if _, err := f.encode(f.wire, v); err != nil { + return nil, settingError(f.key, err) + } + } else if cmd.grouped() { + needState = true + } + } + } + + model, err := p.chargerModel(ctx, sn) + if err != nil { + return nil, err + } + conn, err := p.mqttClient(ctx) + if err != nil { + return nil, err + } + // Listen before writing: the charger answers with its settings, and a + // subscription made afterwards would miss the answer. + if err := conn.listen(ctx, model, sn); err != nil { + return nil, err + } + + state := map[string]any{} + if needState { + if state, err = p.mqttSettingsState(ctx, conn, model, sn); err != nil { + return nil, err + } + } + + frames := make([][]byte, 0, len(wanted)) + for i := range settingCmds { + given, ok := wanted[i] + if !ok { + continue + } + frame, err := encodeSettingCmd(settingCmds[i], given, state) + if err != nil { + return nil, err + } + frames = append(frames, frame) + } + + _, _, before, _ := conn.snapshotOf(sn) + for _, frame := range frames { + if err := conn.publishFrame(ctx, model, sn, frame, 0); err != nil { + return nil, err + } + } + + confirmed, waitErr := conn.waitFor(ctx, sn, func(st *deviceState) bool { + return st.settingsAt.After(before) + }, commandWait) + doc := mqttSettingsDoc{ + Serial: sn, + Applied: applied, + Commands: len(frames), + Status: "accepted", + Confirmed: confirmed, + } + if waitErr != nil { + doc.Detail = "sent, but the cloud connection dropped before the charger confirmed it" + } else if !confirmed { + doc.Detail = "sent; the charger has not confirmed it yet" + } + return json.Marshal(doc) +} + +// encodeSettingCmd builds one command's frame: the fields the caller named +// carrying their new values, and the rest carrying what the charger last +// reported. A sibling the charger has never reported stops the write, because +// the alternative is sending a made-up value the charger would then adopt. +func encodeSettingCmd(cmd settingCmd, given, state map[string]any) ([]byte, error) { + fields := []cmdField{rawField(0xa1, 0x22)} + for _, f := range cmd.fields { + name := f.key + if name == "" { + name = f.state + } + v, ok := any(nil), false + if f.key != "" { + v, ok = given[f.key] + } + if !ok { + if v, ok = state[f.state]; !ok { + return nil, fmt.Errorf( + "anker-solix: this setting is sent together with %s, which the charger has not reported yet; read its status first", name) + } + } + enc, err := f.encode(f.wire, v) + if err != nil { + return nil, settingError(name, err) + } + fields = append(fields, enc) + } + fields = append(fields, timestampField(time.Now())) + return encodeFrame(cmd.msgType, fields) +} + +// settingError names the setting a value was rejected for. The checks it wraps +// are shared with the other transports and carry the package's own prefix, so it +// is trimmed rather than repeated. +func settingError(name string, err error) error { + return fmt.Errorf("anker-solix: %s: %s", name, strings.TrimPrefix(err.Error(), "anker-solix: ")) +} + +// mqttSettingsState returns what the charger last said it is set to, which a +// grouped write needs to leave the fields it is not changing alone. The settings +// arrive on their own message rather than with the telemetry stream, and the +// charger sends one when it has something to acknowledge — so a connection that +// has not heard any yet sends a trigger to prompt one. +func (p *Plugin) mqttSettingsState(ctx context.Context, conn *mqttConn, model, sn string) (map[string]any, error) { + values, _, settingsAt, _ := conn.snapshotOf(sn) + if !settingsAt.IsZero() { + return values, nil + } + if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil { + return nil, err + } + ok, err := conn.waitFor(ctx, sn, func(st *deviceState) bool { + return !st.settingsAt.IsZero() + }, statusWait) + if err != nil { + return nil, err + } + if !ok { + return nil, errors.New("anker-solix: the charger has not reported its current settings, and this setting cannot be changed without them; it may be offline") + } + values, _, _, _ = conn.snapshotOf(sn) + return values, nil +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsettings_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttsettings_test.go new file mode 100644 index 0000000..e2f5f64 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsettings_test.go @@ -0,0 +1,218 @@ +package ankersolix + +import ( + "strings" + "testing" +) + +// cmdFor finds the command that carries a setting, the way a write does. +func cmdFor(t *testing.T, key string) settingCmd { + t.Helper() + i, ok := settingIndex[key] + if !ok { + t.Fatalf("%q is not a writable setting", key) + } + return settingCmds[i] +} + +// A single-field setting is one field between the opener and the timestamp, so +// the frame is checkable byte for byte up to the clock. +func TestEncodeSettingCmdWritesOneField(t *testing.T) { + frame, err := encodeSettingCmd(cmdFor(t, "ledBrightness"), map[string]any{"ledBrightness": 50.0}, nil) + if err != nil { + t.Fatalf("encodeSettingCmd: %v", err) + } + got := encodeHex(frame) + // header (type 0100), the a1 opener, aa as a one-byte 50, then fe and the sum. + if want := "ff09180003000f0100a10122aa020132fe0503"; !strings.HasPrefix(got, want) { + t.Fatalf("frame = %s\nwant prefix %s", got, want) + } + if sum := xorChecksum(frame); sum != 0 { + t.Errorf("XOR over the whole frame = %02x, want 00", sum) + } +} + +// Plug lock and the schedule switch are the two fields where on is 1 and off is +// 2. Getting that backwards would silently invert them, so both are pinned. +func TestSwitchesUseTheirOwnOnOffValues(t *testing.T) { + cases := []struct { + key string + on bool + want byte + }{ + {"plugLock", true, 1}, + {"plugLock", false, 2}, + {"autoStart", true, 1}, + {"autoStart", false, 0}, + } + for _, tc := range cases { + cmd := cmdFor(t, tc.key) + var field settingField + for _, f := range cmd.fields { + if f.key == tc.key { + field = f + } + } + got, err := field.encode(field.wire, tc.on) + if err != nil { + t.Fatalf("%s(%v): %v", tc.key, tc.on, err) + } + if len(got.value) != 1 || got.value[0] != tc.want { + t.Errorf("%s(%v) = %v, want %d", tc.key, tc.on, got.value, tc.want) + } + } +} + +// The charger reads a grouped command as the whole truth, so a write that names +// one of its settings must carry the others as the charger last reported them. +func TestGroupedSettingResendsSiblingsFromState(t *testing.T) { + state := map[string]any{ + "lightOffScheduleSwitch": 1.0, + "lightOffStart": "22:30", + "lightOffEnd": "06:15", + } + frame, err := encodeSettingCmd(cmdFor(t, "lightOffSchedule"), + map[string]any{"lightOffSchedule": false}, state) + if err != nil { + t.Fatalf("encodeSettingCmd: %v", err) + } + fields := fieldsOf(t, frame) + // b4 the switch, then the two times as hour<<8|minute, least significant byte + // first — 22:30 and 06:15 exactly as the charger reported them. + if got := encodeHex(fields[0xb4]); got != "00" { + t.Errorf("switch = %s, want 00 — the value the write asked for", got) + } + if got := encodeHex(fields[0xb5]); got != "1e16" { + t.Errorf("start = %s, want 1e16 (22:30) — the charger's own value, carried through", got) + } + if got := encodeHex(fields[0xb6]); got != "0f06" { + t.Errorf("end = %s, want 0f06 (06:15) — the charger's own value, carried through", got) + } +} + +// A sibling the charger has never reported is the one case where the write +// stops: sending a made-up value would set it for real. +func TestGroupedSettingRefusesWithoutState(t *testing.T) { + _, err := encodeSettingCmd(cmdFor(t, "lightOffSchedule"), + map[string]any{"lightOffSchedule": true}, map[string]any{}) + if err == nil { + t.Fatal("a grouped write with no state was accepted") + } + if !strings.Contains(err.Error(), "lightOffStart") { + t.Errorf("error does not name the missing sibling: %v", err) + } +} + +// The load-balancing group carries a meter serial nothing here can invent, so it +// travels back exactly as the charger reported it. +func TestLoadBalancingCarriesTheMeterBack(t *testing.T) { + state := map[string]any{ + "loadBalancing": 0.0, + "loadBalanceMonitorMode": 1.0, + "loadBalanceMeterFlag": 1.0, + "loadBalanceMonitorSN": "SM1234567890", + } + frame, err := encodeSettingCmd(cmdFor(t, "loadBalancing"), + map[string]any{"loadBalancing": true}, state) + if err != nil { + t.Fatalf("encodeSettingCmd: %v", err) + } + if got := encodeHex(frame[7:9]); got != msgEVBalancing { + t.Errorf("message type = %s, want %s", got, msgEVBalancing) + } + if !strings.Contains(string(frame), "SM1234567890") { + t.Errorf("the meter serial is not in the frame: %s", encodeHex(frame)) + } +} + +// Values are checked against the ranges the charger takes, before anything is +// published — a step, a bound, a clock and a name are all caught here. +func TestSettingValuesAreValidated(t *testing.T) { + cases := []struct { + name string + key string + val any + }{ + {"brightness off its 10% step", "ledBrightness", 55.0}, + {"brightness above 100", "ledBrightness", 120.0}, + {"breaker limit below 10 A", "mainBreakerLimitA", 5.0}, + {"breaker limit above 500 A", "mainBreakerLimitA", 600.0}, + {"a current that pauses instead of charging", "maxCurrentA", 3.0}, + {"a schedule mode the charger has no name for", "scheduleMode", 7.0}, + {"a time that is not a time", "weekStart", "half past nine"}, + {"a switch that is not a switch", "autoStart", "maybe"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := cmdFor(t, tc.key) + for _, f := range cmd.fields { + if f.key != tc.key { + continue + } + if _, err := f.encode(f.wire, tc.val); err == nil { + t.Errorf("%s = %v was accepted", tc.key, tc.val) + } + } + }) + } +} + +// Amps go on the wire in deciamps, the same as the Modbus register carries them. +func TestMaxCurrentIsWrittenInDeciamps(t *testing.T) { + frame, err := encodeSettingCmd(cmdFor(t, "maxCurrentA"), map[string]any{"maxCurrentA": 16.0}, nil) + if err != nil { + t.Fatalf("encodeSettingCmd: %v", err) + } + // 16 A is 160 on the wire, two bytes, least significant first. + if got := encodeHex(fieldsOf(t, frame)[0xa8]); got != "a000" { + t.Errorf("a8 = %s, want a000 (160 deciamps)", got) + } +} + +// Two settings on the same command belong in one frame; two on different +// commands are two frames, in the table's order. +func TestSettingsGroupByCommand(t *testing.T) { + byCmd := map[int]bool{} + for _, k := range []string{"weekStart", "weekEnd", "ledBrightness"} { + byCmd[settingIndex[k]] = true + } + if len(byCmd) != 2 { + t.Errorf("three settings landed on %d commands, want 2", len(byCmd)) + } + if settingIndex["weekStart"] != settingIndex["weekEnd"] { + t.Error("the two week times are not on the same command") + } +} + +// Every settable name has a decode entry behind it, so a caller can read a +// snapshot and send the same names back. +func TestEverySettingReadsBack(t *testing.T) { + decoded := map[string]bool{} + for _, f := range evParams { + decoded[f.name] = true + } + for _, cmd := range settingCmds { + for _, f := range cmd.fields { + if !decoded[f.state] { + t.Errorf("setting %q reads its current value from %q, which nothing decodes", f.key, f.state) + } + } + } +} + +// fieldsOf splits a command frame into its data fields, keyed by wire name and +// stripped of the value type. A command message type has no decode map of its +// own — the charger reports these values back on a different message — so a +// command frame is checked as the bytes it is. +func fieldsOf(t *testing.T, frame []byte) map[byte][]byte { + t.Helper() + raw, ok := splitFields(frame, frameHeaderLen) + if !ok { + t.Fatalf("frame does not divide into whole data fields: %s", encodeHex(frame)) + } + out := map[byte][]byte{} + for _, f := range raw { + out[f.name] = f.value + } + return out +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go index 1c5e062..d0f332b 100644 --- a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go @@ -58,10 +58,9 @@ type MqttSnapshot struct { StartCountdownSeconds *float64 `json:"startCountdownSeconds,omitempty"` ChargingWindowSeconds *float64 `json:"chargingWindowSeconds,omitempty"` - PhaseMode *int `json:"phaseMode,omitempty"` - ChargingMode *int `json:"chargingMode,omitempty"` - BoostMode *bool `json:"boostMode,omitempty"` - Plugged *bool `json:"plugged,omitempty"` + PhaseMode *int `json:"phaseMode,omitempty"` + BoostMode *bool `json:"boostMode,omitempty"` + Plugged *bool `json:"plugged,omitempty"` CPSignal *int `json:"cpSignal,omitempty"` CPSignalDesc string `json:"cpSignalDesc,omitempty"` @@ -105,6 +104,17 @@ type MqttSettings struct { MainBreakerLimitA *float64 `json:"mainBreakerLimitA,omitempty"` SolarMinCurrentA *float64 `json:"solarMinCurrentA,omitempty"` AutoPhaseSwitching *bool `json:"autoPhaseSwitching,omitempty"` + + // The rest of what a settings write can change, so a caller can read a + // snapshot, change one name in it and send it back (see mqttsettings.go). + ScheduleMode *int `json:"scheduleMode,omitempty"` + WeekendMode *int `json:"weekendMode,omitempty"` + LightOffSchedule *bool `json:"lightOffSchedule,omitempty"` + LightOffStart string `json:"lightOffStart,omitempty"` + LightOffEnd string `json:"lightOffEnd,omitempty"` + // SolarChargeMode is 0 for solar with grid support and 1 for solar only. It + // is not the Modbus snapshot's chargingMode, which is a different register. + SolarChargeMode *int `json:"solarChargeMode,omitempty"` } // MqttLocalAccess is the charger's own view of its Modbus TCP server. @@ -298,7 +308,7 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { snap.PlugCountdownSeconds = num("plugCountdownSeconds") snap.StartCountdownSeconds = num("startCountdownSeconds") snap.ChargingWindowSeconds = num("chargingWindowSeconds") - snap.PhaseMode, snap.ChargingMode = whole("phaseMode"), whole("chargingMode") + snap.PhaseMode = whole("phaseMode") snap.BoostMode, snap.Plugged = flag("boostMode"), flag("plugged") snap.LoadBalancing, snap.SolarBalancing = flag("loadBalancing"), flag("solarBalancing") snap.LEDBrightness = whole("ledBrightness") @@ -334,6 +344,12 @@ func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { WeekEnd: text("weekEnd"), WeekendStart: text("weekendStart"), WeekendEnd: text("weekendEnd"), + ScheduleMode: whole("scheduleMode"), + WeekendMode: whole("weekendMode"), + LightOffSchedule: flag("lightOffScheduleSwitch"), + LightOffStart: text("lightOffStart"), + LightOffEnd: text("lightOffEnd"), + SolarChargeMode: whole("solarChargeMode"), } // Both of these read 1 for on and 2 for off, which is the charger's own // convention on these two registers and nowhere else.