Control had two transports and neither fitted the ordinary customer. OCPP waits for the charger to dial in, which needs a public endpoint it can reach, a certificate, and a firmware willing to talk to our CSMS. Modbus TCP dials the charger, which needs the server on the charger's own network. Between them they cover a charger we host and a charger we stand next to; the common case is a charger behind someone else's router, and that had nothing. It was never unreachable, though. The charger holds a connection open to Anker's own broker — it is how the mobile app drives it from anywhere, and it is the mqttStatus register the Modbus snapshot has been reporting all along. So a third control mode joins that broker as the account: get_user_mqtt_info issues a client certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same topics the app publishes on. Nothing on the customer's side has to be forwarded, addressed or certificated. What travels is not an API call. The payload is a JSON envelope around a base64 binary frame the device itself speaks — marker, little-endian length, message type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec rather than a client, written from the message maps in anker-solix-api and anchored on the one frame that project documents byte for byte. A frame whose fields do not tile exactly up to the checksum is refused rather than half-read: these arrive over a link we do not control, and a truncated frame must not read as a charger reporting zeros. Two of the charger's habits shape the rest. It publishes nothing unless asked, so a status read arms a telemetry trigger and waits for the next frame, and a poll inside that window answers from what has since arrived. And a broker connection costs a fetched certificate and a TLS handshake while the plugin manager builds a throwaway instance per request — so the connection lives on the account's shared session beside the auth token, for exactly the reason the token lives there, and closes itself after five idle minutes. The transport also sees two signals no other one does: the boost flag, and the plug and start countdowns. The package doc has said since the first commit that they are never set and the derived mode must do without them. Here they are set, so a charger that has been told to start and is counting down a delay says so rather than sitting in "preparing", and "skip the delay" is offered only while there is a delay to skip. The clients generalise instead of growing a second layout. Both snapshots name the same quantities the same way, so what was Modbus-only in the readouts is now whichever transport read the charger — ModbusStatus becomes ChargerStatus on the phone, mb becomes dev on the web. What each transport can be *told* still differs, and the buttons branch on that: reset and clear-limit stay with OCPP, the timeout and phase registers with Modbus, skip-delay with the cloud. A command a transport has no equivalent for is refused by name, saying which one has it. The cost is worth saying plainly. This leans on Anker's cloud being up and on an unofficial protocol the app may change under us, where Modbus leans on nothing but the LAN. And it is checked against the reference implementation's own worked example rather than against hardware — there is no charger on this end to point it at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
552 lines
20 KiB
Go
552 lines
20 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
|
|
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))
|
|
}
|