Modernize invertergui: MQTT write support, HA integration, UI updates
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
Some checks failed
build / inverter_gui_pipeline (push) Has been cancelled
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"invertergui/mk2driver"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
316
plugins/mqttclient/mqtt_test.go
Normal file
316
plugins/mqttclient/mqtt_test.go
Normal 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(" "))
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"invertergui/mk2driver"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/diebietse/invertergui/mk2driver"
|
||||
"invertergui/mk2driver"
|
||||
)
|
||||
|
||||
func TestServer(_ *testing.T) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(/^\/+/, "");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user