Reach the charger where it is, instead of waiting for it to call
OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a route in through the customer's router. Our own handler then demanded two more things the V1 does not offer — TLS on a charger that connects over ws://, and Basic auth credentials the Anker app has no field for — so every connection was turned away before the upgrade. Anker publishes a Modbus TCP register map for this charger, and it inverts the problem: we dial the charger, on its own network, with no inbound reachability to arrange. That works for a charger behind a router that OCPP cannot reach at all. internal/modbus is the protocol, hand-rolled against the spec like the MQTT and WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same 0-8 status enum the cloud already reports, per-phase measurements, and the writable registers behind start, stop, current limit, boost and phase mode. A new "modbus" control mode routes the existing control endpoints down it, so the REST surface, the rate limit, the confirmation step and the audit trail are the ones already there. The commands the register map has no equivalent for say so by name rather than failing as unknown, and a current below the charger's 6 A floor is refused because it pauses the charge rather than slowing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e9a82a1cca
commit
f7472bada3
@@ -209,9 +209,10 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
// package (internal/ocpp) — but it is advertised here so a superadmin can
|
||||
// set/lock it at the global layer, and it cascades like the other fields.
|
||||
{Key: "controlMode", Label: "Control mode", Type: "select", Default: "off",
|
||||
Help: "How DriverVault controls the charger over OCPP. Off = monitoring only (default). Own CSMS = the charger connects directly to DriverVault. Proxy CSMS = DriverVault relays to Anker's cloud and can inject commands.",
|
||||
Help: "How DriverVault controls the charger. Off = monitoring only (default). Modbus TCP = DriverVault connects to the charger on the local network (enable it in the Anker app under Settings > Integrations); this is the only mode that works when the charger cannot reach the server. The two OCPP modes need the charger to connect in to DriverVault: Own CSMS directly, Proxy CSMS relayed to Anker's cloud.",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "off", Label: "Off (monitoring only)"},
|
||||
{Value: "modbus", Label: "Modbus TCP (local network)"},
|
||||
{Value: "own", Label: "Own CSMS (full control)"},
|
||||
{Value: "proxy", Label: "Proxy CSMS (relay + control)"},
|
||||
}},
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
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; the whole map lives in one 2xxxx space with RO and RW entries side by
|
||||
// side, which is the holding-register convention, so FC03/FC06 is what this uses.
|
||||
|
||||
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 FC03's limit of
|
||||
// 125 registers, 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.ReadHolding(ctx, liveStart, liveCount)
|
||||
if err != nil {
|
||||
return snap, err
|
||||
}
|
||||
decodeLive(&snap, live)
|
||||
|
||||
if withIdentity {
|
||||
ident, err := c.ReadHolding(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))
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package ankersolix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// liveBlock builds a 20041-20100 register block with every register zero, ready
|
||||
// for a test to set the ones it cares about by address.
|
||||
type liveBlock []uint16
|
||||
|
||||
func newLiveBlock() liveBlock { return make(liveBlock, liveCount) }
|
||||
|
||||
func (b liveBlock) set(addr int, v uint16) { b[addr-liveStart] = v }
|
||||
|
||||
func (b liveBlock) set32(addr int, v uint32) {
|
||||
b[addr-liveStart] = uint16(v >> 16)
|
||||
b[addr-liveStart+1] = uint16(v)
|
||||
}
|
||||
|
||||
func TestDecodeLiveScalesMeasurements(t *testing.T) {
|
||||
b := newLiveBlock()
|
||||
b.set(regVoltageL1N, 2301) // 230.1 V, gain 10
|
||||
b.set(regVoltageL1N+1, 2312) // 231.2 V
|
||||
b.set(regVoltageL1N+2, 2298) // 229.8 V
|
||||
b.set(regCurrentL1, 1600) // 16.00 A, gain 100
|
||||
b.set(regCurrentL1+1, 1598) // 15.98 A
|
||||
b.set(regCurrentL1+2, 0)
|
||||
b.set32(regPowerL1, 3680)
|
||||
b.set32(regPowerTotal, 11040)
|
||||
b.set32(regSessionSec, 3725)
|
||||
b.set32(regSessionWh, 12500)
|
||||
|
||||
var snap ModbusSnapshot
|
||||
decodeLive(&snap, b)
|
||||
|
||||
if snap.VoltageL1 == nil || *snap.VoltageL1 != 230.1 {
|
||||
t.Errorf("VoltageL1 = %v, want 230.1", snap.VoltageL1)
|
||||
}
|
||||
if snap.VoltageL3 == nil || *snap.VoltageL3 != 229.8 {
|
||||
t.Errorf("VoltageL3 = %v, want 229.8", snap.VoltageL3)
|
||||
}
|
||||
if snap.CurrentL1 == nil || *snap.CurrentL1 != 16.0 {
|
||||
t.Errorf("CurrentL1 = %v, want 16.0", snap.CurrentL1)
|
||||
}
|
||||
if snap.CurrentL2 == nil || *snap.CurrentL2 != 15.98 {
|
||||
t.Errorf("CurrentL2 = %v, want 15.98", snap.CurrentL2)
|
||||
}
|
||||
// The 32-bit values straddle two registers; a swapped pair would read as a
|
||||
// wildly different number rather than a near miss.
|
||||
if snap.PowerL1 == nil || *snap.PowerL1 != 3680 {
|
||||
t.Errorf("PowerL1 = %v, want 3680", snap.PowerL1)
|
||||
}
|
||||
if snap.PowerTotal == nil || *snap.PowerTotal != 11040 {
|
||||
t.Errorf("PowerTotal = %v, want 11040", snap.PowerTotal)
|
||||
}
|
||||
if snap.SessionSeconds == nil || *snap.SessionSeconds != 3725 {
|
||||
t.Errorf("SessionSeconds = %v, want 3725", snap.SessionSeconds)
|
||||
}
|
||||
if snap.SessionWh == nil || *snap.SessionWh != 12500 {
|
||||
t.Errorf("SessionWh = %v, want 12500", snap.SessionWh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLiveNamesStates(t *testing.T) {
|
||||
b := newLiveBlock()
|
||||
b.set(regChargingStatus, 2) // charging
|
||||
b.set(regCPSignal, 5) // C1, vehicle drawing
|
||||
b.set(regOCPPStatus, 2) // connected
|
||||
b.set(regMQTTStatus, 1) // connected
|
||||
b.set(regBoostMode, 1)
|
||||
b.set(regLoadBalancing, 0)
|
||||
b.set(regLEDBrightness, 70)
|
||||
|
||||
var snap ModbusSnapshot
|
||||
decodeLive(&snap, b)
|
||||
|
||||
// The Modbus status enum is the same one the cloud reports, so it must decode
|
||||
// to the same names the rest of the plugin already uses.
|
||||
if snap.Status == nil || *snap.Status != 2 || snap.StatusDesc != stateCharging {
|
||||
t.Errorf("status = %v/%q, want 2/%q", snap.Status, snap.StatusDesc, stateCharging)
|
||||
}
|
||||
if snap.CPSignalDesc != cpSignalNames[5] {
|
||||
t.Errorf("CPSignalDesc = %q, want %q", snap.CPSignalDesc, cpSignalNames[5])
|
||||
}
|
||||
if snap.OcppStatusDesc != "connected" {
|
||||
t.Errorf("OcppStatusDesc = %q, want connected", snap.OcppStatusDesc)
|
||||
}
|
||||
// Register value 1 means "connected" for MQTT but only "connecting" for OCPP;
|
||||
// the two registers must not be decoded through one table.
|
||||
if snap.MqttStatusDesc != "connected" {
|
||||
t.Errorf("MqttStatusDesc = %q, want connected", snap.MqttStatusDesc)
|
||||
}
|
||||
if snap.BoostMode == nil || !*snap.BoostMode {
|
||||
t.Errorf("BoostMode = %v, want true", snap.BoostMode)
|
||||
}
|
||||
if snap.LoadBalancing == nil || *snap.LoadBalancing {
|
||||
t.Errorf("LoadBalancing = %v, want false", snap.LoadBalancing)
|
||||
}
|
||||
if snap.LEDBrightness == nil || *snap.LEDBrightness != 70 {
|
||||
t.Errorf("LEDBrightness = %v, want 70", snap.LEDBrightness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLiveReadsNegativeTemperatures(t *testing.T) {
|
||||
b := newLiveBlock()
|
||||
below := int16(-7)
|
||||
b.set(regRelay1Temp, uint16(below))
|
||||
b.set(regRelay2Temp, 41)
|
||||
|
||||
var snap ModbusSnapshot
|
||||
decodeLive(&snap, b)
|
||||
|
||||
if snap.Relay1TempC == nil || *snap.Relay1TempC != -7 {
|
||||
t.Errorf("Relay1TempC = %v, want -7", snap.Relay1TempC)
|
||||
}
|
||||
if snap.Relay2TempC == nil || *snap.Relay2TempC != 41 {
|
||||
t.Errorf("Relay2TempC = %v, want 41", snap.Relay2TempC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeLiveFlagsAlarms(t *testing.T) {
|
||||
quiet := newLiveBlock()
|
||||
var snap ModbusSnapshot
|
||||
decodeLive(&snap, quiet)
|
||||
if snap.Alarm {
|
||||
t.Error("Alarm set with every alarm word zero")
|
||||
}
|
||||
if len(snap.Alarms) != 12 {
|
||||
t.Errorf("got %d alarm words, want 12", len(snap.Alarms))
|
||||
}
|
||||
|
||||
noisy := newLiveBlock()
|
||||
noisy.set(regAlarm1+7, 0x0040)
|
||||
var snap2 ModbusSnapshot
|
||||
decodeLive(&snap2, noisy)
|
||||
if !snap2.Alarm {
|
||||
t.Error("Alarm not set although an alarm bit is on")
|
||||
}
|
||||
if snap2.Alarms[7] != 0x0040 {
|
||||
t.Errorf("alarm word 8 = 0x%04x, want 0x0040", snap2.Alarms[7])
|
||||
}
|
||||
}
|
||||
|
||||
// A short block must leave fields absent rather than decode whatever follows.
|
||||
func TestDecodeLiveToleratesShortBlock(t *testing.T) {
|
||||
var snap ModbusSnapshot
|
||||
decodeLive(&snap, []uint16{1, 2, 3})
|
||||
if snap.Status != nil {
|
||||
t.Errorf("Status = %v, want nil from a truncated block", snap.Status)
|
||||
}
|
||||
if snap.PowerTotal != nil {
|
||||
t.Errorf("PowerTotal = %v, want nil from a truncated block", snap.PowerTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeIdentityReadsStrings(t *testing.T) {
|
||||
regs := make([]uint16, identityCount)
|
||||
put := func(addr int, s string, count int) {
|
||||
b := []byte(s)
|
||||
for len(b) < count*2 {
|
||||
b = append(b, 0)
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
regs[addr-identityStart+i] = binary.BigEndian.Uint16(b[i*2:])
|
||||
}
|
||||
}
|
||||
put(regModelName, "A5191", 10)
|
||||
put(regSerialNumber, "V1SN0123456789AB", 12)
|
||||
put(regSoftwareVersion, "1.2.3", 6)
|
||||
put(regHardwareVersion, "1.0", 6)
|
||||
|
||||
var snap ModbusSnapshot
|
||||
decodeIdentity(&snap, regs)
|
||||
|
||||
if snap.Model != "A5191" {
|
||||
t.Errorf("Model = %q, want A5191", snap.Model)
|
||||
}
|
||||
if snap.Serial != "V1SN0123456789AB" {
|
||||
t.Errorf("Serial = %q, want V1SN0123456789AB", snap.Serial)
|
||||
}
|
||||
if snap.Firmware != "1.2.3" {
|
||||
t.Errorf("Firmware = %q, want 1.2.3", snap.Firmware)
|
||||
}
|
||||
if snap.Hardware != "1.0" {
|
||||
t.Errorf("Hardware = %q, want 1.0", snap.Hardware)
|
||||
}
|
||||
}
|
||||
|
||||
// Below 6 A the charger stops instead of charging slowly, so a limit in that
|
||||
// range has to be refused rather than quietly turned into a pause.
|
||||
func TestModbusSetMaxCurrentRefusesBelowFloor(t *testing.T) {
|
||||
err := ModbusSetMaxCurrent(context.Background(), nil, 4)
|
||||
if err == nil {
|
||||
t.Fatal("4 A was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pauses charging") {
|
||||
t.Errorf("error = %q, want it to explain the pause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModbusSetMaxCurrentRefusesOutOfRange(t *testing.T) {
|
||||
for _, amps := range []float64{-1, 40} {
|
||||
if err := ModbusSetMaxCurrent(context.Background(), nil, amps); err == nil {
|
||||
t.Errorf("%.0f A was accepted", amps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModbusSetPhaseModeRejectsUnknownMode(t *testing.T) {
|
||||
if err := ModbusSetPhaseMode(context.Background(), nil, 3); err == nil {
|
||||
t.Fatal("phase mode 3 was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModbusSetTimeoutEnforcesFloor(t *testing.T) {
|
||||
if err := ModbusSetTimeout(context.Background(), nil, 5); err == nil {
|
||||
t.Fatal("a 5 second timeout was accepted, but the spec requires more than 5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModbusConfigDefaultsToPort502(t *testing.T) {
|
||||
if got := (ModbusConfig{Host: "10.2.1.55"}).Address(); got != "10.2.1.55:502" {
|
||||
t.Errorf("address = %q, want 10.2.1.55:502", got)
|
||||
}
|
||||
if got := (ModbusConfig{Host: "10.2.1.55", Port: 1502}).Address(); got != "10.2.1.55:1502" {
|
||||
t.Errorf("address = %q, want 10.2.1.55:1502", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModbusDialRequiresHost(t *testing.T) {
|
||||
if _, err := ModbusDial(context.Background(), ModbusConfig{}); err == nil {
|
||||
t.Fatal("an empty host was accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user