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)}, }}, // The panel's own three: what a swipe up, a swipe down and a touch do. The // charger has always reported them and the card has always shown them; they // are settings like any other on this message, so they are writable here too. {msgType: msgEVSettings, fields: []settingField{ {wire: 0xaf, key: "swipeUpMode", state: "swipeUpMode", encode: optionValue(0, 1, 2, 3)}, }}, {msgType: msgEVSettings, fields: []settingField{ {wire: 0xb0, key: "swipeDownMode", state: "swipeDownMode", encode: optionValue(0, 1, 2, 3)}, }}, {msgType: msgEVSettings, fields: []settingField{ {wire: 0xb2, key: "smartTouchMode", state: "smartTouchMode", encode: optionValue(0, 1)}, }}, {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 }