All checks were successful
continuous-integration/drone/push Build is passing
504 lines
13 KiB
Go
504 lines
13 KiB
Go
package mqttclient
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"git.coadcorp.com/nathan/invertergui/mk2driver"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
type fakeWriter struct {
|
|
lastKind string
|
|
lastID uint16
|
|
lastValue int16
|
|
lastSwitchState mk2driver.PanelSwitchState
|
|
lastCurrentLimit *float64
|
|
lastStandby *bool
|
|
err error
|
|
}
|
|
|
|
func (f *fakeWriter) WriteRAMVar(id uint16, value int16) error {
|
|
f.lastKind = commandKindRAMVar
|
|
f.lastID = id
|
|
f.lastValue = value
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeWriter) WriteSetting(id uint16, value int16) error {
|
|
f.lastKind = commandKindSetting
|
|
f.lastID = id
|
|
f.lastValue = value
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeWriter) SetPanelState(switchState mk2driver.PanelSwitchState, currentLimitA *float64) error {
|
|
f.lastKind = commandKindPanel
|
|
f.lastSwitchState = switchState
|
|
f.lastCurrentLimit = currentLimitA
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeWriter) SetStandby(standby bool) error {
|
|
f.lastKind = commandKindStandby
|
|
f.lastStandby = &standby
|
|
return f.err
|
|
}
|
|
|
|
func Test_decodeWriteCommand(t *testing.T) {
|
|
currentLimit := 16.5
|
|
tests := []struct {
|
|
name string
|
|
payload string
|
|
check func(*testing.T, writeCommand)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "setting",
|
|
payload: `{"request_id":"abc","kind":"setting","id":15,"value":-5}`,
|
|
check: func(t *testing.T, got writeCommand) {
|
|
assert.Equal(t, writeCommand{
|
|
Source: mk2driver.CommandSourceMQTT,
|
|
RequestID: "abc",
|
|
Kind: commandKindSetting,
|
|
ID: 15,
|
|
Value: -5,
|
|
}, got)
|
|
},
|
|
},
|
|
{
|
|
name: "ram_var alias from type",
|
|
payload: `{"type":"ramvar","id":2,"value":7}`,
|
|
check: func(t *testing.T, got writeCommand) {
|
|
assert.Equal(t, writeCommand{
|
|
Source: mk2driver.CommandSourceMQTT,
|
|
Kind: commandKindRAMVar,
|
|
ID: 2,
|
|
Value: 7,
|
|
}, got)
|
|
},
|
|
},
|
|
{
|
|
name: "panel state",
|
|
payload: `{"kind":"panel_state","switch":"on","current_limit":16.5}`,
|
|
check: func(t *testing.T, got writeCommand) {
|
|
assert.Equal(t, commandKindPanel, got.Kind)
|
|
assert.True(t, got.HasSwitch)
|
|
assert.Equal(t, mk2driver.PanelSwitchOn, got.SwitchState)
|
|
assert.Equal(t, "on", got.SwitchName)
|
|
if assert.NotNil(t, got.CurrentLimitA) {
|
|
assert.Equal(t, currentLimit, *got.CurrentLimitA)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "panel current limit only",
|
|
payload: `{"kind":"panel_state","current_limit":12.0}`,
|
|
check: func(t *testing.T, got writeCommand) {
|
|
assert.Equal(t, commandKindPanel, got.Kind)
|
|
assert.False(t, got.HasSwitch)
|
|
assert.Nil(t, got.Standby)
|
|
if assert.NotNil(t, got.CurrentLimitA) {
|
|
assert.Equal(t, 12.0, *got.CurrentLimitA)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "standby bool",
|
|
payload: `{"kind":"standby","standby":true}`,
|
|
check: func(t *testing.T, got writeCommand) {
|
|
assert.Equal(t, commandKindStandby, got.Kind)
|
|
if assert.NotNil(t, got.Standby) {
|
|
assert.True(t, *got.Standby)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "standby using value string",
|
|
payload: `{"kind":"standby","value":"OFF"}`,
|
|
check: func(t *testing.T, got writeCommand) {
|
|
assert.Equal(t, commandKindStandby, got.Kind)
|
|
if assert.NotNil(t, got.Standby) {
|
|
assert.False(t, *got.Standby)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "missing id",
|
|
payload: `{"kind":"setting","value":1}`,
|
|
wantErr: `missing required field "id"`,
|
|
},
|
|
{
|
|
name: "missing panel switch and current limit",
|
|
payload: `{"kind":"panel_state"}`,
|
|
wantErr: `missing required field "switch"`,
|
|
},
|
|
{
|
|
name: "invalid standby",
|
|
payload: `{"kind":"standby","value":"banana"}`,
|
|
wantErr: `field "standby" must be true/false`,
|
|
},
|
|
{
|
|
name: "invalid kind",
|
|
payload: `{"kind":"unknown","id":1,"value":1}`,
|
|
wantErr: `unsupported write command kind "unknown"`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := decodeWriteCommand([]byte(tt.payload))
|
|
if tt.wantErr != "" {
|
|
assert.Error(t, err)
|
|
assert.ErrorContains(t, err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
assert.NoError(t, err)
|
|
tt.check(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_executeWriteCommand(t *testing.T) {
|
|
limit := 8.0
|
|
standby := true
|
|
tests := []struct {
|
|
name string
|
|
cmd writeCommand
|
|
want string
|
|
}{
|
|
{
|
|
name: "setting",
|
|
cmd: writeCommand{
|
|
Kind: commandKindSetting,
|
|
ID: 9,
|
|
Value: 2,
|
|
},
|
|
want: commandKindSetting,
|
|
},
|
|
{
|
|
name: "ram_var",
|
|
cmd: writeCommand{
|
|
Kind: commandKindRAMVar,
|
|
ID: 3,
|
|
Value: -1,
|
|
},
|
|
want: commandKindRAMVar,
|
|
},
|
|
{
|
|
name: "panel_state",
|
|
cmd: writeCommand{
|
|
Kind: commandKindPanel,
|
|
HasSwitch: true,
|
|
SwitchState: mk2driver.PanelSwitchInverterOnly,
|
|
CurrentLimitA: &limit,
|
|
},
|
|
want: commandKindPanel,
|
|
},
|
|
{
|
|
name: "standby",
|
|
cmd: writeCommand{
|
|
Kind: commandKindStandby,
|
|
Standby: &standby,
|
|
},
|
|
want: commandKindStandby,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
writer := &fakeWriter{}
|
|
err := executeWriteCommand(writer, tt.cmd)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tt.want, writer.lastKind)
|
|
switch tt.want {
|
|
case commandKindPanel:
|
|
assert.Equal(t, tt.cmd.SwitchState, writer.lastSwitchState)
|
|
if assert.NotNil(t, writer.lastCurrentLimit) {
|
|
assert.Equal(t, *tt.cmd.CurrentLimitA, *writer.lastCurrentLimit)
|
|
}
|
|
case commandKindStandby:
|
|
if assert.NotNil(t, writer.lastStandby) {
|
|
assert.Equal(t, *tt.cmd.Standby, *writer.lastStandby)
|
|
}
|
|
default:
|
|
assert.Equal(t, tt.cmd.ID, writer.lastID)
|
|
assert.Equal(t, tt.cmd.Value, writer.lastValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_buildHADiscoveryDefinitions(t *testing.T) {
|
|
cfg := Config{
|
|
Topic: "invertergui/updates",
|
|
CommandTopic: "invertergui/settings/set",
|
|
HomeAssistant: HomeAssistantConfig{
|
|
Enabled: true,
|
|
DiscoveryPrefix: "homeassistant",
|
|
NodeID: "victron_main",
|
|
DeviceName: "Shed Victron",
|
|
},
|
|
}
|
|
|
|
definitions := buildHADiscoveryDefinitions(cfg)
|
|
assert.NotEmpty(t, definitions)
|
|
|
|
var panelMode *haDiscoveryDefinition
|
|
var panelCurrentLimit *haDiscoveryDefinition
|
|
var panelStandby *haDiscoveryDefinition
|
|
var batteryVoltage *haDiscoveryDefinition
|
|
for i := range definitions {
|
|
def := &definitions[i]
|
|
if def.Component == "select" && def.ObjectID == "remote_panel_mode" {
|
|
panelMode = def
|
|
}
|
|
if def.Component == "number" && def.ObjectID == "remote_panel_current_limit" {
|
|
panelCurrentLimit = def
|
|
}
|
|
if def.Component == "switch" && def.ObjectID == "remote_panel_standby" {
|
|
panelStandby = def
|
|
}
|
|
if def.Component == "sensor" && def.ObjectID == "battery_voltage" {
|
|
batteryVoltage = def
|
|
}
|
|
}
|
|
|
|
if assert.NotNil(t, panelMode) {
|
|
assert.Equal(t, cfg.CommandTopic, panelMode.Config["command_topic"])
|
|
assert.Equal(t, haPanelSwitchStateTopic(cfg), panelMode.Config["state_topic"])
|
|
}
|
|
if assert.NotNil(t, panelCurrentLimit) {
|
|
assert.Equal(t, cfg.CommandTopic, panelCurrentLimit.Config["command_topic"])
|
|
assert.Equal(t, haCurrentLimitStateTopic(cfg), panelCurrentLimit.Config["state_topic"])
|
|
}
|
|
if assert.NotNil(t, panelStandby) {
|
|
assert.Equal(t, cfg.CommandTopic, panelStandby.Config["command_topic"])
|
|
assert.Equal(t, haStandbyStateTopic(cfg), panelStandby.Config["state_topic"])
|
|
}
|
|
if assert.NotNil(t, batteryVoltage) {
|
|
assert.Equal(t, cfg.Topic, batteryVoltage.Config["state_topic"])
|
|
}
|
|
}
|
|
|
|
func Test_panelStateCacheResolvePanelCommand(t *testing.T) {
|
|
cache := &panelStateCache{}
|
|
|
|
_, err := cache.resolvePanelCommand(writeCommand{
|
|
Kind: commandKindPanel,
|
|
CurrentLimitA: float64Ptr(12),
|
|
})
|
|
assert.Error(t, err)
|
|
|
|
cache.remember(writeCommand{
|
|
Kind: commandKindPanel,
|
|
HasSwitch: true,
|
|
SwitchState: mk2driver.PanelSwitchOn,
|
|
SwitchName: "on",
|
|
})
|
|
|
|
resolved, err := cache.resolvePanelCommand(writeCommand{
|
|
Kind: commandKindPanel,
|
|
CurrentLimitA: float64Ptr(10),
|
|
})
|
|
assert.NoError(t, err)
|
|
assert.True(t, resolved.HasSwitch)
|
|
assert.Equal(t, mk2driver.PanelSwitchOn, resolved.SwitchState)
|
|
assert.Equal(t, "on", resolved.SwitchName)
|
|
}
|
|
|
|
func Test_normalizeID(t *testing.T) {
|
|
assert.Equal(t, "victron_main_01", normalizeID("Victron Main #01"))
|
|
assert.Equal(t, "inverter-gui", normalizeID(" inverter-gui "))
|
|
assert.Equal(t, "", normalizeID(" "))
|
|
}
|
|
|
|
func Test_decodeVenusWriteCommand(t *testing.T) {
|
|
cfg := Config{
|
|
ClientID: "inverter-gui",
|
|
Venus: VenusConfig{
|
|
Enabled: true,
|
|
PortalID: "site01",
|
|
Service: "vebus/257",
|
|
GuideCompat: true,
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
topic string
|
|
payload string
|
|
assertion func(*testing.T, writeCommand)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "mode numeric",
|
|
topic: "W/site01/vebus/257/Mode",
|
|
payload: `{"value":3}`,
|
|
assertion: func(t *testing.T, cmd writeCommand) {
|
|
assert.Equal(t, commandKindPanel, cmd.Kind)
|
|
assert.True(t, cmd.HasSwitch)
|
|
assert.Equal(t, mk2driver.PanelSwitchOn, cmd.SwitchState)
|
|
assert.Equal(t, "on", cmd.SwitchName)
|
|
},
|
|
},
|
|
{
|
|
name: "current limit",
|
|
topic: "W/site01/vebus/257/Ac/ActiveIn/CurrentLimit",
|
|
payload: `{"value":16.5}`,
|
|
assertion: func(t *testing.T, cmd writeCommand) {
|
|
assert.Equal(t, commandKindPanel, cmd.Kind)
|
|
if assert.NotNil(t, cmd.CurrentLimitA) {
|
|
assert.Equal(t, 16.5, *cmd.CurrentLimitA)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "standby",
|
|
topic: "W/site01/vebus/257/Settings/Standby",
|
|
payload: `{"value":true}`,
|
|
assertion: func(t *testing.T, cmd writeCommand) {
|
|
assert.Equal(t, commandKindStandby, cmd.Kind)
|
|
if assert.NotNil(t, cmd.Standby) {
|
|
assert.True(t, *cmd.Standby)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid topic",
|
|
topic: "W/site01/vebus/257/Unknown",
|
|
payload: `{"value":1}`,
|
|
wantErr: "unsupported Venus write path",
|
|
},
|
|
{
|
|
name: "guide ess setpoint",
|
|
topic: "W/site01/settings/0/Settings/CGwacs/AcPowerSetPoint",
|
|
payload: `{"value":-1200}`,
|
|
assertion: func(t *testing.T, cmd writeCommand) {
|
|
assert.Equal(t, commandKindESSSet, cmd.Kind)
|
|
if assert.NotNil(t, cmd.FloatValue) {
|
|
assert.InDelta(t, -1200, *cmd.FloatValue, 0.01)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "guide ess mode with prefix",
|
|
topic: "victron/W/site01/settings/0/Settings/CGwacs/BatteryLife/State",
|
|
payload: `{"value":10}`,
|
|
assertion: func(t *testing.T, cmd writeCommand) {
|
|
assert.Equal(t, commandKindESSMode, cmd.Kind)
|
|
assert.Equal(t, int16(10), cmd.Value)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testCfg := cfg
|
|
if tt.name == "guide ess mode with prefix" {
|
|
testCfg.Venus.TopicPrefix = "victron"
|
|
}
|
|
cmd, err := decodeVenusWriteCommand(testCfg, tt.topic, []byte(tt.payload))
|
|
if tt.wantErr != "" {
|
|
assert.Error(t, err)
|
|
assert.ErrorContains(t, err, tt.wantErr)
|
|
return
|
|
}
|
|
assert.NoError(t, err)
|
|
tt.assertion(t, cmd)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_panelStateCacheRememberTracksFields(t *testing.T) {
|
|
cache := &panelStateCache{}
|
|
limit := 12.0
|
|
standby := true
|
|
|
|
cache.remember(writeCommand{
|
|
Kind: commandKindPanel,
|
|
HasSwitch: true,
|
|
SwitchName: "on",
|
|
SwitchState: mk2driver.PanelSwitchOn,
|
|
CurrentLimitA: &limit,
|
|
})
|
|
cache.remember(writeCommand{
|
|
Kind: commandKindStandby,
|
|
Standby: &standby,
|
|
})
|
|
|
|
s := cache.snapshot()
|
|
assert.True(t, s.HasSwitch)
|
|
assert.Equal(t, "on", s.Switch)
|
|
assert.True(t, s.HasCurrent)
|
|
assert.InDelta(t, 12.0, s.CurrentLimit, 0.001)
|
|
assert.True(t, s.HasStandby)
|
|
assert.True(t, s.Standby)
|
|
}
|
|
|
|
func Test_historyTrackerSummary(t *testing.T) {
|
|
h := newHistoryTracker(2)
|
|
now := time.Now().UTC()
|
|
|
|
h.Add(telemetrySnapshot{
|
|
Timestamp: now,
|
|
InputPower: 100,
|
|
OutputPower: 90,
|
|
BatteryPower: -10,
|
|
BatteryVoltage: 25.0,
|
|
}, operatingStatePassthru, 0, nil)
|
|
summary := h.Add(telemetrySnapshot{
|
|
Timestamp: now.Add(1 * time.Second),
|
|
InputPower: 200,
|
|
OutputPower: 180,
|
|
BatteryPower: -20,
|
|
BatteryVoltage: 24.5,
|
|
}, operatingStateInverter, 2, &now)
|
|
|
|
assert.Equal(t, 2, summary.Samples)
|
|
assert.InDelta(t, 150, summary.AverageInputPower, 0.01)
|
|
assert.InDelta(t, 135, summary.AverageOutputPower, 0.01)
|
|
assert.InDelta(t, 180, summary.MaxOutputPower, 0.01)
|
|
assert.InDelta(t, 24.5, summary.MinBatteryVoltage, 0.01)
|
|
assert.Equal(t, uint64(2), summary.FaultCount)
|
|
}
|
|
|
|
func Test_resolveESSWriteCommand(t *testing.T) {
|
|
ess := newESSControlCache()
|
|
telemetry := &telemetryCache{}
|
|
telemetry.set(telemetrySnapshot{InputVoltage: 230})
|
|
|
|
setpoint := 920.0
|
|
mapped, err := resolveESSWriteCommand(writeCommand{
|
|
Kind: commandKindESSSet,
|
|
FloatValue: &setpoint,
|
|
}, ess, telemetry)
|
|
assert.NoError(t, err)
|
|
if assert.NotNil(t, mapped) {
|
|
assert.Equal(t, commandKindPanel, mapped.Kind)
|
|
assert.Equal(t, "charger_only", mapped.SwitchName)
|
|
if assert.NotNil(t, mapped.CurrentLimitA) {
|
|
assert.InDelta(t, 4.0, *mapped.CurrentLimitA, 0.01)
|
|
}
|
|
}
|
|
|
|
maxDischarge := 1000.0
|
|
_, err = resolveESSWriteCommand(writeCommand{
|
|
Kind: commandKindESSMaxD,
|
|
FloatValue: &maxDischarge,
|
|
}, ess, telemetry)
|
|
assert.NoError(t, err)
|
|
|
|
dischargeSetpoint := -2000.0
|
|
mapped, err = resolveESSWriteCommand(writeCommand{
|
|
Kind: commandKindESSSet,
|
|
FloatValue: &dischargeSetpoint,
|
|
}, ess, telemetry)
|
|
assert.NoError(t, err)
|
|
if assert.NotNil(t, mapped) {
|
|
assert.Equal(t, commandKindPanel, mapped.Kind)
|
|
assert.Equal(t, "inverter_only", mapped.SwitchName)
|
|
}
|
|
}
|