Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/modbus.go
T
tajniak81andClaude Opus 5 cf4fd14b56 The table the charger actually keeps its measurements in
Modbus mode never returned a reading: every status poll came back as "the
charger did not answer", though the charger was answering all along. It was
refusing the question. The A5191 splits its map across two tables where the
spec's single 2xxxx column suggests one — 20000-20100 are input registers and
reject FC03 with an illegal-address exception at every address in the range,
while 21000-21005 really are holding registers and read back over FC03. We
inferred one space from the spec's layout and asked for all of it with FC03.

The client learns FC04, sharing a body with FC03 since the two differ only in
which table the server consults, and the plugin's two measurement reads move to
it. Writes stay on FC06, where the controls already live.

Confirmed against an A5191 on firmware 1.0.6.1: identity, live block and the
control registers all decode as the spec tabulates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:42:39 +02:00

402 lines
14 KiB
Go

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
regCurrentL1 = 20059 // UINT16, gain 100
regPowerL1 = 20062 // UINT32, W
regPowerTotal = 20068 // UINT32, W
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
regCPSignal = 20092
regRelay1Temp = 20093 // INT16, degC
regRelay2Temp = 20094 // INT16, degC
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 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"`
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"`
PowerL1 *uint32 `json:"powerL1,omitempty"`
PowerL2 *uint32 `json:"powerL2,omitempty"`
PowerL3 *uint32 `json:"powerL3,omitempty"`
PowerTotal *uint32 `json:"powerTotal,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"`
CPSignal *int `json:"cpSignal,omitempty"`
CPSignalDesc string `json:"cpSignalDesc,omitempty"`
Relay1TempC *int `json:"relay1TempC,omitempty"`
Relay2TempC *int `json:"relay2TempC,omitempty"`
OcppStatus *int `json:"ocppStatus,omitempty"`
OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
MqttStatus *int `json:"mqttStatus,omitempty"`
MqttStatusDesc string `json:"mqttStatusDesc,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"`
}
// 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)
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
}
degrees := func(addr int) *int {
v, ok := at(addr)
if !ok {
return nil
}
n := int(int16(v)) // signed: the relays can read below zero
return &n
}
snap.VoltageL1 = scaled(regVoltageL1N, 10)
snap.VoltageL2 = scaled(regVoltageL1N+1, 10)
snap.VoltageL3 = scaled(regVoltageL1N+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.SessionSeconds = word(regSessionSec)
snap.SessionWh = word(regSessionWh)
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"))
}
snap.Model = text(regModelName, 10)
snap.Serial = text(regSerialNumber, 12)
snap.Firmware = text(regSoftwareVersion, 6)
snap.Hardware = text(regHardwareVersion, 6)
}
// 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)
}
// ModbusSetMaxCurrent sets the charging current ceiling, in amps. The register
// carries deciamps, and anything below currentPauseFloor stops the charge
// outright rather than slowing it — so that case is refused here, and a caller
// that means to pause is asked to say so.
func ModbusSetMaxCurrent(ctx context.Context, c *modbus.Client, 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 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))
}