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"` // What the charger says it is, on the same names the register map uses for // the same three answers. The reference decodes none of them; see the // version fields in mqttframe.go for how they are read. Firmware string `json:"firmware,omitempty"` ControllerVersion string `json:"controllerVersion,omitempty"` Hardware string `json:"hardware,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 session's own three energies and when it began. The register map has // neither: a session is a cloud idea, and only this transport counts it. SessionWhL1 *float64 `json:"sessionWhL1,omitempty"` SessionWhL2 *float64 `json:"sessionWhL2,omitempty"` SessionWhL3 *float64 `json:"sessionWhL3,omitempty"` SessionStartedAt *float64 `json:"sessionStartedAt,omitempty"` // unix seconds OrderID *float64 `json:"orderId,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"` // Where the charge is coming from — 0 off or paused, 1 grid, 7 solar. The // reference marks this reading uncertain, so it is reported as the number it // is and named for what it distinguishes rather than folded into a mode. ChargingSource *int `json:"chargingSource,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"` // The panel's three gestures: what a swipe up, a swipe down and a touch do. SwipeUpMode *int `json:"swipeUpMode,omitempty"` SwipeDownMode *int `json:"swipeDownMode,omitempty"` SmartTouchMode *int `json:"smartTouchMode,omitempty"` // What the two balancing features are watching. The reference has not pinned // down what the two modes and the flag select, so they are reported as the // numbers they are; the serials name the meter and the monitor themselves, // and nothing outside the charger knows them. LoadBalanceMonitorMode *int `json:"loadBalanceMonitorMode,omitempty"` LoadBalanceMeterFlag *int `json:"loadBalanceMeterFlag,omitempty"` LoadBalanceMonitorSN string `json:"loadBalanceMonitorSN,omitempty"` SolarMonitoringMode *int `json:"solarMonitoringMode,omitempty"` SolarMonitorSN string `json:"solarMonitorSN,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"` // Extra is everything else the charger sent: the values its messages carry // that the fields above have no home for, and the fields no message map can // name at all, keyed by the message and name byte they arrived with. They // have no unit and no scaling here — naming one would be claiming to know // what it means — but they are what the charger actually said, so they are // carried rather than dropped. Extra map[string]any `json:"extra,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. _, _, settingsAt, triggered := conn.snapshotOf(sn) if time.Until(triggered) < triggerRenew { if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil { return nil, err } } // The trigger buys telemetry only. What the charger is set to — its schedule, // its balancing, its Modbus server, its firmware — travels on its own message, // and without asking it never comes: a charger that has been read a hundred // times and commanded none reports amps and nothing else. So the read asks for // that half too whenever it is missing or has gone stale. askedSettings := false if time.Since(settingsAt) > settingsMaxAge { // A charger that will not answer this is not a failed read: the telemetry // half is still the answer, and the settings half is what it was. if err := p.mqttStatusRequest(ctx, conn, model, sn); err == nil { askedSettings = true } } // 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 } // Give the parameter message a moment of its own, but only while this charger // still looks like one that answers: the reference reads the status request as // carrying an Anker bug, so a firmware that ignores it must not tax every read // with the same wait forever. if askedSettings && conn.statusReqAnswered(sn) { // A charger that has never reported its settings is a different case from // one whose settings have merely gone stale. Stale has something to fall // back on — the values are still there, and a read that misses costs the // caller nothing but their age. Never-reported has nothing: the settings // are absent from the answer entirely, and the card that reads them draws // almost nothing. So the first ones are given the same wait a settings // write gives them rather than the short one that only has to catch a // refresh. It is still bounded by statusReqTries, so a charger that truly // never answers costs that wait three times and then stops being asked to. wait := settingsWait if settingsAt.IsZero() { wait = statusWait } settled, werr := conn.waitFor(ctx, sn, func(st *deviceState) bool { return st.settingsAt.After(cutoff) }, wait) if werr == nil && !settled { conn.noteStatusMiss(sn) } } 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, } // 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 // 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": case isRestart(command): default: 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) 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) case isRestart(command): err = p.mqttRestart(ctx, conn, model, sn) default: err = p.mqttTrigger(ctx, conn, model, sn, triggerWindow) } if err != nil { 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 { 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} // Every read goes through one of the four below, and each one notes the key // it took. What is left over at the end is what this projection has no field // for — which is exactly what Extra is, and keeping the list that way means a // field added above stops appearing there without anyone having to remember. read := map[string]bool{} num := func(key string) *float64 { read[key] = true f, ok := v[key].(float64) if !ok { return nil } return &f } whole := func(key string) *int { read[key] = true f, ok := v[key].(float64) if !ok { return nil } n := int(f) return &n } flag := func(key string) *bool { read[key] = true f, ok := v[key].(float64) if !ok { return nil } b := f != 0 return &b } text := func(key string) string { read[key] = true 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.SessionWhL1, snap.SessionWhL2 = num("sessionWhL1"), num("sessionWhL2") snap.SessionWhL3 = num("sessionWhL3") snap.SessionStartedAt, snap.OrderID = num("sessionStartedAt"), num("orderId") snap.ChargingSource = whole("chargingSource") snap.Firmware, snap.Hardware = text("softwareVersion"), text("hardwareVersion") snap.ControllerVersion = text("controllerVersion") snap.SwipeUpMode, snap.SwipeDownMode = whole("swipeUpMode"), whole("swipeDownMode") snap.SmartTouchMode = whole("smartTouchMode") snap.LoadBalanceMonitorMode = whole("loadBalanceMonitorMode") snap.LoadBalanceMeterFlag = whole("loadBalanceMeterFlag") snap.LoadBalanceMonitorSN = text("loadBalanceMonitorSN") snap.SolarMonitoringMode = whole("solarMonitoringMode") snap.SolarMonitorSN = text("solarMonitorSN") 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 } // Whatever the projection did not take. The charger sent it, so it is part of // the reading — under the charger's own name for it, since this package has // none. for key, val := range v { if read[key] { continue } if snap.Extra == nil { snap.Extra = map[string]any{} } snap.Extra[key] = val } 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) }