Modernize invertergui: MQTT write support, HA integration, UI updates
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled

This commit is contained in:
2026-02-19 12:03:52 +11:00
parent 959d1e3c1f
commit a31a0b4829
460 changed files with 19655 additions and 40205 deletions

View File

@@ -1,7 +1,7 @@
package cli
import (
"github.com/diebietse/invertergui/mk2driver"
"invertergui/mk2driver"
"github.com/sirupsen/logrus"
)

View File

@@ -2,9 +2,14 @@ package mqttclient
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/diebietse/invertergui/mk2driver"
"invertergui/mk2driver"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/sirupsen/logrus"
)
@@ -13,42 +18,758 @@ var log = logrus.WithField("ctx", "inverter-gui-mqtt")
const keepAlive = 5 * time.Second
// Config sets MQTT client configuration
type Config struct {
Broker string
ClientID string
Topic string
Username string
Password string
const (
commandKindSetting = "setting"
commandKindRAMVar = "ram_var"
commandKindPanel = "panel_state"
commandKindStandby = "standby"
writeStatusOK = "ok"
writeStatusError = "error"
)
type HomeAssistantConfig struct {
Enabled bool
DiscoveryPrefix string
NodeID string
DeviceName string
}
// New creates an MQTT client that starts publishing MK2 data as it is received.
func New(mk2 mk2driver.Mk2, config Config) error {
// Config sets MQTT client configuration
type Config struct {
Broker string
ClientID string
Topic string
CommandTopic string
StatusTopic string
HomeAssistant HomeAssistantConfig
Username string
Password string
}
type writeCommand struct {
RequestID string
Kind string
ID uint16
Value int16
HasSwitch bool
SwitchState mk2driver.PanelSwitchState
SwitchName string
CurrentLimitA *float64
Standby *bool
}
type writeCommandPayload struct {
RequestID string `json:"request_id"`
Kind string `json:"kind"`
Type string `json:"type"`
ID *uint16 `json:"id"`
Value json.RawMessage `json:"value"`
Switch string `json:"switch"`
SwitchState string `json:"switch_state"`
CurrentLimitA *float64 `json:"current_limit"`
Standby *bool `json:"standby"`
}
type writeStatus struct {
RequestID string `json:"request_id,omitempty"`
Status string `json:"status"`
Kind string `json:"kind,omitempty"`
ID uint16 `json:"id"`
Value int16 `json:"value"`
Switch string `json:"switch,omitempty"`
CurrentLimitA *float64 `json:"current_limit,omitempty"`
Standby *bool `json:"standby,omitempty"`
Error string `json:"error,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type haDiscoveryDefinition struct {
Component string
ObjectID string
Config map[string]any
}
type panelStateCache struct {
mu sync.Mutex
hasSwitch bool
switchName string
switchState mk2driver.PanelSwitchState
}
// New creates an MQTT client that publishes MK2 updates and optionally handles setting write commands.
func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) error {
c := mqtt.NewClient(getOpts(config))
if token := c.Connect(); token.Wait() && token.Error() != nil {
return token.Error()
}
cache := &panelStateCache{}
if config.HomeAssistant.Enabled {
if err := publishHAAvailability(c, config, "online"); err != nil {
return fmt.Errorf("could not publish Home Assistant availability: %w", err)
}
if err := publishHADiscovery(c, config); err != nil {
return fmt.Errorf("could not publish Home Assistant discovery payloads: %w", err)
}
if writer != nil {
if err := subscribeHAPanelModeState(c, config, cache); err != nil {
log.Warnf("Could not subscribe to Home Assistant panel mode state topic: %v", err)
}
}
}
if config.CommandTopic != "" {
if writer == nil {
log.Warnf("MQTT command topic %q configured, but no settings writer is available", config.CommandTopic)
} else {
t := c.Subscribe(config.CommandTopic, 1, commandHandler(c, writer, config, cache))
t.Wait()
if t.Error() != nil {
return fmt.Errorf("could not subscribe to MQTT command topic %q: %w", config.CommandTopic, t.Error())
}
log.Infof("Subscribed to MQTT command topic: %s", config.CommandTopic)
}
}
go func() {
for e := range mk2.C() {
if e.Valid {
data, err := json.Marshal(e)
if err != nil {
log.Errorf("Could not parse data source: %v", err)
continue
}
t := c.Publish(config.Topic, 0, false, data)
t.Wait()
if t.Error() != nil {
log.Errorf("Could not publish data: %v", t.Error())
}
if e == nil || !e.Valid {
continue
}
if err := publishJSON(c, config.Topic, e, 0, false); err != nil {
log.Errorf("Could not publish update to MQTT topic %q: %v", config.Topic, err)
}
}
}()
return nil
}
func subscribeHAPanelModeState(client mqtt.Client, config Config, cache *panelStateCache) error {
if cache == nil {
return nil
}
stateTopic := haPanelSwitchStateTopic(config)
t := client.Subscribe(stateTopic, 1, func(_ mqtt.Client, msg mqtt.Message) {
switchState, switchName, err := normalizePanelSwitch(string(msg.Payload()))
if err != nil {
return
}
cache.remember(writeCommand{
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: switchState,
SwitchName: switchName,
})
})
t.Wait()
return t.Error()
}
func commandHandler(client mqtt.Client, writer mk2driver.SettingsWriter, config Config, cache *panelStateCache) mqtt.MessageHandler {
if cache == nil {
cache = &panelStateCache{}
}
return func(_ mqtt.Client, msg mqtt.Message) {
cmd, err := decodeWriteCommand(msg.Payload())
if err != nil {
log.Errorf("Invalid MQTT write command payload from topic %q: %v", msg.Topic(), err)
publishWriteStatus(client, config.StatusTopic, writeStatus{
Status: writeStatusError,
Error: err.Error(),
Timestamp: time.Now().UTC(),
})
return
}
execCmd := cmd
status := writeStatus{
RequestID: cmd.RequestID,
Status: writeStatusOK,
Kind: cmd.Kind,
Timestamp: time.Now().UTC(),
}
switch cmd.Kind {
case commandKindPanel:
execCmd, err = cache.resolvePanelCommand(cmd)
if err != nil {
status.Status = writeStatusError
status.Error = err.Error()
log.Errorf("Invalid MQTT write command %s: %v", formatWriteCommandLog(cmd), err)
publishWriteStatus(client, config.StatusTopic, status)
return
}
status.Switch = execCmd.SwitchName
status.CurrentLimitA = execCmd.CurrentLimitA
case commandKindStandby:
status.Standby = copyBoolPtr(execCmd.Standby)
default:
status.ID = cmd.ID
status.Value = cmd.Value
}
if err := executeWriteCommand(writer, execCmd); err != nil {
status.Status = writeStatusError
status.Error = err.Error()
log.Errorf("Failed MQTT write command %s: %v", formatWriteCommandLog(execCmd), err)
} else {
log.Infof("Applied MQTT write command %s", formatWriteCommandLog(execCmd))
cache.remember(execCmd)
if config.HomeAssistant.Enabled {
if err := publishHAControlState(client, config, execCmd); err != nil {
log.Errorf("Could not publish Home Assistant control state update: %v", err)
}
}
}
publishWriteStatus(client, config.StatusTopic, status)
}
}
func (c *panelStateCache) resolvePanelCommand(cmd writeCommand) (writeCommand, error) {
if cmd.Kind != commandKindPanel {
return cmd, nil
}
if cmd.HasSwitch {
return cmd, nil
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.hasSwitch {
return writeCommand{}, errors.New("panel_state command missing switch and no prior mode is known; set mode once first")
}
cmd.HasSwitch = true
cmd.SwitchName = c.switchName
cmd.SwitchState = c.switchState
return cmd, nil
}
func (c *panelStateCache) remember(cmd writeCommand) {
if cmd.Kind != commandKindPanel || !cmd.HasSwitch {
return
}
c.mu.Lock()
c.hasSwitch = true
c.switchName = cmd.SwitchName
c.switchState = cmd.SwitchState
c.mu.Unlock()
}
func decodeWriteCommand(payload []byte) (writeCommand, error) {
msg := writeCommandPayload{}
if err := json.Unmarshal(payload, &msg); err != nil {
return writeCommand{}, fmt.Errorf("invalid JSON payload: %w", err)
}
kind := msg.Kind
if kind == "" {
kind = msg.Type
}
normalizedKind, err := normalizeWriteKind(kind)
if err != nil {
return writeCommand{}, err
}
if normalizedKind == commandKindPanel {
switchName := msg.Switch
if switchName == "" {
switchName = msg.SwitchState
}
hasSwitch := false
switchState := mk2driver.PanelSwitchState(0)
normalizedSwitchName := ""
if switchName != "" {
var err error
switchState, normalizedSwitchName, err = normalizePanelSwitch(switchName)
if err != nil {
return writeCommand{}, err
}
hasSwitch = true
}
if msg.CurrentLimitA != nil && *msg.CurrentLimitA < 0 {
return writeCommand{}, fmt.Errorf("current_limit must be >= 0, got %.3f", *msg.CurrentLimitA)
}
if !hasSwitch && msg.CurrentLimitA == nil {
return writeCommand{}, errors.New(`missing required field "switch" (or "switch_state"), or "current_limit"`)
}
return writeCommand{
RequestID: msg.RequestID,
Kind: normalizedKind,
HasSwitch: hasSwitch,
SwitchState: switchState,
SwitchName: normalizedSwitchName,
CurrentLimitA: msg.CurrentLimitA,
}, nil
}
if normalizedKind == commandKindStandby {
standby, err := decodeStandbyValue(msg)
if err != nil {
return writeCommand{}, err
}
return writeCommand{
RequestID: msg.RequestID,
Kind: normalizedKind,
Standby: &standby,
}, nil
}
if msg.ID == nil {
return writeCommand{}, errors.New(`missing required field "id"`)
}
value, err := decodeInt16Value(msg.Value)
if err != nil {
return writeCommand{}, err
}
return writeCommand{
RequestID: msg.RequestID,
Kind: normalizedKind,
ID: *msg.ID,
Value: value,
}, nil
}
func normalizeWriteKind(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "setting", "settings":
return commandKindSetting, nil
case "ram", "ramvar", "ram_var", "ram-variable", "ramvariable":
return commandKindRAMVar, nil
case "panel", "panel_state", "switch", "remote_panel":
return commandKindPanel, nil
case "standby", "panel_standby", "remote_panel_standby":
return commandKindStandby, nil
default:
return "", fmt.Errorf("unsupported write command kind %q", raw)
}
}
func normalizePanelSwitch(raw string) (mk2driver.PanelSwitchState, string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "charger_only", "charger-only", "charger":
return mk2driver.PanelSwitchChargerOnly, "charger_only", nil
case "2", "inverter_only", "inverter-only", "inverter":
return mk2driver.PanelSwitchInverterOnly, "inverter_only", nil
case "3", "on":
return mk2driver.PanelSwitchOn, "on", nil
case "4", "off":
return mk2driver.PanelSwitchOff, "off", nil
default:
return 0, "", fmt.Errorf("unsupported panel switch %q", raw)
}
}
func executeWriteCommand(writer mk2driver.SettingsWriter, cmd writeCommand) error {
if writer == nil {
return errors.New("settings writer is not available")
}
switch cmd.Kind {
case commandKindPanel:
if !cmd.HasSwitch {
return errors.New("panel_state command requires a switch state")
}
return writer.SetPanelState(cmd.SwitchState, cmd.CurrentLimitA)
case commandKindStandby:
if cmd.Standby == nil {
return errors.New("standby command missing standby value")
}
return writer.SetStandby(*cmd.Standby)
case commandKindRAMVar:
return writer.WriteRAMVar(cmd.ID, cmd.Value)
case commandKindSetting:
return writer.WriteSetting(cmd.ID, cmd.Value)
default:
return fmt.Errorf("unsupported write command kind %q", cmd.Kind)
}
}
func formatWriteCommandLog(cmd writeCommand) string {
switch cmd.Kind {
case commandKindPanel:
switchName := cmd.SwitchName
if switchName == "" {
switchName = "<cached>"
}
if cmd.CurrentLimitA == nil {
return fmt.Sprintf("kind=%s switch=%s", cmd.Kind, switchName)
}
return fmt.Sprintf("kind=%s switch=%s current_limit=%.3f", cmd.Kind, switchName, *cmd.CurrentLimitA)
case commandKindStandby:
if cmd.Standby == nil {
return fmt.Sprintf("kind=%s standby=<unset>", cmd.Kind)
}
return fmt.Sprintf("kind=%s standby=%t", cmd.Kind, *cmd.Standby)
default:
return fmt.Sprintf("kind=%s id=%d value=%d", cmd.Kind, cmd.ID, cmd.Value)
}
}
func decodeInt16Value(raw json.RawMessage) (int16, error) {
if len(raw) == 0 {
return 0, errors.New(`missing required field "value"`)
}
var value int16
if err := json.Unmarshal(raw, &value); err != nil {
return 0, fmt.Errorf(`field "value" must be a signed integer: %w`, err)
}
return value, nil
}
func decodeStandbyValue(msg writeCommandPayload) (bool, error) {
if msg.Standby != nil {
return *msg.Standby, nil
}
if len(msg.Value) == 0 {
return false, errors.New(`missing required field "standby" (or boolean "value")`)
}
var boolValue bool
if err := json.Unmarshal(msg.Value, &boolValue); err == nil {
return boolValue, nil
}
var stringValue string
if err := json.Unmarshal(msg.Value, &stringValue); err == nil {
switch strings.ToLower(strings.TrimSpace(stringValue)) {
case "1", "true", "on", "enable", "enabled":
return true, nil
case "0", "false", "off", "disable", "disabled":
return false, nil
}
}
var intValue int
if err := json.Unmarshal(msg.Value, &intValue); err == nil {
switch intValue {
case 1:
return true, nil
case 0:
return false, nil
}
}
return false, errors.New(`field "standby" must be true/false`)
}
func publishWriteStatus(client mqtt.Client, topic string, status writeStatus) {
if topic == "" {
return
}
if err := publishJSON(client, topic, status, 1, false); err != nil {
log.Errorf("Could not publish command status to MQTT topic %q: %v", topic, err)
}
}
func publishHADiscovery(client mqtt.Client, config Config) error {
definitions := buildHADiscoveryDefinitions(config)
prefix := haDiscoveryPrefix(config)
nodeID := haNodeID(config)
for _, def := range definitions {
topic := fmt.Sprintf("%s/%s/%s/%s/config", prefix, def.Component, nodeID, def.ObjectID)
if err := publishJSON(client, topic, def.Config, 1, true); err != nil {
return fmt.Errorf("could not publish discovery for %s/%s: %w", def.Component, def.ObjectID, err)
}
}
return nil
}
func buildHADiscoveryDefinitions(config Config) []haDiscoveryDefinition {
if !config.HomeAssistant.Enabled {
return nil
}
nodeID := haNodeID(config)
device := map[string]any{
"identifiers": []string{fmt.Sprintf("invertergui_%s", nodeID)},
"name": haDeviceName(config),
"manufacturer": "Victron Energy",
"model": "MultiPlus",
"sw_version": "invertergui",
}
availabilityTopic := haAvailabilityTopic(config)
stateTopic := config.Topic
sensors := []haDiscoveryDefinition{
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "battery_voltage", "Battery Voltage", "{{ value_json.BatVoltage }}", "V", "voltage", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "battery_current", "Battery Current", "{{ value_json.BatCurrent }}", "A", "current", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "battery_charge", "Battery Charge", "{{ ((value_json.ChargeState | float(0)) * 100) | round(1) }}", "%", "battery", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "input_voltage", "Input Voltage", "{{ value_json.InVoltage }}", "V", "voltage", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "input_current", "Input Current", "{{ value_json.InCurrent }}", "A", "current", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "input_frequency", "Input Frequency", "{{ value_json.InFrequency }}", "Hz", "frequency", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "output_voltage", "Output Voltage", "{{ value_json.OutVoltage }}", "V", "voltage", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "output_current", "Output Current", "{{ value_json.OutCurrent }}", "A", "current", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "output_frequency", "Output Frequency", "{{ value_json.OutFrequency }}", "Hz", "frequency", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "input_power", "Input Power", "{{ ((value_json.InVoltage | float(0)) * (value_json.InCurrent | float(0))) | round(1) }}", "VA", "", "measurement"),
buildHASensor(device, availabilityTopic, stateTopic, nodeID, "output_power", "Output Power", "{{ ((value_json.OutVoltage | float(0)) * (value_json.OutCurrent | float(0))) | round(1) }}", "VA", "", "measurement"),
{
Component: "binary_sensor",
ObjectID: "data_valid",
Config: map[string]any{
"name": "Data Valid",
"unique_id": fmt.Sprintf("%s_data_valid", nodeID),
"state_topic": stateTopic,
"value_template": "{{ value_json.Valid }}",
"payload_on": "true",
"payload_off": "false",
"availability_topic": availabilityTopic,
"device": device,
"entity_category": "diagnostic",
},
},
}
if config.CommandTopic != "" {
sensors = append(sensors,
haDiscoveryDefinition{
Component: "select",
ObjectID: "remote_panel_mode",
Config: map[string]any{
"name": "Remote Panel Mode",
"unique_id": fmt.Sprintf("%s_remote_panel_mode", nodeID),
"state_topic": haPanelSwitchStateTopic(config),
"command_topic": config.CommandTopic,
"command_template": "{\"kind\":\"panel_state\",\"switch\":\"{{ value }}\"}",
"options": []string{"charger_only", "inverter_only", "on", "off"},
"availability_topic": availabilityTopic,
"device": device,
"icon": "mdi:transmission-tower-export",
},
},
haDiscoveryDefinition{
Component: "number",
ObjectID: "remote_panel_current_limit",
Config: map[string]any{
"name": "Remote Panel Current Limit",
"unique_id": fmt.Sprintf("%s_remote_panel_current_limit", nodeID),
"state_topic": haCurrentLimitStateTopic(config),
"command_topic": config.CommandTopic,
"command_template": "{\"kind\":\"panel_state\",\"current_limit\":{{ value | float(0) }}}",
"unit_of_measurement": "A",
"device_class": "current",
"mode": "box",
"min": 0,
"max": 100,
"step": 0.1,
"availability_topic": availabilityTopic,
"device": device,
"icon": "mdi:current-ac",
},
},
haDiscoveryDefinition{
Component: "switch",
ObjectID: "remote_panel_standby",
Config: map[string]any{
"name": "Remote Panel Standby",
"unique_id": fmt.Sprintf("%s_remote_panel_standby", nodeID),
"state_topic": haStandbyStateTopic(config),
"command_topic": config.CommandTopic,
"payload_on": "{\"kind\":\"standby\",\"standby\":true}",
"payload_off": "{\"kind\":\"standby\",\"standby\":false}",
"state_on": "ON",
"state_off": "OFF",
"availability_topic": availabilityTopic,
"device": device,
"icon": "mdi:power-sleep",
},
},
)
}
return sensors
}
func buildHASensor(device map[string]any, availabilityTopic, stateTopic, nodeID, objectID, name, valueTemplate, unit, deviceClass, stateClass string) haDiscoveryDefinition {
config := map[string]any{
"name": name,
"unique_id": fmt.Sprintf("%s_%s", nodeID, objectID),
"state_topic": stateTopic,
"value_template": valueTemplate,
"availability_topic": availabilityTopic,
"device": device,
}
if unit != "" {
config["unit_of_measurement"] = unit
}
if deviceClass != "" {
config["device_class"] = deviceClass
}
if stateClass != "" {
config["state_class"] = stateClass
}
return haDiscoveryDefinition{
Component: "sensor",
ObjectID: objectID,
Config: config,
}
}
func publishHAAvailability(client mqtt.Client, config Config, status string) error {
return publishString(client, haAvailabilityTopic(config), status, 1, true)
}
func publishHAControlState(client mqtt.Client, config Config, cmd writeCommand) error {
switch cmd.Kind {
case commandKindPanel:
if err := publishString(client, haPanelSwitchStateTopic(config), cmd.SwitchName, 1, true); err != nil {
return err
}
if cmd.CurrentLimitA != nil {
limit := strconv.FormatFloat(*cmd.CurrentLimitA, 'f', 1, 64)
if err := publishString(client, haCurrentLimitStateTopic(config), limit, 1, true); err != nil {
return err
}
}
case commandKindStandby:
if cmd.Standby == nil {
return nil
}
state := "OFF"
if *cmd.Standby {
state = "ON"
}
if err := publishString(client, haStandbyStateTopic(config), state, 1, true); err != nil {
return err
}
}
return nil
}
func publishJSON(client mqtt.Client, topic string, payload any, qos byte, retained bool) error {
if topic == "" {
return errors.New("topic is empty")
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("could not marshal payload: %w", err)
}
t := client.Publish(topic, qos, retained, data)
t.Wait()
if t.Error() != nil {
return t.Error()
}
return nil
}
func publishString(client mqtt.Client, topic, payload string, qos byte, retained bool) error {
if topic == "" {
return errors.New("topic is empty")
}
t := client.Publish(topic, qos, retained, payload)
t.Wait()
if t.Error() != nil {
return t.Error()
}
return nil
}
func mqttTopicRoot(topic string) string {
t := strings.Trim(strings.TrimSpace(topic), "/")
if t == "" {
return "invertergui"
}
if strings.HasSuffix(t, "/updates") {
root := strings.TrimSuffix(t, "/updates")
if root != "" {
return root
}
}
return t
}
func haAvailabilityTopic(config Config) string {
return fmt.Sprintf("%s/homeassistant/availability", mqttTopicRoot(config.Topic))
}
func haPanelSwitchStateTopic(config Config) string {
return fmt.Sprintf("%s/homeassistant/remote_panel_mode/state", mqttTopicRoot(config.Topic))
}
func haCurrentLimitStateTopic(config Config) string {
return fmt.Sprintf("%s/homeassistant/remote_panel_current_limit/state", mqttTopicRoot(config.Topic))
}
func haStandbyStateTopic(config Config) string {
return fmt.Sprintf("%s/homeassistant/remote_panel_standby/state", mqttTopicRoot(config.Topic))
}
func haDiscoveryPrefix(config Config) string {
prefix := strings.Trim(strings.TrimSpace(config.HomeAssistant.DiscoveryPrefix), "/")
if prefix == "" {
return "homeassistant"
}
return prefix
}
func haNodeID(config Config) string {
nodeID := normalizeID(config.HomeAssistant.NodeID)
if nodeID == "" {
nodeID = normalizeID(config.ClientID)
}
if nodeID == "" {
return "invertergui"
}
return nodeID
}
func haDeviceName(config Config) string {
name := strings.TrimSpace(config.HomeAssistant.DeviceName)
if name == "" {
return "Victron Inverter"
}
return name
}
func normalizeID(in string) string {
trimmed := strings.TrimSpace(strings.ToLower(in))
if trimmed == "" {
return ""
}
var b strings.Builder
lastUnderscore := false
for _, r := range trimmed {
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_'
if valid {
b.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
b.WriteRune('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}
func copyBoolPtr(in *bool) *bool {
if in == nil {
return nil
}
value := *in
return &value
}
func getOpts(config Config) *mqtt.ClientOptions {
opts := mqtt.NewClientOptions()
opts.AddBroker(config.Broker)
@@ -60,6 +781,9 @@ func getOpts(config Config) *mqtt.ClientOptions {
if config.Password != "" {
opts.SetPassword(config.Password)
}
if config.HomeAssistant.Enabled {
opts.SetWill(haAvailabilityTopic(config), "offline", 1, true)
}
opts.SetKeepAlive(keepAlive)
opts.SetOnConnectHandler(func(mqtt.Client) {
@@ -67,7 +791,6 @@ func getOpts(config Config) *mqtt.ClientOptions {
})
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Errorf("Client connection to broker lost: %v", err)
})
return opts
}

View File

@@ -0,0 +1,316 @@
package mqttclient
import (
"testing"
"invertergui/mk2driver"
"github.com/stretchr/testify/assert"
)
type fakeWriter struct {
lastKind string
lastID uint16
lastValue int16
lastSwitchState mk2driver.PanelSwitchState
lastCurrentLimit *float64
lastStandby *bool
err error
}
func (f *fakeWriter) WriteRAMVar(id uint16, value int16) error {
f.lastKind = commandKindRAMVar
f.lastID = id
f.lastValue = value
return f.err
}
func (f *fakeWriter) WriteSetting(id uint16, value int16) error {
f.lastKind = commandKindSetting
f.lastID = id
f.lastValue = value
return f.err
}
func (f *fakeWriter) SetPanelState(switchState mk2driver.PanelSwitchState, currentLimitA *float64) error {
f.lastKind = commandKindPanel
f.lastSwitchState = switchState
f.lastCurrentLimit = currentLimitA
return f.err
}
func (f *fakeWriter) SetStandby(standby bool) error {
f.lastKind = commandKindStandby
f.lastStandby = &standby
return f.err
}
func Test_decodeWriteCommand(t *testing.T) {
currentLimit := 16.5
tests := []struct {
name string
payload string
check func(*testing.T, writeCommand)
wantErr string
}{
{
name: "setting",
payload: `{"request_id":"abc","kind":"setting","id":15,"value":-5}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, writeCommand{
RequestID: "abc",
Kind: commandKindSetting,
ID: 15,
Value: -5,
}, got)
},
},
{
name: "ram_var alias from type",
payload: `{"type":"ramvar","id":2,"value":7}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, writeCommand{
Kind: commandKindRAMVar,
ID: 2,
Value: 7,
}, got)
},
},
{
name: "panel state",
payload: `{"kind":"panel_state","switch":"on","current_limit":16.5}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindPanel, got.Kind)
assert.True(t, got.HasSwitch)
assert.Equal(t, mk2driver.PanelSwitchOn, got.SwitchState)
assert.Equal(t, "on", got.SwitchName)
if assert.NotNil(t, got.CurrentLimitA) {
assert.Equal(t, currentLimit, *got.CurrentLimitA)
}
},
},
{
name: "panel current limit only",
payload: `{"kind":"panel_state","current_limit":12.0}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindPanel, got.Kind)
assert.False(t, got.HasSwitch)
assert.Nil(t, got.Standby)
if assert.NotNil(t, got.CurrentLimitA) {
assert.Equal(t, 12.0, *got.CurrentLimitA)
}
},
},
{
name: "standby bool",
payload: `{"kind":"standby","standby":true}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindStandby, got.Kind)
if assert.NotNil(t, got.Standby) {
assert.True(t, *got.Standby)
}
},
},
{
name: "standby using value string",
payload: `{"kind":"standby","value":"OFF"}`,
check: func(t *testing.T, got writeCommand) {
assert.Equal(t, commandKindStandby, got.Kind)
if assert.NotNil(t, got.Standby) {
assert.False(t, *got.Standby)
}
},
},
{
name: "missing id",
payload: `{"kind":"setting","value":1}`,
wantErr: `missing required field "id"`,
},
{
name: "missing panel switch and current limit",
payload: `{"kind":"panel_state"}`,
wantErr: `missing required field "switch"`,
},
{
name: "invalid standby",
payload: `{"kind":"standby","value":"banana"}`,
wantErr: `field "standby" must be true/false`,
},
{
name: "invalid kind",
payload: `{"kind":"unknown","id":1,"value":1}`,
wantErr: `unsupported write command kind "unknown"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := decodeWriteCommand([]byte(tt.payload))
if tt.wantErr != "" {
assert.Error(t, err)
assert.ErrorContains(t, err, tt.wantErr)
return
}
assert.NoError(t, err)
tt.check(t, got)
})
}
}
func Test_executeWriteCommand(t *testing.T) {
limit := 8.0
standby := true
tests := []struct {
name string
cmd writeCommand
want string
}{
{
name: "setting",
cmd: writeCommand{
Kind: commandKindSetting,
ID: 9,
Value: 2,
},
want: commandKindSetting,
},
{
name: "ram_var",
cmd: writeCommand{
Kind: commandKindRAMVar,
ID: 3,
Value: -1,
},
want: commandKindRAMVar,
},
{
name: "panel_state",
cmd: writeCommand{
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: mk2driver.PanelSwitchInverterOnly,
CurrentLimitA: &limit,
},
want: commandKindPanel,
},
{
name: "standby",
cmd: writeCommand{
Kind: commandKindStandby,
Standby: &standby,
},
want: commandKindStandby,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
writer := &fakeWriter{}
err := executeWriteCommand(writer, tt.cmd)
assert.NoError(t, err)
assert.Equal(t, tt.want, writer.lastKind)
switch tt.want {
case commandKindPanel:
assert.Equal(t, tt.cmd.SwitchState, writer.lastSwitchState)
if assert.NotNil(t, writer.lastCurrentLimit) {
assert.Equal(t, *tt.cmd.CurrentLimitA, *writer.lastCurrentLimit)
}
case commandKindStandby:
if assert.NotNil(t, writer.lastStandby) {
assert.Equal(t, *tt.cmd.Standby, *writer.lastStandby)
}
default:
assert.Equal(t, tt.cmd.ID, writer.lastID)
assert.Equal(t, tt.cmd.Value, writer.lastValue)
}
})
}
}
func Test_buildHADiscoveryDefinitions(t *testing.T) {
cfg := Config{
Topic: "invertergui/updates",
CommandTopic: "invertergui/settings/set",
HomeAssistant: HomeAssistantConfig{
Enabled: true,
DiscoveryPrefix: "homeassistant",
NodeID: "victron_main",
DeviceName: "Shed Victron",
},
}
definitions := buildHADiscoveryDefinitions(cfg)
assert.NotEmpty(t, definitions)
var panelMode *haDiscoveryDefinition
var panelCurrentLimit *haDiscoveryDefinition
var panelStandby *haDiscoveryDefinition
var batteryVoltage *haDiscoveryDefinition
for i := range definitions {
def := &definitions[i]
if def.Component == "select" && def.ObjectID == "remote_panel_mode" {
panelMode = def
}
if def.Component == "number" && def.ObjectID == "remote_panel_current_limit" {
panelCurrentLimit = def
}
if def.Component == "switch" && def.ObjectID == "remote_panel_standby" {
panelStandby = def
}
if def.Component == "sensor" && def.ObjectID == "battery_voltage" {
batteryVoltage = def
}
}
if assert.NotNil(t, panelMode) {
assert.Equal(t, cfg.CommandTopic, panelMode.Config["command_topic"])
assert.Equal(t, haPanelSwitchStateTopic(cfg), panelMode.Config["state_topic"])
}
if assert.NotNil(t, panelCurrentLimit) {
assert.Equal(t, cfg.CommandTopic, panelCurrentLimit.Config["command_topic"])
assert.Equal(t, haCurrentLimitStateTopic(cfg), panelCurrentLimit.Config["state_topic"])
}
if assert.NotNil(t, panelStandby) {
assert.Equal(t, cfg.CommandTopic, panelStandby.Config["command_topic"])
assert.Equal(t, haStandbyStateTopic(cfg), panelStandby.Config["state_topic"])
}
if assert.NotNil(t, batteryVoltage) {
assert.Equal(t, cfg.Topic, batteryVoltage.Config["state_topic"])
}
}
func Test_panelStateCacheResolvePanelCommand(t *testing.T) {
cache := &panelStateCache{}
_, err := cache.resolvePanelCommand(writeCommand{
Kind: commandKindPanel,
CurrentLimitA: float64Ptr(12),
})
assert.Error(t, err)
cache.remember(writeCommand{
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: mk2driver.PanelSwitchOn,
SwitchName: "on",
})
resolved, err := cache.resolvePanelCommand(writeCommand{
Kind: commandKindPanel,
CurrentLimitA: float64Ptr(10),
})
assert.NoError(t, err)
assert.True(t, resolved.HasSwitch)
assert.Equal(t, mk2driver.PanelSwitchOn, resolved.SwitchState)
assert.Equal(t, "on", resolved.SwitchName)
}
func float64Ptr(in float64) *float64 {
return &in
}
func Test_normalizeID(t *testing.T) {
assert.Equal(t, "victron_main_01", normalizeID("Victron Main #01"))
assert.Equal(t, "inverter-gui", normalizeID(" inverter-gui "))
assert.Equal(t, "", normalizeID(" "))
}

View File

@@ -36,7 +36,7 @@ import (
"net/http"
"time"
"github.com/diebietse/invertergui/mk2driver"
"invertergui/mk2driver"
"github.com/sirupsen/logrus"
)

View File

@@ -6,7 +6,7 @@ import (
"net/http/httptest"
"testing"
"github.com/diebietse/invertergui/mk2driver"
"invertergui/mk2driver"
)
func TestServer(_ *testing.T) {

View File

@@ -31,7 +31,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package prometheus
import (
"github.com/diebietse/invertergui/mk2driver"
"invertergui/mk2driver"
"github.com/prometheus/client_golang/prometheus"
)

View File

@@ -98,6 +98,117 @@
<div class="alert alert-danger" role="alert" v-if="error.has_error">
{{ error.error_message }}
</div>
<div
class="alert"
v-if="control.message !== ''"
v-bind:class="[control.has_error ? 'alert-danger' : 'alert-success']"
>
{{ control.message }}
</div>
<div class="row">
<div class="col">
<div class="card mb-3">
<div class="card-body">
<h4 class="card-title">Remote Panel Control</h4>
<p class="text-muted mb-2">
Mode and current limit are applied together, equivalent to
<code>set_remote_panel_state</code>.
</p>
<p class="mb-1">
<strong>Current Mode:</strong>
{{ remoteModeLabel(state.remote_panel) }}
</p>
<p class="mb-1">
<strong>Current Limit:</strong>
{{ state.remote_panel.current_limit === null || state.remote_panel.current_limit === undefined ? 'Unknown' : state.remote_panel.current_limit + ' A' }}
</p>
<p class="mb-3">
<strong>Standby:</strong>
{{ remoteStandbyLabel(state.remote_panel) }}
</p>
<div class="row">
<div class="col-md-8">
<form v-on:submit.prevent="applyRemotePanelState">
<div class="form-row">
<div class="form-group col-md-6">
<label for="modeSelect">Remote Panel Mode</label>
<select
class="form-control"
id="modeSelect"
v-model="remote_form.mode"
v-bind:disabled="!state.remote_panel.writable || control.busy"
>
<option value="on">on</option>
<option value="off">off</option>
<option value="charger_only">charger_only</option>
<option value="inverter_only">inverter_only</option>
</select>
</div>
<div class="form-group col-md-6">
<label for="currentLimitInput">AC Input Current Limit (A)</label>
<input
id="currentLimitInput"
type="number"
min="0"
step="0.1"
class="form-control"
v-model="remote_form.current_limit"
placeholder="leave blank to keep current"
v-bind:disabled="!state.remote_panel.writable || control.busy"
/>
</div>
</div>
<button
type="submit"
class="btn btn-primary"
v-bind:disabled="!state.remote_panel.writable || control.busy"
>
Apply Mode + Current Limit
</button>
</form>
</div>
<div class="col-md-4">
<form v-on:submit.prevent="applyStandby">
<div class="form-group">
<div class="form-check mt-4">
<input
id="standbySwitch"
type="checkbox"
class="form-check-input"
v-model="remote_form.standby"
v-bind:disabled="!state.remote_panel.writable || control.busy"
/>
<label class="form-check-label" for="standbySwitch">
Prevent sleep while off
</label>
</div>
</div>
<button
type="submit"
class="btn btn-secondary"
v-bind:disabled="!state.remote_panel.writable || control.busy"
>
Apply Standby
</button>
</form>
</div>
</div>
<div class="mt-3 text-muted" v-if="state.remote_panel.last_updated">
Last update {{ state.remote_panel.last_updated }}
<span v-if="state.remote_panel.last_command">
({{ state.remote_panel.last_command }})
</span>
</div>
<div class="mt-2 text-danger" v-if="state.remote_panel.last_error">
{{ state.remote_panel.last_error }}
</div>
<div class="mt-2 text-warning" v-if="!state.remote_panel.writable">
Remote control is unavailable for this data source.
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<hr />

View File

@@ -3,6 +3,46 @@ const timeoutMax = 30000;
const timeoutMin = 1000;
var timeout = timeoutMin;
function defaultRemotePanelState() {
return {
writable: false,
mode: "unknown",
current_limit: null,
standby: null,
last_command: "",
last_error: "",
last_updated: ""
};
}
function defaultState() {
return {
output_current: null,
output_voltage: 0,
output_frequency: 0,
output_power: 0,
input_current: 0,
input_voltage: 0,
input_frequency: 0,
input_power: 0,
battery_current: 0,
battery_voltage: 0,
battery_charge: 0,
battery_power: 0,
led_map: {
led_mains: "dot-off",
led_absorb: "dot-off",
led_bulk: "dot-off",
led_float: "dot-off",
led_inverter: "dot-off",
led_overload: "dot-off",
led_bat_low: "dot-off",
led_over_temp: "dot-off"
},
remote_panel: defaultRemotePanelState()
};
}
function loadContent() {
app = new Vue({
el: "#app",
@@ -11,33 +51,172 @@ function loadContent() {
has_error: false,
error_message: ""
},
state: {
output_current: null,
output_voltage: 0,
output_frequency: 0,
output_power: 0,
input_current: 0,
input_voltage: 0,
input_frequency: 0,
input_power: 0,
battery_current: 0,
battery_voltage: 0,
battery_charge: 0,
battery_power: 0,
led_map: [
{ led_mains: "dot-off" },
{ led_absorb: "dot-off" },
{ led_bulk: "dot-off" },
{ led_float: "dot-off" },
{ led_inverter: "dot-off" },
{ led_overload: "dot-off" },
{ led_bat_low: "dot-off" },
{ led_over_temp: "dot-off" }
]
control: {
busy: false,
has_error: false,
message: ""
},
remote_form: {
mode: "on",
current_limit: "",
standby: false
},
state: defaultState()
},
methods: {
syncRemoteFormFromState: function(remoteState) {
if (!remoteState) {
return;
}
if (remoteState.mode && remoteState.mode !== "unknown") {
this.remote_form.mode = remoteState.mode;
}
if (remoteState.current_limit === null || remoteState.current_limit === undefined) {
this.remote_form.current_limit = "";
} else {
this.remote_form.current_limit = String(remoteState.current_limit);
}
if (remoteState.standby === null || remoteState.standby === undefined) {
this.remote_form.standby = false;
} else {
this.remote_form.standby = !!remoteState.standby;
}
},
remoteModeLabel: function(remoteState) {
var mode = (remoteState && remoteState.mode) || "unknown";
if (mode === "charger_only") {
return "Charger Only";
}
if (mode === "inverter_only") {
return "Inverter Only";
}
if (mode === "on") {
return "On";
}
if (mode === "off") {
return "Off";
}
return "Unknown";
},
remoteStandbyLabel: function(remoteState) {
if (!remoteState || remoteState.standby === null || remoteState.standby === undefined) {
return "Unknown";
}
return remoteState.standby ? "Enabled" : "Disabled";
},
refreshRemoteState: function() {
var self = this;
fetch(getAPIURI("api/remote-panel/state"))
.then(function(resp) {
if (!resp.ok) {
throw new Error("Could not load remote panel state.");
}
return resp.json();
})
.then(function(payload) {
self.state.remote_panel = payload;
self.syncRemoteFormFromState(payload);
})
.catch(function(err) {
self.control.has_error = true;
self.control.message = err.message;
});
},
applyRemotePanelState: function() {
var self = this;
if (!self.state.remote_panel.writable) {
return;
}
var body = {
mode: self.remote_form.mode
};
if (self.remote_form.current_limit !== "") {
var parsed = parseFloat(self.remote_form.current_limit);
if (isNaN(parsed)) {
self.control.has_error = true;
self.control.message = "Current limit must be numeric.";
return;
}
body.current_limit = parsed;
}
self.control.busy = true;
self.control.has_error = false;
self.control.message = "";
fetch(getAPIURI("api/remote-panel/state"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(body)
})
.then(function(resp) {
if (!resp.ok) {
return resp.text().then(function(text) {
throw new Error(text || "Failed to set remote panel mode/current limit.");
});
}
return resp.json();
})
.then(function(payload) {
self.state.remote_panel = payload;
self.syncRemoteFormFromState(payload);
self.control.has_error = false;
self.control.message = "Remote panel state updated.";
})
.catch(function(err) {
self.control.has_error = true;
self.control.message = err.message;
})
.finally(function() {
self.control.busy = false;
});
},
applyStandby: function() {
var self = this;
if (!self.state.remote_panel.writable) {
return;
}
self.control.busy = true;
self.control.has_error = false;
self.control.message = "";
fetch(getAPIURI("api/remote-panel/standby"), {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
standby: !!self.remote_form.standby
})
})
.then(function(resp) {
if (!resp.ok) {
return resp.text().then(function(text) {
throw new Error(text || "Failed to set standby mode.");
});
}
return resp.json();
})
.then(function(payload) {
self.state.remote_panel = payload;
self.syncRemoteFormFromState(payload);
self.control.has_error = false;
self.control.message = "Standby mode updated.";
})
.catch(function(err) {
self.control.has_error = true;
self.control.message = err.message;
})
.finally(function() {
self.control.busy = false;
});
}
}
});
app.refreshRemoteState();
connect();
}
@@ -61,7 +240,7 @@ function connect() {
}
};
conn.onopen = function(evt) {
conn.onopen = function() {
timeout = timeoutMin;
app.error.has_error = false;
};
@@ -69,6 +248,9 @@ function connect() {
conn.onmessage = function(evt) {
var update = JSON.parse(evt.data);
app.state = update;
if (!app.control.busy) {
app.syncRemoteFormFromState(update.remote_panel);
}
};
} else {
app.error.has_error = true;
@@ -88,3 +270,11 @@ function getURI() {
new_uri += loc.pathname + "ws";
return new_uri;
}
function getAPIURI(path) {
var base = window.location.pathname;
if (base.slice(-1) !== "/") {
base += "/";
}
return base + path.replace(/^\/+/, "");
}

View File

@@ -31,13 +31,15 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package webui
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/diebietse/invertergui/mk2driver"
"github.com/diebietse/invertergui/websocket"
"invertergui/mk2driver"
"invertergui/websocket"
"github.com/sirupsen/logrus"
)
@@ -51,25 +53,53 @@ const (
BlinkGreen = "blink-green"
)
const (
modeChargerOnly = "charger_only"
modeInverterOnly = "inverter_only"
modeOn = "on"
modeOff = "off"
modeUnknown = "unknown"
)
type WebGui struct {
mk2driver.Mk2
writer mk2driver.SettingsWriter
stopChan chan struct{}
wg sync.WaitGroup
hub *websocket.Hub
stateMu sync.RWMutex
latest *templateInput
remote remotePanelState
}
func NewWebGui(source mk2driver.Mk2) *WebGui {
func NewWebGui(source mk2driver.Mk2, writer mk2driver.SettingsWriter) *WebGui {
w := &WebGui{
stopChan: make(chan struct{}),
Mk2: source,
writer: writer,
hub: websocket.NewHub(),
remote: remotePanelState{
Writable: writer != nil,
Mode: modeUnknown,
},
}
w.wg.Add(1)
go w.dataPoll()
return w
}
type remotePanelState struct {
Writable bool `json:"writable"`
Mode string `json:"mode"`
CurrentLimit *float64 `json:"current_limit,omitempty"`
Standby *bool `json:"standby,omitempty"`
LastCommand string `json:"last_command,omitempty"`
LastError string `json:"last_error,omitempty"`
LastUpdated string `json:"last_updated,omitempty"`
}
type templateInput struct {
Error []error `json:"errors"`
@@ -94,12 +124,125 @@ type templateInput struct {
OutFreq string `json:"output_frequency"`
LedMap map[string]string `json:"led_map"`
RemotePanel remotePanelState `json:"remote_panel"`
}
type setRemotePanelStateRequest struct {
Mode string `json:"mode"`
CurrentLimit *float64 `json:"current_limit"`
}
type setRemotePanelStandbyRequest struct {
Standby bool `json:"standby"`
}
func (w *WebGui) ServeHub(rw http.ResponseWriter, r *http.Request) {
w.hub.ServeHTTP(rw, r)
}
func (w *WebGui) ServeRemotePanelState(rw http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost:
w.handleSetRemotePanelState(rw, r)
default:
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (w *WebGui) ServeRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
case http.MethodPost:
w.handleSetRemotePanelStandby(rw, r)
default:
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil {
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return
}
req := setRemotePanelStateRequest{}
if err := decodeJSONBody(r, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
switchState, normalizedMode, err := parsePanelMode(req.Mode)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if err := w.writer.SetPanelState(switchState, req.CurrentLimit); err != nil {
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_state"
state.LastError = err.Error()
})
http.Error(rw, err.Error(), http.StatusBadGateway)
return
}
w.updateRemotePanelState(func(state *remotePanelState) {
state.Mode = normalizedMode
state.CurrentLimit = copyFloat64Ptr(req.CurrentLimit)
state.LastCommand = "set_remote_panel_state"
state.LastError = ""
})
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
}
func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Request) {
if w.writer == nil {
http.Error(rw, "remote control is not supported by this data source", http.StatusNotImplemented)
return
}
req := setRemotePanelStandbyRequest{}
if err := decodeJSONBody(r, &req); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
if err := w.writer.SetStandby(req.Standby); err != nil {
w.updateRemotePanelState(func(state *remotePanelState) {
state.LastCommand = "set_remote_panel_standby"
state.LastError = err.Error()
})
http.Error(rw, err.Error(), http.StatusBadGateway)
return
}
w.updateRemotePanelState(func(state *remotePanelState) {
state.Standby = copyBoolPtr(&req.Standby)
state.LastCommand = "set_remote_panel_standby"
state.LastError = ""
})
writeJSON(rw, http.StatusOK, w.getRemotePanelState())
}
func parsePanelMode(raw string) (mk2driver.PanelSwitchState, string, error) {
switch strings.TrimSpace(strings.ToLower(raw)) {
case modeChargerOnly:
return mk2driver.PanelSwitchChargerOnly, modeChargerOnly, nil
case modeInverterOnly:
return mk2driver.PanelSwitchInverterOnly, modeInverterOnly, nil
case modeOn:
return mk2driver.PanelSwitchOn, modeOn, nil
case modeOff:
return mk2driver.PanelSwitchOff, modeOff, nil
default:
return 0, "", fmt.Errorf("unsupported panel mode %q", raw)
}
}
func ledName(led mk2driver.Led) string {
name, ok := mk2driver.LedNames[led]
if !ok {
@@ -162,15 +305,17 @@ func (w *WebGui) Stop() {
w.wg.Wait()
}
// dataPoll waits for data from the w.poller channel. It will send its currently stored status
// to respChan if anything reads from it.
func (w *WebGui) dataPoll() {
for {
select {
case s := <-w.C():
if s.Valid {
err := w.hub.Broadcast(buildTemplateInput(s))
if err != nil {
payload := buildTemplateInput(s)
w.stateMu.Lock()
payload.RemotePanel = w.remote
w.latest = payload
w.stateMu.Unlock()
if err := w.hub.Broadcast(payload); err != nil {
log.Errorf("Could not send update to clients: %v", err)
}
}
@@ -180,3 +325,93 @@ func (w *WebGui) dataPoll() {
}
}
}
func (w *WebGui) getRemotePanelState() remotePanelState {
w.stateMu.RLock()
defer w.stateMu.RUnlock()
return copyRemotePanelState(w.remote)
}
func (w *WebGui) updateRemotePanelState(update func(state *remotePanelState)) {
w.stateMu.Lock()
update(&w.remote)
w.remote.LastUpdated = time.Now().UTC().Format(time.RFC3339)
snapshot := w.snapshotLocked()
w.stateMu.Unlock()
if snapshot != nil {
if err := w.hub.Broadcast(snapshot); err != nil {
log.Errorf("Could not send control update to clients: %v", err)
}
}
}
func (w *WebGui) snapshotLocked() *templateInput {
if w.latest == nil {
return nil
}
snapshot := cloneTemplateInput(w.latest)
snapshot.RemotePanel = copyRemotePanelState(w.remote)
return snapshot
}
func cloneTemplateInput(in *templateInput) *templateInput {
if in == nil {
return nil
}
out := *in
if in.Error != nil {
out.Error = append([]error(nil), in.Error...)
}
if in.LedMap != nil {
out.LedMap = make(map[string]string, len(in.LedMap))
for k, v := range in.LedMap {
out.LedMap[k] = v
}
}
out.RemotePanel = copyRemotePanelState(in.RemotePanel)
return &out
}
func copyRemotePanelState(in remotePanelState) remotePanelState {
in.CurrentLimit = copyFloat64Ptr(in.CurrentLimit)
in.Standby = copyBoolPtr(in.Standby)
return in
}
func copyFloat64Ptr(in *float64) *float64 {
if in == nil {
return nil
}
value := *in
return &value
}
func copyBoolPtr(in *bool) *bool {
if in == nil {
return nil
}
value := *in
return &value
}
func decodeJSONBody(r *http.Request, destination any) error {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
func writeJSON(rw http.ResponseWriter, statusCode int, payload any) {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(statusCode)
if err := json.NewEncoder(rw).Encode(payload); err != nil {
log.Errorf("Could not encode webui API response: %v", err)
}
}

View File

@@ -36,7 +36,7 @@ import (
"testing"
"time"
"github.com/diebietse/invertergui/mk2driver"
"invertergui/mk2driver"
)
type templateTest struct {
@@ -91,3 +91,53 @@ func TestTemplateInput(t *testing.T) {
}
}
}
func TestParsePanelMode(t *testing.T) {
tests := []struct {
name string
input string
want mk2driver.PanelSwitchState
wantRaw string
wantErr bool
}{
{
name: "on",
input: "on",
want: mk2driver.PanelSwitchOn,
wantRaw: "on",
},
{
name: "charger_only",
input: "charger_only",
want: mk2driver.PanelSwitchChargerOnly,
wantRaw: "charger_only",
},
{
name: "invalid",
input: "banana",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotRaw, err := parsePanelMode(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got switch %d, want %d", got, tt.want)
}
if gotRaw != tt.wantRaw {
t.Fatalf("got mode %q, want %q", gotRaw, tt.wantRaw)
}
})
}
}