Modernize invertergui: MQTT write support, HA integration, UI updates
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled

This commit is contained in:
2026-02-19 12:03:52 +11:00
parent 959d1e3c1f
commit a31a0b4829
460 changed files with 19655 additions and 40205 deletions

View File

@@ -0,0 +1,316 @@
package mqttclient
import (
"testing"
"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{
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{
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 float64Ptr(in float64) *float64 {
return &in
}
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(" "))
}