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