add capability to restrict remote panel modes
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-19 15:55:20 +11:00
parent e8153e2953
commit e700239764
9 changed files with 197 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ type config struct {
MaxCurrentLimit float64 `long:"control.max_current_limit" env:"CONTROL_MAX_CURRENT_LIMIT" default:"0" description:"Optional max AC current limit guardrail in amps (0 disables)."`
ModeChangeMinInterval time.Duration `long:"control.mode_change_min_interval" env:"CONTROL_MODE_CHANGE_MIN_INTERVAL" default:"3s" description:"Minimum time between mode changes."`
LockoutWindow time.Duration `long:"control.lockout_window" env:"CONTROL_LOCKOUT_WINDOW" default:"0s" description:"Post-command lockout window for command arbitration."`
AllowedPanelModes string `long:"control.allowed_panel_modes" env:"CONTROL_ALLOWED_PANEL_MODES" default:"" description:"Comma-separated allowlist of remote panel modes. Supported values: on, off, charger_only, inverter_only. Empty allows all modes."`
}
Data struct {
Source string `long:"data.source" env:"DATA_SOURCE" default:"serial" description:"Set the source of data for the inverter gui. \"serial\", \"tcp\" or \"mock\""`
@@ -48,9 +49,9 @@ type config struct {
Enabled bool `long:"mqtt.venus.enabled" env:"MQTT_VENUS_ENABLED" description:"Enable Venus-style MQTT compatibility topics (N/W topic model)."`
PortalID string `long:"mqtt.venus.portal_id" env:"MQTT_VENUS_PORTAL_ID" default:"invertergui" description:"Set Venus portal ID segment used in N/W topics."`
Service string `long:"mqtt.venus.service" env:"MQTT_VENUS_SERVICE" default:"vebus/257" description:"Set Venus service segment used in N/W topics."`
SubscribeWrites bool `long:"mqtt.venus.subscribe_writes" env:"MQTT_VENUS_SUBSCRIBE_WRITES" default:"true" description:"Subscribe to Venus write topics and map them to MK2 control commands."`
SubscribeWrites bool `long:"mqtt.venus.subscribe_writes" env:"MQTT_VENUS_SUBSCRIBE_WRITES" description:"Subscribe to Venus write topics and map them to MK2 control commands."`
TopicPrefix string `long:"mqtt.venus.topic_prefix" env:"MQTT_VENUS_TOPIC_PREFIX" default:"" description:"Optional topic prefix before Venus N/W topics, for example 'victron'."`
GuideCompat bool `long:"mqtt.venus.guide_compat" env:"MQTT_VENUS_GUIDE_COMPAT" default:"true" description:"Enable guide-style settings/0/Settings/CGwacs compatibility paths for Home Assistant controls."`
GuideCompat bool `long:"mqtt.venus.guide_compat" env:"MQTT_VENUS_GUIDE_COMPAT" description:"Enable guide-style settings/0/Settings/CGwacs compatibility paths for Home Assistant controls."`
}
Username string `long:"mqtt.username" env:"MQTT_USERNAME" default:"" description:"Set the MQTT username"`
Password string `long:"mqtt.password" env:"MQTT_PASSWORD" default:"" description:"Set the MQTT password"`
@@ -61,6 +62,9 @@ type config struct {
func parseConfig() (*config, error) {
conf := &config{}
// go-flags bool options cannot use struct-tag defaults; keep intended behavior via struct initialization.
conf.MQTT.Venus.SubscribeWrites = true
conf.MQTT.Venus.GuideCompat = true
parser := flags.NewParser(conf, flags.Default)
if _, err := parser.Parse(); err != nil {
return nil, err

View File

@@ -36,6 +36,7 @@ import (
"net"
"net/http"
"os"
"sort"
"strings"
"git.coadcorp.com/nathan/invertergui/mk2core"
@@ -87,6 +88,7 @@ func main() {
"mqtt_venus_prefix": conf.MQTT.Venus.TopicPrefix,
"mqtt_venus_guide": conf.MQTT.Venus.GuideCompat,
"control_profile": conf.Control.Profile,
"control_modes": conf.Control.AllowedPanelModes,
}).Info("Configuration loaded")
mk2, err := getMk2Device(conf.Data.Source, conf.Data.Host, conf.Data.Device)
@@ -119,6 +121,11 @@ func main() {
}
writer = nil
} else if writer != nil {
allowedPanelStates, allowedPanelModeNames, parseErr := parseAllowedPanelModes(conf.Control.AllowedPanelModes)
if parseErr != nil {
log.WithError(parseErr).Fatal("Invalid control.allowed_panel_modes configuration")
}
policyProfile := mk2driver.WriterProfile(strings.ToLower(strings.TrimSpace(conf.Control.Profile)))
if policyProfile == "" {
policyProfile = mk2driver.WriterProfileNormal
@@ -141,12 +148,19 @@ func main() {
MaxCurrentLimitA: maxCurrentLimit,
ModeChangeMinInterval: conf.Control.ModeChangeMinInterval,
LockoutWindow: conf.Control.LockoutWindow,
AllowedPanelStates: allowedPanelStates,
})
allowedModes := "all"
if len(allowedPanelModeNames) > 0 {
allowedModes = strings.Join(allowedPanelModeNames, ",")
}
log.WithFields(logrus.Fields{
"profile": policyProfile,
"max_current_limit": conf.Control.MaxCurrentLimit,
"mode_change_min_interval": conf.Control.ModeChangeMinInterval,
"lockout_window": conf.Control.LockoutWindow,
"allowed_panel_modes": allowedModes,
}).Info("Write policy/arbitration layer enabled")
}
gui := webui.NewWebGui(core.NewSubscription(), writer)
@@ -250,3 +264,54 @@ func getMk2Device(source, ip, dev string) (mk2driver.Mk2, error) {
return mk2, nil
}
func parseAllowedPanelModes(raw string) (map[mk2driver.PanelSwitchState]struct{}, []string, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil, nil
}
out := make(map[mk2driver.PanelSwitchState]struct{})
names := make([]string, 0, 4)
for _, token := range strings.Split(raw, ",") {
mode := strings.ToLower(strings.TrimSpace(token))
if mode == "" {
continue
}
var state mk2driver.PanelSwitchState
var canonical string
switch mode {
case "on":
state = mk2driver.PanelSwitchOn
canonical = "on"
case "off":
state = mk2driver.PanelSwitchOff
canonical = "off"
case "charger_only", "charger-only":
state = mk2driver.PanelSwitchChargerOnly
canonical = "charger_only"
case "inverter_only", "inverter-only":
state = mk2driver.PanelSwitchInverterOnly
canonical = "inverter_only"
default:
return nil, nil, fmt.Errorf(
"unsupported panel mode %q in control.allowed_panel_modes; supported values: on, off, charger_only, inverter_only",
token,
)
}
if _, exists := out[state]; !exists {
out[state] = struct{}{}
names = append(names, canonical)
}
}
if len(out) == 0 {
return nil, nil, fmt.Errorf("control.allowed_panel_modes is set but no valid modes were provided")
}
sort.Strings(names)
return out, names, nil
}

View File

@@ -0,0 +1,46 @@
package main
import (
"testing"
"git.coadcorp.com/nathan/invertergui/mk2driver"
)
func TestParseAllowedPanelModesEmpty(t *testing.T) {
states, names, err := parseAllowedPanelModes("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if states != nil {
t.Fatalf("expected nil state map for empty input, got %#v", states)
}
if names != nil {
t.Fatalf("expected nil names for empty input, got %#v", names)
}
}
func TestParseAllowedPanelModesValid(t *testing.T) {
states, names, err := parseAllowedPanelModes("off, charger-only,off")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(states) != 2 {
t.Fatalf("expected 2 allowed states, got %d", len(states))
}
if _, ok := states[mk2driver.PanelSwitchOff]; !ok {
t.Fatalf("off should be allowed")
}
if _, ok := states[mk2driver.PanelSwitchChargerOnly]; !ok {
t.Fatalf("charger_only should be allowed")
}
if len(names) != 2 {
t.Fatalf("expected 2 names, got %d", len(names))
}
}
func TestParseAllowedPanelModesInvalid(t *testing.T) {
_, _, err := parseAllowedPanelModes("off,invalid_mode")
if err == nil {
t.Fatal("expected error for invalid mode")
}
}