Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/modbus_test.go
T
tajniak81andClaude Opus 5 aaa89dfe10 Relays that run at 33 degrees, not 331
The two relay temperatures came back as 331 and 319 from a charger sitting idle
with nothing plugged in. The spec's gain column says 1 for both, so we reported
them as 331 °C and 319 °C — a reading that would have meant a fire rather than a
wallbox at room temperature.

The gain is 10. The same table hands the maximum current setting a unit of watts
and the timeout a unit of amps, so its unit and gain columns are not load-bearing
here; what settles the alignment is the LED brightness two registers earlier,
which reads exactly 100 at gain 1, and the fact that the neighbouring registers
all decode as tabulated. Read back from the charger afterwards: 33.1 °C and
31.9 °C.

The field becomes a float, as the voltages and currents beside it already are.

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

239 lines
7.4 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)
}
}
// 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")
}
}