From e700239764490221d82de435d33902452a48f477 Mon Sep 17 00:00:00 2001 From: Nathan Coad Date: Thu, 19 Feb 2026 15:55:20 +1100 Subject: [PATCH] add capability to restrict remote panel modes --- Dockerfile | 1 + Dockerfile.publish.amd64 | 1 + Dockerfile.publish.arm64 | 1 + README.md | 21 +++++++++++ cmd/invertergui/config.go | 8 +++- cmd/invertergui/main.go | 65 ++++++++++++++++++++++++++++++++ cmd/invertergui/main_test.go | 46 ++++++++++++++++++++++ mk2driver/managed_writer.go | 38 +++++++++++++++++++ mk2driver/managed_writer_test.go | 18 +++++++++ 9 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 cmd/invertergui/main_test.go diff --git a/Dockerfile b/Dockerfile index d9f43d7..08a580c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ FROM scratch # Group ID 20 is dialout, needed for tty read/write access USER 3000:20 ENV READ_ONLY=false +ENV CONTROL_ALLOWED_PANEL_MODES="" COPY --from=builder /build/invertergui /bin/ ENTRYPOINT [ "/bin/invertergui" ] EXPOSE 8080 diff --git a/Dockerfile.publish.amd64 b/Dockerfile.publish.amd64 index df3eb88..e85df19 100644 --- a/Dockerfile.publish.amd64 +++ b/Dockerfile.publish.amd64 @@ -3,6 +3,7 @@ FROM scratch # Group ID 20 is dialout, needed for tty read/write access USER 3000:20 ENV READ_ONLY=false +ENV CONTROL_ALLOWED_PANEL_MODES="" COPY dist/invertergui-linux-amd64 /bin/invertergui ENTRYPOINT ["/bin/invertergui"] EXPOSE 8080 diff --git a/Dockerfile.publish.arm64 b/Dockerfile.publish.arm64 index 0d6b856..ef3149d 100644 --- a/Dockerfile.publish.arm64 +++ b/Dockerfile.publish.arm64 @@ -3,6 +3,7 @@ FROM scratch # Group ID 20 is dialout, needed for tty read/write access USER 3000:20 ENV READ_ONLY=false +ENV CONTROL_ALLOWED_PANEL_MODES="" COPY dist/invertergui-linux-arm64 /bin/invertergui ENTRYPOINT ["/bin/invertergui"] EXPOSE 8080 diff --git a/README.md b/README.md index c6d2539..d466370 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Application Options: --control.max_current_limit= Optional max AC current limit guardrail in amps (0 disables). (default: 0) [$CONTROL_MAX_CURRENT_LIMIT] --control.mode_change_min_interval= Minimum time between mode changes. (default: 3s) [$CONTROL_MODE_CHANGE_MIN_INTERVAL] --control.lockout_window= Post-command lockout window for command arbitration. (default: 0s) [$CONTROL_LOCKOUT_WINDOW] + --control.allowed_panel_modes= Comma-separated allowlist of remote panel modes. Supported values: on, off, charger_only, inverter_only. Empty allows all modes. [$CONTROL_ALLOWED_PANEL_MODES] --loglevel= The log level to generate logs at. ("panic", "fatal", "error", "warn", "info", "debug", "trace") (default: info) [$LOGLEVEL] Help Options: @@ -136,6 +137,25 @@ This affects: - MQTT command handling (`--mqtt.command_topic` commands are ignored) - Web UI control actions (`POST /api/remote-panel/state` and `POST /api/remote-panel/standby`) +### Remote Panel Mode Allowlist + +Set `CONTROL_ALLOWED_PANEL_MODES` (or `--control.allowed_panel_modes`) to restrict which +remote panel modes can be selected from Web UI, MQTT, Home Assistant, and Venus-compatible +write paths. + +Supported values: + +- `on` +- `off` +- `charger_only` +- `inverter_only` + +Use a comma-separated list. Empty means all modes are allowed. + +Example: + +- `CONTROL_ALLOWED_PANEL_MODES=off,charger_only` + Example `docker-compose.yml` snippet: ```yaml @@ -144,6 +164,7 @@ services: image: registry.coadcorp.com/nathan/invertergui:latest environment: READ_ONLY: "true" + CONTROL_ALLOWED_PANEL_MODES: "off,charger_only" devices: - "/dev/ttyUSB0:/dev/ttyUSB0" command: ["--mqtt.enabled", "--mqtt.broker=tcp://192.168.1.1:1883", "--loglevel=info"] diff --git a/cmd/invertergui/config.go b/cmd/invertergui/config.go index 3ee3c16..9cf9acf 100644 --- a/cmd/invertergui/config.go +++ b/cmd/invertergui/config.go @@ -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 diff --git a/cmd/invertergui/main.go b/cmd/invertergui/main.go index dfd1345..d34b904 100644 --- a/cmd/invertergui/main.go +++ b/cmd/invertergui/main.go @@ -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 +} diff --git a/cmd/invertergui/main_test.go b/cmd/invertergui/main_test.go new file mode 100644 index 0000000..7476d74 --- /dev/null +++ b/cmd/invertergui/main_test.go @@ -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") + } +} diff --git a/mk2driver/managed_writer.go b/mk2driver/managed_writer.go index 071d30c..dd8e42d 100644 --- a/mk2driver/managed_writer.go +++ b/mk2driver/managed_writer.go @@ -3,6 +3,7 @@ package mk2driver import ( "errors" "fmt" + "sort" "sync" "time" ) @@ -20,6 +21,7 @@ type WriterPolicy struct { MaxCurrentLimitA *float64 ModeChangeMinInterval time.Duration LockoutWindow time.Duration + AllowedPanelStates map[PanelSwitchState]struct{} } type CommandEvent struct { @@ -93,6 +95,15 @@ func (m *ManagedWriter) SetPanelStateWithSource(source CommandSource, switchStat if err := m.ensureProfileAllows("set_panel_state"); err != nil { return err } + if len(m.policy.AllowedPanelStates) > 0 { + if _, ok := m.policy.AllowedPanelStates[switchState]; !ok { + return fmt.Errorf( + "panel switch mode %s denied by allowlist policy; allowed modes: %s", + panelStateLabel(switchState), + stringsForAllowedPanelStates(m.policy.AllowedPanelStates), + ) + } + } if m.policy.MaxCurrentLimitA != nil && currentLimitA != nil && *currentLimitA > *m.policy.MaxCurrentLimitA { return fmt.Errorf("current limit %.2fA exceeds configured policy max %.2fA", *currentLimitA, *m.policy.MaxCurrentLimitA) } @@ -192,3 +203,30 @@ func (m *ManagedWriter) recordLocked(source CommandSource, kind string, allowed func (m *ManagedWriter) baseWriter() SettingsWriter { return m.writer } + +func panelStateLabel(state PanelSwitchState) string { + switch state { + case PanelSwitchOn: + return "on" + case PanelSwitchOff: + return "off" + case PanelSwitchChargerOnly: + return "charger_only" + case PanelSwitchInverterOnly: + return "inverter_only" + default: + return fmt.Sprintf("unknown(0x%02x)", byte(state)) + } +} + +func stringsForAllowedPanelStates(states map[PanelSwitchState]struct{}) string { + if len(states) == 0 { + return "" + } + labels := make([]string, 0, len(states)) + for state := range states { + labels = append(labels, panelStateLabel(state)) + } + sort.Strings(labels) + return fmt.Sprintf("%v", labels) +} diff --git a/mk2driver/managed_writer_test.go b/mk2driver/managed_writer_test.go index 1e2c760..982df7f 100644 --- a/mk2driver/managed_writer_test.go +++ b/mk2driver/managed_writer_test.go @@ -77,3 +77,21 @@ func TestManagedWriterModeRateLimit(t *testing.T) { assert.Error(t, err) assert.Equal(t, 1, base.panelWrites) } + +func TestManagedWriterPanelModeAllowlist(t *testing.T) { + base := &writerStub{} + managed := NewManagedWriter(base, WriterPolicy{ + Profile: WriterProfileNormal, + AllowedPanelStates: map[PanelSwitchState]struct{}{ + PanelSwitchOff: {}, + }, + }) + + err := managed.SetPanelStateWithSource(CommandSourceMQTT, PanelSwitchOn, nil) + assert.Error(t, err) + assert.Equal(t, 0, base.panelWrites) + + err = managed.SetPanelStateWithSource(CommandSourceMQTT, PanelSwitchOff, nil) + assert.NoError(t, err) + assert.Equal(t, 1, base.panelWrites) +}