package ankersolix // Local control over Modbus TCP, the one path Anker documents publicly for this // charger ("Anker SOLIX V1 Smart EV Charger Modbus Protocol", V1.0.0, // 30-11-2025). It is the counterpart to the read-only cloud plugin in // ankersolix.go: same device, same status enum, but reached over the LAN with no // account, no cloud round-trip and no inbound connection for the charger to make // — which is what rules OCPP out wherever the charger cannot dial us. // // The owner enables it in the Anker app under Settings > Integrations > Modbus // TCP; the app then shows the charger's local IP and port. Two caveats from the // spec that shape the code here: // // - At most two clients may be connected at once, and an operator's debugging // tool may well be one of them. Connections are therefore opened per // operation and closed again, never pooled. // - Charging pauses on its own whenever the current is set below 6 A, so a // limit under that floor is a pause, not a slow charge. SetMaxCurrent says so // rather than letting a caller discover it. // // The spec tabulates addresses, types and gains but does not name the function // codes. An A5191 on firmware 1.0.6.1 answers the measurement block (20000-20100) // on FC04 only — FC03 there is refused with an illegal-address exception, for // every address in the range — while the controls at 21000-21005 do read back // over FC03. So the map is two tables, not the one 2xxxx space it looks like: // input registers for what the charger reports, holding registers for what it // accepts, which is FC04 to read and FC06 to write. import ( "context" "encoding/binary" "fmt" "strings" "time" "drivervault/apiserver/internal/modbus" ) // Register addresses from the protocol spec. Identity and configuration sit in // 20000-20040, live measurements and state in 20041-20100, and the writable // controls in 21000-21005. const ( regProductNumber = 20000 // UINT16 regModelName = 20001 // STRING, 10 registers regSerialNumber = 20011 // STRING, 12 registers regSoftwareVersion = 20023 // STRING, 6 registers regHardwareVersion = 20029 // STRING, 6 registers regRatedPower = 20035 // INT32, W regMinOutCurrent = 20037 // INT32 regMaxOutCurrent = 20039 // INT32 regAlarm1 = 20041 // UINT16 x12, each bit an alarm regVoltageL1N = 20053 // UINT16, gain 10 regVoltageL1L2 = 20056 // UINT16, gain 10, line to line regCurrentL1 = 20059 // UINT16, gain 100 regPowerL1 = 20062 // UINT32, W regPowerTotal = 20068 // UINT32, W regReactiveL1 = 20070 // UINT32, var regApparentL1 = 20076 // UINT32, VA regSessionSec = 20082 // UINT32, s regSessionWh = 20084 // UINT32, Wh regPWMEnabled = 20086 regPhaseMode = 20087 regChargingMode = 20088 // 0 solar+grid, 1 solar only regLoadBalancing = 20089 regSolarBalancing = 20090 regCPVoltage = 20091 // UINT16, millivolts — see decodeLive regCPSignal = 20092 regRelay1Temp = 20093 // INT16, decidegC — see decodeLive on the gain regRelay2Temp = 20094 // INT16, decidegC regBoostMode = 20095 regLEDBrightness = 20096 // % regChargingStatus = 20097 // same 0-8 enum as the cloud's operating_state regOCPPStatus = 20099 // 0 not connected, 1 connecting, 2 connected regMQTTStatus = 20100 // 0 not connected, 1 connected regChargingCommand = 21000 // 1 start, 2 stop regMaxCurrentSet = 21001 // gain 10, i.e. deciamps regBoostSet = 21002 // 1 on, for the current session only regTimeoutSet = 21003 // seconds, must exceed 5 regPhaseCountSet = 21005 // 0 automatic, 1 fixed single, 2 fixed three ) // The control block, read back rather than written. It is six holding registers // over FC03 — the one part of the map that answers that function code — and it // is the only way to see what the charger is actually set to, as opposed to what // it is doing. const ( settingsStart = regChargingCommand settingsCount = 6 // 21000-21005 ) // The two blocks read in one request each. Both are well inside the 125-register // limit of a read, and splitting them keeps the hot path (live state) small. const ( identityStart = regProductNumber identityCount = 41 // 20000-20040 liveStart = regAlarm1 liveCount = 60 // 20041-20100 ) // Charging command values for regChargingCommand. const ( cmdStartCharging = 1 cmdStopCharging = 2 ) // currentPauseFloor is the current below which the charger stops drawing power // on its own, per the spec's note on the maximum-current register. const currentPauseFloor = 6.0 // cpSignalNames decodes regCPSignal — the control-pilot state, which says what // the vehicle side of the cable is doing independently of the charger's own // status. var cpSignalNames = map[int]string{ 0: "A (12V, not connected)", 3: "B1 (9V)", 4: "B2 (9V)", 5: "C1 (6V, charging)", 6: "C2 (6V, charging)", 7: "error", 8: "D1 (3V)", 9: "D2 (3V)", 10: "E (0V)", 11: "F (-12V)", } // The two connection-status registers do not share an encoding: OCPP has a // three-state one (and matches SolixOcppConnectionStatus in the cloud plugin), // while MQTT is a plain boolean. Decoding both through one table would report a // connected broker as "connecting". var ( ocppStatusNames = map[int]string{0: "disconnected", 1: "connecting", 2: "connected"} mqttStatusNames = map[int]string{0: "disconnected", 1: "connected"} ) // ModbusConfig addresses one charger's local Modbus TCP server. type ModbusConfig struct { Host string Port int UnitID byte } // Address is the host:port this charger is dialled at, defaulting to Modbus's // standard port when none was saved. func (c ModbusConfig) Address() string { port := c.Port if port == 0 { port = 502 } return fmt.Sprintf("%s:%d", strings.TrimSpace(c.Host), port) } // ModbusSnapshot is the charger's live state as the register map reports it. A // field the charger did not supply stays nil rather than zero, matching how // accountCharger treats a value no cloud view knew. type ModbusSnapshot struct { // Identity, present only when the identity block was read too. Model string `json:"model,omitempty"` Serial string `json:"serial,omitempty"` Firmware string `json:"firmware,omitempty"` Hardware string `json:"hardware,omitempty"` ProductNumber *int `json:"productNumber,omitempty"` RatedPowerW *int `json:"ratedPowerW,omitempty"` MinCurrentA *int `json:"minCurrentA,omitempty"` MaxCurrentA *int `json:"maxCurrentA,omitempty"` Status *int `json:"status,omitempty"` StatusDesc string `json:"statusDesc,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"` // Line-to-line voltages, which say something the phase voltages do not on a // three-phase supply and sit at zero on a single-phase one. VoltageL1L2 *float64 `json:"voltageL1L2,omitempty"` VoltageL2L3 *float64 `json:"voltageL2L3,omitempty"` VoltageL3L1 *float64 `json:"voltageL3L1,omitempty"` PowerL1 *uint32 `json:"powerL1,omitempty"` PowerL2 *uint32 `json:"powerL2,omitempty"` PowerL3 *uint32 `json:"powerL3,omitempty"` PowerTotal *uint32 `json:"powerTotal,omitempty"` ReactiveL1 *uint32 `json:"reactiveL1,omitempty"` ReactiveL2 *uint32 `json:"reactiveL2,omitempty"` ReactiveL3 *uint32 `json:"reactiveL3,omitempty"` ApparentL1 *uint32 `json:"apparentL1,omitempty"` ApparentL2 *uint32 `json:"apparentL2,omitempty"` ApparentL3 *uint32 `json:"apparentL3,omitempty"` SessionSeconds *uint32 `json:"sessionSeconds,omitempty"` SessionWh *uint32 `json:"sessionWh,omitempty"` PhaseMode *int `json:"phaseMode,omitempty"` ChargingMode *int `json:"chargingMode,omitempty"` LoadBalancing *bool `json:"loadBalancing,omitempty"` SolarBalancing *bool `json:"solarBalancing,omitempty"` BoostMode *bool `json:"boostMode,omitempty"` LEDBrightness *int `json:"ledBrightness,omitempty"` PWMEnabled *bool `json:"pwmEnabled,omitempty"` CPVoltage *float64 `json:"cpVoltage,omitempty"` CPSignal *int `json:"cpSignal,omitempty"` CPSignalDesc string `json:"cpSignalDesc,omitempty"` Relay1TempC *float64 `json:"relay1TempC,omitempty"` Relay2TempC *float64 `json:"relay2TempC,omitempty"` OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` MqttStatus *int `json:"mqttStatus,omitempty"` MqttStatusDesc string `json:"mqttStatusDesc,omitempty"` // Settings is what the charger is set to, read back from the control block. // It answers a different question from the live fields beside it: BoostMode // says a boost is running, Settings.Boost says one was asked for. Settings *ModbusSettings `json:"settings,omitempty"` // Alarms holds the twelve alarm words verbatim. The spec defers the bit // meanings to a separate alarm list, so they are surfaced undecoded rather // than guessed at; Alarm reports whether any bit is set at all. Alarms []uint16 `json:"alarms,omitempty"` Alarm bool `json:"alarm"` } // ModbusSettings mirrors the writable control block. Every field is what the // charger reports for a register it also accepts writes on, so a view built from // this shows the settings in force rather than the ones last sent. type ModbusSettings struct { LastCommand *int `json:"lastCommand,omitempty"` // 0 none, 1 start, 2 stop MaxCurrentA *float64 `json:"maxCurrentA,omitempty"` // deciamps on the wire Boost *bool `json:"boost,omitempty"` // for the current session only TimeoutSeconds *int `json:"timeoutSeconds,omitempty"` // control falls back after this silence PhaseSetting *int `json:"phaseSetting,omitempty"` // 0 automatic, 1 single, 2 three PhaseDesc string `json:"phaseDesc,omitempty"` } // phaseSettingNames decodes the write register's 0/1/2, which is not the 1/3 the // charger reports for the phase mode it is currently running in. var phaseSettingNames = map[int]string{ 0: "automatic", 1: "fixed single-phase", 2: "fixed three-phase", } // ModbusDial opens a connection to one charger. Callers must Close it; the // charger only tolerates two clients at a time. func ModbusDial(ctx context.Context, cfg ModbusConfig) (*modbus.Client, error) { if strings.TrimSpace(cfg.Host) == "" { return nil, fmt.Errorf("anker-solix: the charger's local IP address is required for Modbus control") } return modbus.Connect(ctx, modbus.Options{ Address: cfg.Address(), UnitID: cfg.UnitID, Timeout: 5 * time.Second, ConnectTimeout: 5 * time.Second, }) } // ModbusRead returns the charger's live state. withIdentity also reads the // slower-moving identity block (model, serial, firmware), which is worth a // second request on a first poll but not on every one. func ModbusRead(ctx context.Context, c *modbus.Client, withIdentity bool) (ModbusSnapshot, error) { var snap ModbusSnapshot live, err := c.ReadInput(ctx, liveStart, liveCount) if err != nil { return snap, err } decodeLive(&snap, live) // The control block is a second table on a second function code, so it is read // separately and best effort: a charger that refuses it still has live state // worth reporting. if set, err := c.ReadHolding(ctx, settingsStart, settingsCount); err == nil { decodeSettings(&snap, set) } if withIdentity { ident, err := c.ReadInput(ctx, identityStart, identityCount) if err != nil { // Identity is a nicety; live state is the point. Report what we have. return snap, nil } decodeIdentity(&snap, ident) } return snap, nil } // decodeLive fills the snapshot from the 20041-20100 block. func decodeLive(snap *ModbusSnapshot, regs []uint16) { at := func(addr int) (uint16, bool) { i := addr - liveStart if i < 0 || i >= len(regs) { return 0, false } return regs[i], true } u32 := func(addr int) (uint32, bool) { hi, ok1 := at(addr) lo, ok2 := at(addr + 1) if !ok1 || !ok2 { return 0, false } return uint32(hi)<<16 | uint32(lo), true } scaled := func(addr int, gain float64) *float64 { v, ok := at(addr) if !ok { return nil } f := float64(v) / gain return &f } word := func(addr int) *uint32 { v, ok := u32(addr) if !ok { return nil } return &v } num := func(addr int) *int { v, ok := at(addr) if !ok { return nil } n := int(v) return &n } flag := func(addr int) *bool { v, ok := at(addr) if !ok { return nil } b := v != 0 return &b } // The spec's gain column says 1 for the two relay temperatures, but a charger // idling with nothing plugged in reads 331 and 319 there, which is a gain of // 10 and not a pair of relays at 331 °C. The same table gives the maximum // current setting in watts and the timeout in amps, so its unit and gain // columns are not load-bearing; the LED brightness two registers earlier // reads exactly 100 at gain 1, which is what fixes the alignment. degrees := func(addr int) *float64 { v, ok := at(addr) if !ok { return nil } c := float64(int16(v)) / 10 // signed: the relays can read below zero return &c } snap.VoltageL1 = scaled(regVoltageL1N, 10) snap.VoltageL2 = scaled(regVoltageL1N+1, 10) snap.VoltageL3 = scaled(regVoltageL1N+2, 10) snap.VoltageL1L2 = scaled(regVoltageL1L2, 10) snap.VoltageL2L3 = scaled(regVoltageL1L2+1, 10) snap.VoltageL3L1 = scaled(regVoltageL1L2+2, 10) snap.CurrentL1 = scaled(regCurrentL1, 100) snap.CurrentL2 = scaled(regCurrentL1+1, 100) snap.CurrentL3 = scaled(regCurrentL1+2, 100) snap.PowerL1 = word(regPowerL1) snap.PowerL2 = word(regPowerL1 + 2) snap.PowerL3 = word(regPowerL1 + 4) snap.PowerTotal = word(regPowerTotal) snap.ReactiveL1 = word(regReactiveL1) snap.ReactiveL2 = word(regReactiveL1 + 2) snap.ReactiveL3 = word(regReactiveL1 + 4) snap.ApparentL1 = word(regApparentL1) snap.ApparentL2 = word(regApparentL1 + 2) snap.ApparentL3 = word(regApparentL1 + 4) snap.SessionSeconds = word(regSessionSec) snap.SessionWh = word(regSessionWh) snap.PWMEnabled = flag(regPWMEnabled) // The spec leaves this register's unit and gain blank. It reads 11873 while // the CP signal register reports state A, which that enum itself names as // 12 V — so the register is millivolts, and the state beside it is the // cross-check. snap.CPVoltage = scaled(regCPVoltage, 1000) snap.PhaseMode = num(regPhaseMode) snap.ChargingMode = num(regChargingMode) snap.LoadBalancing = flag(regLoadBalancing) snap.SolarBalancing = flag(regSolarBalancing) snap.BoostMode = flag(regBoostMode) snap.LEDBrightness = num(regLEDBrightness) snap.Relay1TempC = degrees(regRelay1Temp) snap.Relay2TempC = degrees(regRelay2Temp) if v := num(regCPSignal); v != nil { snap.CPSignal, snap.CPSignalDesc = v, cpSignalNames[*v] } if v := num(regChargingStatus); v != nil { snap.Status, snap.StatusDesc = v, statusName(*v) } if v := num(regOCPPStatus); v != nil { snap.OcppStatus, snap.OcppStatusDesc = v, ocppStatusNames[*v] } if v := num(regMQTTStatus); v != nil { snap.MqttStatus, snap.MqttStatusDesc = v, mqttStatusNames[*v] } for addr := regAlarm1; addr < regAlarm1+12; addr++ { v, ok := at(addr) if !ok { break } snap.Alarms = append(snap.Alarms, v) if v != 0 { snap.Alarm = true } } } // decodeIdentity fills the snapshot from the 20000-20040 block. func decodeIdentity(snap *ModbusSnapshot, regs []uint16) { text := func(addr, count int) string { i := addr - identityStart if i < 0 || i+count > len(regs) { return "" } b := make([]byte, 0, count*2) for _, r := range regs[i : i+count] { b = binary.BigEndian.AppendUint16(b, r) } return strings.TrimSpace(strings.TrimRight(string(b), "\x00")) } // The identity block's numbers are INT32 pairs, big-endian across the two // registers like the power readings in the live block. num := func(addr int) *int { i := addr - identityStart if i < 0 || i+2 > len(regs) { return nil } v := int(int32(uint32(regs[i])<<16 | uint32(regs[i+1]))) return &v } word := func(addr int) *int { i := addr - identityStart if i < 0 || i >= len(regs) { return nil } v := int(regs[i]) return &v } snap.Model = text(regModelName, 10) snap.Serial = text(regSerialNumber, 12) snap.Firmware = text(regSoftwareVersion, 6) snap.Hardware = text(regHardwareVersion, 6) snap.ProductNumber = word(regProductNumber) snap.RatedPowerW = num(regRatedPower) // The spec's unit column calls these watts and kVA; they are amps, which is // what the charger reports and what the current limit is set in. snap.MinCurrentA = num(regMinOutCurrent) snap.MaxCurrentA = num(regMaxOutCurrent) } // decodeSettings fills the snapshot from the 21000-21005 control block. func decodeSettings(snap *ModbusSnapshot, regs []uint16) { at := func(addr int) (uint16, bool) { i := addr - settingsStart if i < 0 || i >= len(regs) { return 0, false } return regs[i], true } set := &ModbusSettings{} if v, ok := at(regChargingCommand); ok { n := int(v) set.LastCommand = &n } if v, ok := at(regMaxCurrentSet); ok { a := float64(v) / 10 set.MaxCurrentA = &a } if v, ok := at(regBoostSet); ok { b := v != 0 set.Boost = &b } if v, ok := at(regTimeoutSet); ok { n := int(v) set.TimeoutSeconds = &n } if v, ok := at(regPhaseCountSet); ok { n := int(v) set.PhaseSetting, set.PhaseDesc = &n, phaseSettingNames[n] } snap.Settings = set } // ModbusStartCharging asks the charger to begin a session. func ModbusStartCharging(ctx context.Context, c *modbus.Client) error { return c.WriteSingle(ctx, regChargingCommand, cmdStartCharging) } // ModbusStopCharging asks the charger to end the current session. func ModbusStopCharging(ctx context.Context, c *modbus.Client) error { return c.WriteSingle(ctx, regChargingCommand, cmdStopCharging) } // checkMaxCurrent validates a charging current ceiling in amps. The limit is the // charger's, not the transport's, so the cloud path applies the same rule (see // cloudmqtt.go): below currentPauseFloor the charger stops rather than charging // slowly, which makes a lower limit a pause in disguise — so it is refused, and // a caller that means to pause is asked to say so. func checkMaxCurrent(amps float64) error { if amps > 0 && amps < currentPauseFloor { return fmt.Errorf("anker-solix: %.1f A is below the charger's %.0f A floor, which pauses charging; stop the session instead", amps, currentPauseFloor) } if amps < 0 || amps > 32 { return fmt.Errorf("anker-solix: %.1f A is outside the charger's range (%.0f-32 A)", amps, currentPauseFloor) } return nil } // ModbusSetMaxCurrent sets the charging current ceiling, in amps. The register // carries deciamps. func ModbusSetMaxCurrent(ctx context.Context, c *modbus.Client, amps float64) error { if err := checkMaxCurrent(amps); err != nil { return err } return c.WriteSingle(ctx, regMaxCurrentSet, uint16(amps*10)) } // ModbusSetBoost turns boost on for the current session; the charger clears it // again when the session ends. func ModbusSetBoost(ctx context.Context, c *modbus.Client, on bool) error { var v uint16 if on { v = 1 } return c.WriteSingle(ctx, regBoostSet, v) } // ModbusSetPhaseMode fixes the charger to single- or three-phase, or hands the // choice back to it. 0 automatic, 1 fixed single-phase, 2 fixed three-phase. func ModbusSetPhaseMode(ctx context.Context, c *modbus.Client, mode int) error { if mode < 0 || mode > 2 { return fmt.Errorf("anker-solix: phase mode %d is not one of 0 (automatic), 1 (single) or 2 (three)", mode) } return c.WriteSingle(ctx, regPhaseCountSet, uint16(mode)) } // ModbusSetTimeout sets the control timeout: if no Modbus client writes within // it, the charger falls back to its own strategy. The spec requires more than // five seconds. func ModbusSetTimeout(ctx context.Context, c *modbus.Client, seconds int) error { if seconds <= 5 { return fmt.Errorf("anker-solix: the Modbus timeout must be more than 5 seconds, not %d", seconds) } return c.WriteSingle(ctx, regTimeoutSet, uint16(seconds)) }