Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/modbus_test.go
T
tajniak81andClaude Opus 5 f7472bada3 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>
2026-09-01 17:03:27 +02:00

238 lines
7.3 KiB
Go

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")
}
}