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