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