implement some features of Venus OS
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-19 15:37:41 +11:00
parent d72e88ab7b
commit e8153e2953
21 changed files with 4143 additions and 90 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package mqttclient
import (
"testing"
"time"
"git.coadcorp.com/nathan/invertergui/mk2driver"
"github.com/stretchr/testify/assert"
@@ -57,6 +58,7 @@ func Test_decodeWriteCommand(t *testing.T) {
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,
@@ -69,9 +71,10 @@ func Test_decodeWriteCommand(t *testing.T) {
payload: `{"type":"ramvar","id":2,"value":7}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, writeCommand{
Kind: commandKindRAMVar,
ID: 2,
Value: 7,
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindRAMVar,
ID: 2,
Value: 7,
}, got)
},
},
@@ -305,12 +308,196 @@ func Test_panelStateCacheResolvePanelCommand(t *testing.T) {
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(" "))
}
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)
}
}

View File

@@ -208,13 +208,19 @@ func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Reque
return
}
if err := w.writer.SetPanelState(switchState, req.CurrentLimit); err != nil {
logEntry.WithError(err).Error("Failed to apply remote panel state")
var setErr error
if sourceAware, ok := w.writer.(mk2driver.SourceAwareSettingsWriter); ok {
setErr = sourceAware.SetPanelStateWithSource(mk2driver.CommandSourceUI, switchState, req.CurrentLimit)
} else {
setErr = w.writer.SetPanelState(switchState, req.CurrentLimit)
}
if setErr != nil {
logEntry.WithError(setErr).Error("Failed to apply remote panel state")
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_state"
state.LastError = err.Error()
state.LastError = setErr.Error()
})
http.Error(rw, err.Error(), http.StatusBadGateway)
http.Error(rw, setErr.Error(), http.StatusBadGateway)
return
}
@@ -243,7 +249,13 @@ func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Req
}
log.WithField("standby", req.Standby).Info("Applying standby state from API")
if err := w.writer.SetStandby(req.Standby); err != nil {
var err error
if sourceAware, ok := w.writer.(mk2driver.SourceAwareSettingsWriter); ok {
err = sourceAware.SetStandbyWithSource(mk2driver.CommandSourceUI, req.Standby)
} else {
err = w.writer.SetStandby(req.Standby)
}
if err != nil {
log.WithError(err).WithField("standby", req.Standby).Error("Failed to apply standby state")
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_standby"