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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user