package ankersolix // What a charger reports over the cloud, and the two capabilities built on it. // // The snapshot below deliberately borrows the field names ModbusSnapshot uses // for the same quantities — status, voltageL1, powerTotal, sessionWh, settings — // because they are the same charger read two ways, and a view that can render // one should not need a second layout for the other. Where the transports differ // the names differ with them: the cloud carries the boost flag and the plug and // start countdowns, which no register holds, while the register map carries the // relay temperatures and the reactive and apparent power, which no cloud message // sends. import ( "context" "encoding/json" "fmt" "strings" "time" ) // MqttSnapshot is one charger's state as the cloud reports it. A field the // charger has not sent stays nil rather than zero, so "not reported" and "zero" // stay distinguishable. type MqttSnapshot struct { Serial string `json:"serial"` Model string `json:"model,omitempty"` Status *int `json:"status,omitempty"` StatusDesc string `json:"statusDesc,omitempty"` // Mode is the operational mode the charger is effectively in and ModeOptions // the ones it can be moved to, derived exactly as the cloud view derives // them — except that here the boost flag and the countdowns they depend on // are actually available. Mode string `json:"mode,omitempty"` ModeOptions []string `json:"modeOptions,omitempty"` VoltageL1 *float64 `json:"voltageL1,omitempty"` VoltageL2 *float64 `json:"voltageL2,omitempty"` VoltageL3 *float64 `json:"voltageL3,omitempty"` CurrentL1 *float64 `json:"currentL1,omitempty"` CurrentL2 *float64 `json:"currentL2,omitempty"` CurrentL3 *float64 `json:"currentL3,omitempty"` PowerL1 *float64 `json:"powerL1,omitempty"` PowerL2 *float64 `json:"powerL2,omitempty"` PowerL3 *float64 `json:"powerL3,omitempty"` PowerTotal *float64 `json:"powerTotal,omitempty"` SessionSeconds *float64 `json:"sessionSeconds,omitempty"` SessionWh *float64 `json:"sessionWh,omitempty"` // The countdowns the charger runs before a session: how long it will wait for // a plug, and how long a start delay still has to go. They are why a charger // that has been told to start can sit in "preparing" without being broken. PlugCountdownSeconds *float64 `json:"plugCountdownSeconds,omitempty"` StartCountdownSeconds *float64 `json:"startCountdownSeconds,omitempty"` ChargingWindowSeconds *float64 `json:"chargingWindowSeconds,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"` OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` LoadBalancing *bool `json:"loadBalancing,omitempty"` SolarBalancing *bool `json:"solarBalancing,omitempty"` LEDBrightness *int `json:"ledBrightness,omitempty"` MinCurrentA *float64 `json:"minCurrentA,omitempty"` MaxCurrentA *float64 `json:"maxCurrentA,omitempty"` Settings *MqttSettings `json:"settings,omitempty"` // Local reports what the charger says about its own LAN side: whether Modbus // TCP is switched on, and at which address. It is the one answer the Modbus // mode's setup screen otherwise has to be given by hand. Local *MqttLocalAccess `json:"local,omitempty"` // TelemetryAt and SettingsAt are when each half of the snapshot last arrived; // Live says the fast stream is currently flowing. TelemetryAt string `json:"telemetryAt,omitempty"` SettingsAt string `json:"settingsAt,omitempty"` Live bool `json:"live"` } // MqttSettings is what the charger is set to, as opposed to what it is doing — // the same distinction ModbusSettings draws over the register map. type MqttSettings struct { MaxCurrentA *float64 `json:"maxCurrentA,omitempty"` AutoStart *bool `json:"autoStart,omitempty"` AutoRestart *bool `json:"autoRestart,omitempty"` RandomDelay *bool `json:"randomDelay,omitempty"` PlugLock *bool `json:"plugLock,omitempty"` ScheduleEnabled *bool `json:"scheduleEnabled,omitempty"` WeekStart string `json:"weekStart,omitempty"` WeekEnd string `json:"weekEnd,omitempty"` WeekendStart string `json:"weekendStart,omitempty"` WeekendEnd string `json:"weekendEnd,omitempty"` 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. type MqttLocalAccess struct { ModbusEnabled *bool `json:"modbusEnabled,omitempty"` Host string `json:"host,omitempty"` Port *int `json:"port,omitempty"` TimeoutSeconds *int `json:"timeoutSeconds,omitempty"` } // ---- the capabilities -------------------------------------------------------- // mqttStatus reads one charger's state over the cloud. The charger publishes // nothing unless asked, so this arms the telemetry trigger and waits for the // next frame; inside an already-armed window the frame that has since arrived // answers immediately. func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, error) { model, err := p.chargerModel(ctx, sn) if err != nil { return nil, err } conn, err := p.mqttClient(ctx) if err != nil { return nil, err } if err := conn.listen(ctx, model, sn); err != nil { return nil, err } // Re-arm whenever the window is spent or close to it, so a poll never lands // in the gap between the last frame and the trigger expiring. _, _, _, triggered := conn.snapshotOf(sn) if time.Until(triggered) < triggerRenew { if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil { return nil, err } } // Anything older than the trigger's own interval is stale; wait for the next. cutoff := time.Now().Add(-triggerRenew) live, err := conn.waitFor(ctx, sn, func(st *deviceState) bool { return st.telemetryAt.After(cutoff) }, statusWait) if err != nil { return nil, err } values, telemetryAt, settingsAt, _ := conn.snapshotOf(sn) if len(values) == 0 { return nil, fmt.Errorf("anker-solix: charger %s did not answer over the cloud; it may be offline", sn) } snap := projectMqttSnapshot(sn, model, values) snap.Live = live if !telemetryAt.IsZero() { snap.TelemetryAt = telemetryAt.UTC().Format(time.RFC3339) } if !settingsAt.IsZero() { snap.SettingsAt = settingsAt.UTC().Format(time.RFC3339) } return json.Marshal(snap) } // mqttCommandDoc is what a cloud command answers with. Confirmed says the // charger sent a message back within commandWait: publishing is fire-and-forget, // so an unconfirmed command is not a failed one — it is one whose effect has not // been seen yet. type mqttCommandDoc struct { Serial string `json:"serial"` Command string `json:"command"` Status string `json:"status"` Confirmed bool `json:"confirmed"` Detail string `json:"detail,omitempty"` } // mqttCommands maps the names this transport accepts to the charger mode each // one asks for. The short names are what the control endpoint sends; the long // ones are the mode names the snapshot reports in modeOptions, so a caller can // send back what it was offered. var mqttCommands = map[string]string{ "start": modeStartCharge, modeStartCharge: modeStartCharge, "stop": modeStopCharge, modeStopCharge: modeStopCharge, "boost": modeBoostCharge, modeBoostCharge: modeBoostCharge, "skip-delay": modeSkipDelay, modeSkipDelay: modeSkipDelay, } // 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 // sign-in, a broker connection and a certificate fetch to be told no. command = strings.ToLower(strings.TrimSpace(command)) mode, isMode := mqttCommands[command] switch { case isMode: case command == "limit": if err := checkMaxCurrent(amps); err != nil { return nil, err } case command == "trigger": default: return nil, fmt.Errorf("anker-solix: %q is not a cloud command (start, stop, boost, skip-delay, limit, trigger)", command) } 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 commanding: the charger confirms a control change with a // message, and a subscription made afterwards would miss it. if err := conn.listen(ctx, model, sn); err != nil { return nil, err } _, _, before, _ := conn.snapshotOf(sn) switch { case isMode: err = p.mqttSetMode(ctx, conn, model, sn, mode) case command == "limit": err = p.mqttSetMaxCurrent(ctx, conn, model, sn, amps) default: err = p.mqttTrigger(ctx, conn, model, sn, triggerWindow) } if err != nil { return nil, err } // 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 { return st.settingsAt.After(before) }, commandWait) doc := mqttCommandDoc{Serial: sn, Command: command, Status: "accepted", Confirmed: confirmed} if waitErr != nil { // The command left; only the confirmation did not. Say so rather than // reporting a failure the charger may well have acted on. 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) } // ---- projection -------------------------------------------------------------- // projectMqttSnapshot turns the named values collected from a charger's messages // into the snapshot. Every read is by name and optional: a message type we have // not seen simply leaves its fields unset. func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { snap := MqttSnapshot{Serial: sn, Model: model} num := func(key string) *float64 { f, ok := v[key].(float64) if !ok { return nil } return &f } whole := func(key string) *int { f, ok := v[key].(float64) if !ok { return nil } n := int(f) return &n } flag := func(key string) *bool { f, ok := v[key].(float64) if !ok { return nil } b := f != 0 return &b } text := func(key string) string { s, _ := v[key].(string) return strings.TrimSpace(s) } snap.VoltageL1, snap.VoltageL2, snap.VoltageL3 = num("voltageL1"), num("voltageL2"), num("voltageL3") snap.CurrentL1, snap.CurrentL2, snap.CurrentL3 = num("currentL1"), num("currentL2"), num("currentL3") snap.PowerL1, snap.PowerL2, snap.PowerL3 = num("powerL1"), num("powerL2"), num("powerL3") snap.PowerTotal = num("powerTotal") snap.SessionSeconds, snap.SessionWh = num("sessionSeconds"), num("sessionWh") snap.PlugCountdownSeconds = num("plugCountdownSeconds") snap.StartCountdownSeconds = num("startCountdownSeconds") snap.ChargingWindowSeconds = num("chargingWindowSeconds") snap.PhaseMode = whole("phaseMode") snap.BoostMode, snap.Plugged = flag("boostMode"), flag("plugged") snap.LoadBalancing, snap.SolarBalancing = flag("loadBalancing"), flag("solarBalancing") snap.LEDBrightness = whole("ledBrightness") snap.MinCurrentA, snap.MaxCurrentA = num("minCurrentA"), num("maxCurrentA") if s := whole("status"); s != nil { snap.Status, snap.StatusDesc = s, statusName(*s) } if s := whole("ocppStatus"); s != nil { snap.OcppStatus, snap.OcppStatusDesc = s, ocppStatusNames[*s] } if s := whole("cpSignal"); s != nil { snap.CPSignal, snap.CPSignalDesc = s, cpSignalNames[*s] } // The mode the cloud view can only guess at, with the two countdowns and the // boost flag it never sees. if snap.StatusDesc != "" { boost := snap.BoostMode != nil && *snap.BoostMode snap.Mode = chargerMode(snap.StatusDesc, boost, intOrZero(snap.PlugCountdownSeconds), intOrZero(snap.StartCountdownSeconds)) snap.ModeOptions = chargerModeOptions(snap.Mode, snap.StatusDesc) } set := &MqttSettings{ MaxCurrentA: num("maxCurrentSetA"), AutoStart: flag("autoStartSwitch"), AutoRestart: flag("autoRestartSwitch"), RandomDelay: flag("randomDelaySwitch"), MainBreakerLimitA: num("mainBreakerLimitA"), SolarMinCurrentA: num("solarMinCurrentA"), AutoPhaseSwitching: flag("autoPhaseSwitch"), WeekStart: text("weekStart"), 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. if s := whole("plugLockSwitch"); s != nil { b := *s == 1 set.PlugLock = &b } if s := whole("scheduleSwitch"); s != nil { b := *s == 1 set.ScheduleEnabled = &b } if *set != (MqttSettings{}) { snap.Settings = set } local := &MqttLocalAccess{ ModbusEnabled: flag("modbusSwitch"), Host: text("ipAddress"), Port: whole("modbusPort"), TimeoutSeconds: whole("modbusTimeoutSeconds"), } if *local != (MqttLocalAccess{}) { snap.Local = local } return snap } // intOrZero reads an optional number as an int, treating "not reported" as zero // — which is what the mode derivation means by a countdown that is not running. func intOrZero(v *float64) int { if v == nil { return 0 } return int(*v) }