Files
tajniak81andClaude Opus 5 62cb691f98 Everything the register map carries, sorted the way it gets asked about
The Modbus snapshot reported about half of what one poll already brings back.
The rest was read into the block and thrown away: line-to-line voltages,
reactive and apparent power per phase, the PWM flag, the control-pilot voltage,
and the identity block's product number, rated power and current range. All of
it now decodes — no extra requests, the registers were in hand already.

Added alongside it: the control block, read back over FC03. It answers a
question the live registers cannot, which is what the charger is *set* to as
opposed to what it is doing — a boost that was asked for reads there while the
live block still reports none running. Best effort, so a charger that refuses
it still reports its state.

Two registers the spec leaves blank are decoded on the hardware's evidence. The
control-pilot voltage reads 11873 while the CP signal register reports state A,
which that enum names as 12 V, so the register is millivolts. The identity
block's current range is in amps, whatever its unit column says about watts and
kVA.

The charging card lays this out in sections rather than a wall of forty numbers:
per-phase measurements as the matrix they are, then live state, then settings,
then the device itself, with alarms surfacing only when a word is non-zero.
Strings in en/da/pl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:37:22 +02:00

334 lines
10 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(-72)
b.set(regRelay1Temp, uint16(below))
// 331 is what an idle charger actually reports: 33.1 °C, not 331 °C.
b.set(regRelay2Temp, 331)
var snap ModbusSnapshot
decodeLive(&snap, b)
if snap.Relay1TempC == nil || *snap.Relay1TempC != -7.2 {
t.Errorf("Relay1TempC = %v, want -7.2", snap.Relay1TempC)
}
if snap.Relay2TempC == nil || *snap.Relay2TempC != 33.1 {
t.Errorf("Relay2TempC = %v, want 33.1", 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)
}
}
func TestDecodeIdentityReadsRatings(t *testing.T) {
regs := make([]uint16, identityCount)
put32 := func(addr int, v uint32) {
regs[addr-identityStart] = uint16(v >> 16)
regs[addr-identityStart+1] = uint16(v)
}
regs[regProductNumber-identityStart] = 7
put32(regRatedPower, 7400)
put32(regMinOutCurrent, 6)
put32(regMaxOutCurrent, 32)
var snap ModbusSnapshot
decodeIdentity(&snap, regs)
for _, c := range []struct {
name string
got *int
want int
}{
{"ProductNumber", snap.ProductNumber, 7},
{"RatedPowerW", snap.RatedPowerW, 7400},
{"MinCurrentA", snap.MinCurrentA, 6},
{"MaxCurrentA", snap.MaxCurrentA, 32},
} {
if c.got == nil || *c.got != c.want {
t.Errorf("%s = %v, want %d", c.name, c.got, c.want)
}
}
}
// The control block says what the charger is set to, which is a different
// question from what it is doing: a boost that was asked for reads here even
// while the live block reports no boost running.
func TestDecodeSettingsReadsTheControlBlock(t *testing.T) {
regs := make([]uint16, settingsCount)
regs[regChargingCommand-settingsStart] = 1
regs[regMaxCurrentSet-settingsStart] = 160 // 16.0 A, gain 10
regs[regBoostSet-settingsStart] = 1
regs[regTimeoutSet-settingsStart] = 120
regs[regPhaseCountSet-settingsStart] = 2
var snap ModbusSnapshot
decodeSettings(&snap, regs)
if snap.Settings == nil {
t.Fatal("Settings is nil")
}
got := snap.Settings
if got.LastCommand == nil || *got.LastCommand != 1 {
t.Errorf("LastCommand = %v, want 1", got.LastCommand)
}
if got.MaxCurrentA == nil || *got.MaxCurrentA != 16 {
t.Errorf("MaxCurrentA = %v, want 16", got.MaxCurrentA)
}
if got.Boost == nil || !*got.Boost {
t.Errorf("Boost = %v, want true", got.Boost)
}
if got.TimeoutSeconds == nil || *got.TimeoutSeconds != 120 {
t.Errorf("TimeoutSeconds = %v, want 120", got.TimeoutSeconds)
}
if got.PhaseSetting == nil || *got.PhaseSetting != 2 || got.PhaseDesc != "fixed three-phase" {
t.Errorf("PhaseSetting = %v (%q), want 2 (fixed three-phase)", got.PhaseSetting, got.PhaseDesc)
}
}
// The registers below were read into the live block all along but never
// decoded, so the block's own alignment is what these guard.
func TestDecodeLiveReadsLineAndReactivePower(t *testing.T) {
b := newLiveBlock()
b.set(regVoltageL1L2, 3809) // 380.9 V, gain 10
b.set32(regReactiveL1, 120)
b.set32(regApparentL1+4, 340)
b.set(regPWMEnabled, 1)
b.set(regCPVoltage, 11873) // 11.873 V, millivolts
var snap ModbusSnapshot
decodeLive(&snap, b)
if snap.VoltageL1L2 == nil || *snap.VoltageL1L2 != 380.9 {
t.Errorf("VoltageL1L2 = %v, want 380.9", snap.VoltageL1L2)
}
if snap.ReactiveL1 == nil || *snap.ReactiveL1 != 120 {
t.Errorf("ReactiveL1 = %v, want 120", snap.ReactiveL1)
}
if snap.ApparentL3 == nil || *snap.ApparentL3 != 340 {
t.Errorf("ApparentL3 = %v, want 340", snap.ApparentL3)
}
if snap.PWMEnabled == nil || !*snap.PWMEnabled {
t.Errorf("PWMEnabled = %v, want true", snap.PWMEnabled)
}
if snap.CPVoltage == nil || *snap.CPVoltage != 11.873 {
t.Errorf("CPVoltage = %v, want 11.873", snap.CPVoltage)
}
}
// 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")
}
}