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

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

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

View File

@@ -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 "<none>"
}
labels := make([]string, 0, len(states))
for state := range states {
labels = append(labels, panelStateLabel(state))
}
sort.Strings(labels)
return fmt.Sprintf("%v", labels)
}

View File

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