add capability to restrict remote panel modes
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
README.md
21
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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
46
cmd/invertergui/main_test.go
Normal file
46
cmd/invertergui/main_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user