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>
238 lines
7.3 KiB
Go
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")
|
|
}
|
|
}
|