Files
Nathan Coad e8153e2953
All checks were successful
continuous-integration/drone/push Build is passing
implement some features of Venus OS
2026-02-19 15:37:41 +11:00

2481 lines
74 KiB
Go

package mqttclient
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.coadcorp.com/nathan/invertergui/mk2driver"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/sirupsen/logrus"
)
var log = logrus.WithField("ctx", "inverter-gui-mqtt")
const keepAlive = 5 * time.Second
const (
commandKindSetting = "setting"
commandKindRAMVar = "ram_var"
commandKindPanel = "panel_state"
commandKindStandby = "standby"
commandKindESSMode = "ess_mode"
commandKindESSSet = "ess_setpoint"
commandKindESSMaxC = "ess_max_charge_power"
commandKindESSMaxD = "ess_max_discharge_power"
writeStatusOK = "ok"
writeStatusError = "error"
)
type HomeAssistantConfig struct {
Enabled bool
DiscoveryPrefix string
NodeID string
DeviceName string
}
type VenusConfig struct {
Enabled bool
PortalID string
Service string
SubscribeWrites bool
TopicPrefix string
GuideCompat bool
}
// Config sets MQTT client configuration
type Config struct {
Broker string
ClientID string
Topic string
CommandTopic string
StatusTopic string
DeviceID string
HistorySize int
InstanceID int
Phase string
PhaseGroup string
HomeAssistant HomeAssistantConfig
Venus VenusConfig
Username string
Password string
}
type writeCommand struct {
Source mk2driver.CommandSource
RequestID string
Kind string
ID uint16
Value int16
FloatValue *float64
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"`
FloatValue *float64 `json:"float_value,omitempty"`
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 telemetryCache struct {
mu sync.Mutex
last telemetrySnapshot
}
type essControlSnapshot struct {
HasSetpoint bool
SetpointW float64
HasMaxCharge bool
MaxChargeW float64
HasMaxDischarge bool
MaxDischargeW float64
HasBatteryLife bool
BatteryLifeState int
}
type essControlCache struct {
mu sync.Mutex
hasSetpoint bool
setpointW float64
hasMaxCharge bool
maxChargeW float64
hasMaxDischarge bool
maxDischargeW float64
hasBatteryLife bool
batteryLifeState int
}
type haDiscoveryDefinition struct {
Component string
ObjectID string
Config map[string]any
}
type panelStateCache struct {
mu sync.Mutex
hasSwitch bool
switchName string
switchState mk2driver.PanelSwitchState
hasCurrent bool
currentA float64
hasStandby bool
standby bool
}
type panelStateSnapshot struct {
Switch string `json:"switch,omitempty"`
CurrentLimit float64 `json:"current_limit,omitempty"`
Standby bool `json:"standby"`
HasSwitch bool `json:"has_switch"`
HasCurrent bool `json:"has_current_limit"`
HasStandby bool `json:"has_standby"`
}
type telemetrySnapshot struct {
Timestamp time.Time `json:"timestamp"`
Valid bool `json:"valid"`
InputVoltage float64 `json:"input_voltage"`
InputCurrent float64 `json:"input_current"`
InputFrequency float64 `json:"input_frequency"`
OutputVoltage float64 `json:"output_voltage"`
OutputCurrent float64 `json:"output_current"`
OutputFrequency float64 `json:"output_frequency"`
BatteryVoltage float64 `json:"battery_voltage"`
BatteryCurrent float64 `json:"battery_current"`
BatteryCharge float64 `json:"battery_charge"`
InputPower float64 `json:"input_power"`
OutputPower float64 `json:"output_power"`
BatteryPower float64 `json:"battery_power"`
LEDs map[string]string `json:"leds"`
}
type historySample struct {
Timestamp time.Time
InputPowerVA float64
OutputPowerVA float64
BatteryPowerW float64
BatteryVoltage float64
Valid bool
State operatingState
}
type historySummary struct {
Samples int `json:"samples"`
WindowSeconds float64 `json:"window_seconds"`
AverageInputPower float64 `json:"average_input_power"`
AverageOutputPower float64 `json:"average_output_power"`
AverageBatteryPower float64 `json:"average_battery_power"`
MaxOutputPower float64 `json:"max_output_power"`
MinBatteryVoltage float64 `json:"min_battery_voltage"`
EnergyInWh float64 `json:"energy_in_wh"`
EnergyOutWh float64 `json:"energy_out_wh"`
BatteryChargeWh float64 `json:"battery_charge_wh"`
BatteryDischargeWh float64 `json:"battery_discharge_wh"`
UptimeSeconds float64 `json:"uptime_seconds"`
FaultCount uint64 `json:"fault_count"`
LastFaultAt string `json:"last_fault_at,omitempty"`
CurrentState string `json:"current_state"`
}
type orchestrationState struct {
DeviceID string `json:"device_id"`
InstanceID int `json:"instance_id"`
Phase string `json:"phase"`
PhaseGroup string `json:"phase_group"`
Timestamp time.Time `json:"timestamp"`
OperatingState string `json:"operating_state"`
Telemetry telemetrySnapshot `json:"telemetry"`
PanelState panelStateSnapshot `json:"panel_state"`
History historySummary `json:"history"`
Alarms []alarmState `json:"alarms"`
}
type alarmState struct {
Code string `json:"code"`
Level string `json:"level"`
Message string `json:"message"`
ActiveSince time.Time `json:"active_since"`
}
type alarmTracker struct {
mu sync.Mutex
active map[string]alarmState
rules map[string]*alarmRule
debounceOn time.Duration
debounceOff time.Duration
faultCount uint64
lastFault time.Time
}
type historyTracker struct {
mu sync.Mutex
max int
samples []historySample
energyInWh float64
energyOutWh float64
batteryChargeWh float64
batteryDischargeWh float64
uptimeSeconds float64
lastSample *historySample
lastState operatingState
lastFaultCount uint64
lastFaultAt time.Time
}
type alarmRule struct {
code string
level string
message string
pendingFrom time.Time
clearFrom time.Time
activeSince time.Time
active bool
}
type diagnosticsBundle struct {
DeviceID string `json:"device_id"`
InstanceID int `json:"instance_id"`
Phase string `json:"phase"`
PhaseGroup string `json:"phase_group"`
GeneratedAt time.Time `json:"generated_at"`
HealthScore int `json:"health_score"`
Driver *mk2driver.DriverDiagnostics `json:"driver,omitempty"`
Commands []mk2driver.CommandEvent `json:"commands,omitempty"`
}
type operatingState string
const (
operatingStateOff operatingState = "Off"
operatingStateInverter operatingState = "Inverter"
operatingStateCharger operatingState = "Charger"
operatingStatePassthru operatingState = "Passthru"
operatingStateFault operatingState = "Fault"
)
// 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 {
if strings.TrimSpace(config.DeviceID) == "" {
config.DeviceID = haNodeID(config)
}
if config.HistorySize <= 0 {
config.HistorySize = 120
}
if strings.TrimSpace(config.Venus.PortalID) == "" {
config.Venus.PortalID = config.DeviceID
}
if strings.TrimSpace(config.Venus.Service) == "" {
config.Venus.Service = "vebus/257"
}
if strings.TrimSpace(config.Venus.TopicPrefix) != "" {
config.Venus.TopicPrefix = strings.Trim(strings.TrimSpace(config.Venus.TopicPrefix), "/")
}
if strings.TrimSpace(config.Phase) == "" {
config.Phase = "L1"
}
if strings.TrimSpace(config.PhaseGroup) == "" {
config.PhaseGroup = "default"
}
if config.InstanceID < 0 {
config.InstanceID = 0
}
log.WithFields(logrus.Fields{
"broker": config.Broker,
"client_id": config.ClientID,
"topic": config.Topic,
"command_topic": config.CommandTopic,
"status_topic": config.StatusTopic,
"device_id": config.DeviceID,
"history_size": config.HistorySize,
"ha_enabled": config.HomeAssistant.Enabled,
"ha_node_id": config.HomeAssistant.NodeID,
"ha_device_name": config.HomeAssistant.DeviceName,
"venus_enabled": config.Venus.Enabled,
"venus_portal": config.Venus.PortalID,
"venus_service": config.Venus.Service,
"venus_prefix": config.Venus.TopicPrefix,
"venus_guide": config.Venus.GuideCompat,
"instance_id": config.InstanceID,
"phase": config.Phase,
"phase_group": config.PhaseGroup,
}).Info("Initializing MQTT client")
c := mqtt.NewClient(getOpts(config))
if token := c.Connect(); token.Wait() && token.Error() != nil {
return token.Error()
}
cache := &panelStateCache{}
alarms := newAlarmTracker()
history := newHistoryTracker(config.HistorySize)
telemetry := &telemetryCache{}
ess := newESSControlCache()
diagControl, hasDiag := mk2.(mk2driver.DiagnosticsControl)
commandHistory, hasCommandHistory := writer.(mk2driver.CommandHistoryProvider)
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 err := subscribeHACurrentLimitState(c, config, cache); err != nil {
log.Warnf("Could not subscribe to Home Assistant current limit state topic: %v", err)
}
if err := subscribeHAStandbyState(c, config, cache); err != nil {
log.Warnf("Could not subscribe to Home Assistant standby 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, alarms, ess, telemetry))
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)
}
}
if config.Venus.Enabled {
if err := publishVenusServiceInfo(c, config); err != nil {
log.WithError(err).Warn("Could not publish initial Venus service metadata")
}
if writer != nil && config.Venus.SubscribeWrites {
if err := subscribeVenusWriteTopics(c, writer, config, cache, alarms, ess, telemetry); err != nil {
log.WithError(err).Warn("Could not subscribe to Venus write topics")
}
}
}
go func() {
for e := range mk2.C() {
if e == nil {
log.Debug("Skipping nil MK2 event for MQTT publish")
continue
}
snapshot := buildTelemetrySnapshot(e)
telemetry.set(snapshot)
panel := cache.snapshot()
essSnapshot := ess.snapshot()
activeAlarms := alarms.Update(snapshot)
state := deriveOperatingState(snapshot, panel, activeAlarms)
faultCount, lastFaultAt := alarms.Stats()
var summary historySummary
if snapshot.Valid {
summary = history.Add(snapshot, state, faultCount, lastFaultAt)
} else {
summary = history.Summary(state, faultCount, lastFaultAt)
}
if err := publishOrchestrationTopics(c, config, snapshot, panel, summary, activeAlarms, state); err != nil {
log.WithError(err).Warn("Could not publish orchestration MQTT topics")
}
if config.Venus.Enabled {
if err := publishVenusTelemetry(c, config, snapshot, panel, activeAlarms, state, summary, essSnapshot); err != nil {
log.WithError(err).Warn("Could not publish Venus MQTT topics")
}
}
if hasDiag {
var commands []mk2driver.CommandEvent
if hasCommandHistory {
commands = commandHistory.History(25)
}
if err := publishDiagnostics(c, config, diagControl.DriverDiagnostics(100), commands); err != nil {
log.WithError(err).Warn("Could not publish diagnostics topic")
}
}
if !e.Valid {
log.Debug("Skipping invalid MK2 event for legacy MQTT update topic")
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)
} else {
log.WithField("topic", config.Topic).Debug("Published MK2 update to MQTT")
}
}
}()
log.Info("MQTT client setup complete")
return nil
}
func subscribeHAPanelModeState(client mqtt.Client, config Config, cache *panelStateCache) error {
if cache == nil {
return nil
}
stateTopic := haPanelSwitchStateTopic(config)
log.WithField("topic", stateTopic).Info("Subscribing to Home Assistant mode state topic for panel cache")
t := client.Subscribe(stateTopic, 1, func(_ mqtt.Client, msg mqtt.Message) {
switchState, switchName, err := normalizePanelSwitch(string(msg.Payload()))
if err != nil {
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"payload": string(msg.Payload()),
}).WithError(err).Warn("Ignoring invalid Home Assistant mode state payload")
return
}
cache.remember(writeCommand{
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: switchState,
SwitchName: switchName,
})
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"switch_name": switchName,
}).Debug("Updated panel mode cache from Home Assistant state topic")
})
t.Wait()
return t.Error()
}
func subscribeHACurrentLimitState(client mqtt.Client, config Config, cache *panelStateCache) error {
if cache == nil {
return nil
}
stateTopic := haCurrentLimitStateTopic(config)
log.WithField("topic", stateTopic).Info("Subscribing to Home Assistant current limit state topic for panel cache")
t := client.Subscribe(stateTopic, 1, func(_ mqtt.Client, msg mqtt.Message) {
trimmed := strings.TrimSpace(string(msg.Payload()))
if trimmed == "" {
return
}
limit, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"payload": string(msg.Payload()),
}).WithError(err).Warn("Ignoring invalid Home Assistant current limit payload")
return
}
cache.setCurrentLimit(limit)
})
t.Wait()
return t.Error()
}
func subscribeHAStandbyState(client mqtt.Client, config Config, cache *panelStateCache) error {
if cache == nil {
return nil
}
stateTopic := haStandbyStateTopic(config)
log.WithField("topic", stateTopic).Info("Subscribing to Home Assistant standby state topic for panel cache")
t := client.Subscribe(stateTopic, 1, func(_ mqtt.Client, msg mqtt.Message) {
standby, err := parseStandbyText(string(msg.Payload()))
if err != nil {
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"payload": string(msg.Payload()),
}).WithError(err).Warn("Ignoring invalid Home Assistant standby payload")
return
}
cache.setStandby(standby)
})
t.Wait()
return t.Error()
}
func commandHandler(client mqtt.Client, writer mk2driver.SettingsWriter, config Config, cache *panelStateCache, alarms *alarmTracker, ess *essControlCache, telemetry *telemetryCache) mqtt.MessageHandler {
if cache == nil {
cache = &panelStateCache{}
}
return func(_ mqtt.Client, msg mqtt.Message) {
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"payload": string(msg.Payload()),
}).Debug("Received MQTT command 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
}
status := applyWriteCommand(client, writer, config, cache, alarms, ess, telemetry, cmd)
publishWriteStatus(client, config.StatusTopic, status)
}
}
func applyWriteCommand(client mqtt.Client, writer mk2driver.SettingsWriter, config Config, cache *panelStateCache, alarms *alarmTracker, ess *essControlCache, telemetry *telemetryCache, cmd writeCommand) writeStatus {
if ess == nil {
ess = newESSControlCache()
}
if telemetry == nil {
telemetry = &telemetryCache{}
}
execCmd := cmd
status := writeStatus{
RequestID: cmd.RequestID,
Status: writeStatusOK,
Kind: cmd.Kind,
Timestamp: time.Now().UTC(),
}
switch cmd.Kind {
case commandKindPanel:
resolved, 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)
return status
}
execCmd = resolved
status.Switch = execCmd.SwitchName
status.CurrentLimitA = execCmd.CurrentLimitA
case commandKindStandby:
status.Standby = copyBoolPtr(execCmd.Standby)
case commandKindESSSet, commandKindESSMode, commandKindESSMaxC, commandKindESSMaxD:
if cmd.FloatValue != nil {
status.FloatValue = float64Ptr(*cmd.FloatValue)
}
mapped, err := resolveESSWriteCommand(cmd, ess, telemetry)
if err != nil {
status.Status = writeStatusError
status.Error = err.Error()
log.Errorf("Invalid ESS compatibility command %s: %v", formatWriteCommandLog(cmd), err)
return status
}
if mapped != nil {
execCmd = *mapped
status.Kind = execCmd.Kind
status.Switch = execCmd.SwitchName
status.CurrentLimitA = execCmd.CurrentLimitA
} else {
if config.Venus.Enabled {
if err := publishVenusESSState(client, config, ess.snapshot()); err != nil {
log.WithError(err).Warn("Could not publish ESS compatibility state")
}
}
return status
}
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)
if alarms != nil {
active := alarms.RecordCommandFailure(err, time.Now().UTC())
if publishErr := publishActiveAlarms(client, config, active); publishErr != nil {
log.WithError(publishErr).Warn("Could not publish active alarms after command failure")
}
}
return status
}
if alarms != nil {
alarms.RecordCommandSuccess(time.Now().UTC())
}
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)
}
}
if config.Venus.Enabled {
if err := publishVenusControlState(client, config, execCmd, cache.snapshot()); err != nil {
log.Errorf("Could not publish Venus control state update: %v", err)
}
if err := publishVenusESSState(client, config, ess.snapshot()); err != nil {
log.WithError(err).Warn("Could not publish ESS compatibility state")
}
}
return 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
log.WithField("switch", cmd.SwitchName).Debug("Resolved panel command switch from cache")
return cmd, nil
}
func (c *panelStateCache) remember(cmd writeCommand) {
c.mu.Lock()
defer c.mu.Unlock()
switch cmd.Kind {
case commandKindPanel:
if cmd.HasSwitch {
c.hasSwitch = true
c.switchName = cmd.SwitchName
c.switchState = cmd.SwitchState
}
if cmd.CurrentLimitA != nil {
c.hasCurrent = true
c.currentA = *cmd.CurrentLimitA
}
case commandKindStandby:
if cmd.Standby != nil {
c.hasStandby = true
c.standby = *cmd.Standby
}
}
}
func (c *panelStateCache) setCurrentLimit(limit float64) {
c.mu.Lock()
c.hasCurrent = true
c.currentA = limit
c.mu.Unlock()
}
func (c *panelStateCache) setStandby(standby bool) {
c.mu.Lock()
c.hasStandby = true
c.standby = standby
c.mu.Unlock()
}
func (c *panelStateCache) snapshot() panelStateSnapshot {
c.mu.Lock()
defer c.mu.Unlock()
return panelStateSnapshot{
Switch: c.switchName,
CurrentLimit: c.currentA,
Standby: c.standby,
HasSwitch: c.hasSwitch,
HasCurrent: c.hasCurrent,
HasStandby: c.hasStandby,
}
}
func newESSControlCache() *essControlCache {
return &essControlCache{
hasBatteryLife: true,
batteryLifeState: 10,
}
}
func (e *essControlCache) snapshot() essControlSnapshot {
e.mu.Lock()
defer e.mu.Unlock()
return essControlSnapshot{
HasSetpoint: e.hasSetpoint,
SetpointW: e.setpointW,
HasMaxCharge: e.hasMaxCharge,
MaxChargeW: e.maxChargeW,
HasMaxDischarge: e.hasMaxDischarge,
MaxDischargeW: e.maxDischargeW,
HasBatteryLife: e.hasBatteryLife,
BatteryLifeState: e.batteryLifeState,
}
}
func (e *essControlCache) setSetpoint(value float64) {
e.mu.Lock()
e.hasSetpoint = true
e.setpointW = value
e.mu.Unlock()
}
func (e *essControlCache) setMaxCharge(value float64) {
e.mu.Lock()
e.hasMaxCharge = true
e.maxChargeW = value
e.mu.Unlock()
}
func (e *essControlCache) setMaxDischarge(value float64) {
e.mu.Lock()
e.hasMaxDischarge = true
e.maxDischargeW = value
e.mu.Unlock()
}
func (e *essControlCache) setBatteryLifeState(value int) {
e.mu.Lock()
e.hasBatteryLife = true
e.batteryLifeState = value
e.mu.Unlock()
}
func (t *telemetryCache) set(snapshot telemetrySnapshot) {
t.mu.Lock()
t.last = snapshot
t.mu.Unlock()
}
func (t *telemetryCache) snapshot() telemetrySnapshot {
t.mu.Lock()
defer t.mu.Unlock()
return t.last
}
func resolveESSWriteCommand(cmd writeCommand, ess *essControlCache, telemetry *telemetryCache) (*writeCommand, error) {
if ess == nil {
ess = newESSControlCache()
}
switch cmd.Kind {
case commandKindESSMaxC:
if cmd.FloatValue == nil {
return nil, errors.New("missing ess max charge power value")
}
if *cmd.FloatValue < 0 {
return nil, fmt.Errorf("ess max charge power must be >= 0, got %.3f", *cmd.FloatValue)
}
ess.setMaxCharge(*cmd.FloatValue)
return nil, nil
case commandKindESSMaxD:
if cmd.FloatValue == nil {
return nil, errors.New("missing ess max discharge power value")
}
if *cmd.FloatValue < 0 {
return nil, fmt.Errorf("ess max discharge power must be >= 0, got %.3f", *cmd.FloatValue)
}
ess.setMaxDischarge(*cmd.FloatValue)
return nil, nil
case commandKindESSMode:
mode := int(cmd.Value)
if mode == 0 && cmd.FloatValue != nil {
mode = int(*cmd.FloatValue)
}
if mode != 9 && mode != 10 {
return nil, fmt.Errorf("unsupported ess mode %d; expected 9 or 10", mode)
}
ess.setBatteryLifeState(mode)
switchState := mk2driver.PanelSwitchOn
switchName := "on"
if mode == 9 {
switchState = mk2driver.PanelSwitchChargerOnly
switchName = "charger_only"
}
mapped := writeCommand{
Source: cmd.Source,
RequestID: cmd.RequestID,
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: switchState,
SwitchName: switchName,
}
return &mapped, nil
case commandKindESSSet:
if cmd.FloatValue == nil {
return nil, errors.New("missing ess setpoint value")
}
setpoint := *cmd.FloatValue
essSnapshot := ess.snapshot()
if setpoint > 0 && essSnapshot.HasMaxCharge && setpoint > essSnapshot.MaxChargeW {
setpoint = essSnapshot.MaxChargeW
}
if setpoint < 0 && essSnapshot.HasMaxDischarge && -setpoint > essSnapshot.MaxDischargeW {
setpoint = -essSnapshot.MaxDischargeW
}
ess.setSetpoint(setpoint)
if setpoint > 1 {
voltage := 230.0
if telemetry != nil {
last := telemetry.snapshot()
if last.InputVoltage > 1 {
voltage = last.InputVoltage
}
}
currentLimit := setpoint / voltage
mapped := writeCommand{
Source: cmd.Source,
RequestID: cmd.RequestID,
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: mk2driver.PanelSwitchChargerOnly,
SwitchName: "charger_only",
CurrentLimitA: &currentLimit,
}
return &mapped, nil
}
if setpoint < -1 {
mapped := writeCommand{
Source: cmd.Source,
RequestID: cmd.RequestID,
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: mk2driver.PanelSwitchInverterOnly,
SwitchName: "inverter_only",
}
return &mapped, nil
}
mapped := writeCommand{
Source: cmd.Source,
RequestID: cmd.RequestID,
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: mk2driver.PanelSwitchOn,
SwitchName: "on",
}
return &mapped, nil
default:
return &cmd, nil
}
}
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{
Source: mk2driver.CommandSourceMQTT,
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{
Source: mk2driver.CommandSourceMQTT,
RequestID: msg.RequestID,
Kind: normalizedKind,
Standby: &standby,
}, nil
}
if normalizedKind == commandKindESSSet || normalizedKind == commandKindESSMaxC || normalizedKind == commandKindESSMaxD {
value, err := decodeFloatValue(msg.Value)
if err != nil {
return writeCommand{}, err
}
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
RequestID: msg.RequestID,
Kind: normalizedKind,
FloatValue: &value,
}, nil
}
if normalizedKind == commandKindESSMode {
modeValue, err := decodeIntValue(msg.Value)
if err != nil {
return writeCommand{}, err
}
modeFloat := float64(modeValue)
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
RequestID: msg.RequestID,
Kind: normalizedKind,
Value: int16(modeValue),
FloatValue: &modeFloat,
}, 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{
Source: mk2driver.CommandSourceMQTT,
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
case "ess_mode", "ess_battery_life_state", "battery_life_state":
return commandKindESSMode, nil
case "ess_setpoint", "ac_power_setpoint", "grid_setpoint":
return commandKindESSSet, nil
case "ess_max_charge_power", "max_charge_power":
return commandKindESSMaxC, nil
case "ess_max_discharge_power", "max_discharge_power":
return commandKindESSMaxD, 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")
}
source := cmd.Source
if source == "" {
source = mk2driver.CommandSourceMQTT
}
if sourceAware, ok := writer.(mk2driver.SourceAwareSettingsWriter); ok {
switch cmd.Kind {
case commandKindPanel:
if !cmd.HasSwitch {
return errors.New("panel_state command requires a switch state")
}
return sourceAware.SetPanelStateWithSource(source, cmd.SwitchState, cmd.CurrentLimitA)
case commandKindStandby:
if cmd.Standby == nil {
return errors.New("standby command missing standby value")
}
return sourceAware.SetStandbyWithSource(source, *cmd.Standby)
case commandKindRAMVar:
return sourceAware.WriteRAMVarWithSource(source, cmd.ID, cmd.Value)
case commandKindSetting:
return sourceAware.WriteSettingWithSource(source, cmd.ID, cmd.Value)
default:
return fmt.Errorf("unsupported write command kind %q", cmd.Kind)
}
}
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)
case commandKindESSSet, commandKindESSMaxC, commandKindESSMaxD:
if cmd.FloatValue == nil {
return fmt.Sprintf("kind=%s value=<unset>", cmd.Kind)
}
return fmt.Sprintf("kind=%s value=%.3f", cmd.Kind, *cmd.FloatValue)
case commandKindESSMode:
return fmt.Sprintf("kind=%s value=%d", cmd.Kind, cmd.Value)
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 decodeIntValue(raw json.RawMessage) (int, error) {
if len(raw) == 0 {
return 0, errors.New(`missing required field "value"`)
}
var intValue int
if err := json.Unmarshal(raw, &intValue); err == nil {
return intValue, nil
}
var floatValue float64
if err := json.Unmarshal(raw, &floatValue); err == nil {
return int(floatValue), nil
}
var stringValue string
if err := json.Unmarshal(raw, &stringValue); err == nil {
parsed, parseErr := strconv.Atoi(strings.TrimSpace(stringValue))
if parseErr != nil {
return 0, fmt.Errorf(`field "value" must be integer-like: %w`, parseErr)
}
return parsed, nil
}
return 0, errors.New(`field "value" must be integer-like`)
}
func decodeFloatValue(raw json.RawMessage) (float64, error) {
if len(raw) == 0 {
return 0, errors.New(`missing required field "value"`)
}
var floatValue float64
if err := json.Unmarshal(raw, &floatValue); err == nil {
return floatValue, nil
}
var intValue int
if err := json.Unmarshal(raw, &intValue); err == nil {
return float64(intValue), nil
}
var stringValue string
if err := json.Unmarshal(raw, &stringValue); err == nil {
parsed, parseErr := strconv.ParseFloat(strings.TrimSpace(stringValue), 64)
if parseErr != nil {
return 0, fmt.Errorf(`field "value" must be numeric: %w`, parseErr)
}
return parsed, nil
}
return 0, errors.New(`field "value" must be numeric`)
}
func parseStandbyText(raw string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "on", "enable", "enabled":
return true, nil
case "0", "false", "off", "disable", "disabled":
return false, nil
default:
return false, fmt.Errorf("field \"standby\" must be true/false, got %q", raw)
}
}
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 {
return parseStandbyText(stringValue)
}
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 == "" {
log.Debug("Skipping write status publish; status topic is empty")
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)
}
}
type venusValueEnvelope struct {
Value any `json:"value"`
}
func subscribeVenusWriteTopics(client mqtt.Client, writer mk2driver.SettingsWriter, config Config, cache *panelStateCache, alarms *alarmTracker, ess *essControlCache, telemetry *telemetryCache) error {
if writer == nil {
return nil
}
subscribeTopics := []string{venusWriteTopicWildcard(config)}
if config.Venus.GuideCompat {
guideTopic := venusWriteTopicWildcardWithService(config, "settings/0")
if guideTopic != subscribeTopics[0] {
subscribeTopics = append(subscribeTopics, guideTopic)
}
}
handler := func(_ mqtt.Client, msg mqtt.Message) {
cmd, err := decodeVenusWriteCommand(config, msg.Topic(), msg.Payload())
if err != nil {
log.WithFields(logrus.Fields{
"topic": msg.Topic(),
"payload": string(msg.Payload()),
}).WithError(err).Warn("Ignoring unsupported Venus write request")
return
}
status := applyWriteCommand(client, writer, config, cache, alarms, ess, telemetry, cmd)
publishWriteStatus(client, config.StatusTopic, status)
}
for _, topic := range subscribeTopics {
log.WithField("topic", topic).Info("Subscribing to Venus write topic wildcard")
t := client.Subscribe(topic, 1, handler)
t.Wait()
if t.Error() != nil {
return t.Error()
}
}
return nil
}
func decodeVenusWriteCommand(config Config, topic string, payload []byte) (writeCommand, error) {
service, path, err := parseVenusWriteTopic(config, topic)
if err != nil {
return writeCommand{}, err
}
if path == "" {
return writeCommand{}, errors.New("missing Venus write path")
}
value, err := decodeVenusValue(payload)
if err != nil {
return writeCommand{}, err
}
switch path {
case "Mode":
switchState, switchName, err := decodePanelSwitchFromAny(value)
if err != nil {
return writeCommand{}, err
}
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindPanel,
HasSwitch: true,
SwitchState: switchState,
SwitchName: switchName,
}, nil
case "Ac/ActiveIn/CurrentLimit":
limit, err := toFloat64(value)
if err != nil {
return writeCommand{}, fmt.Errorf("invalid current limit payload: %w", err)
}
if limit < 0 {
return writeCommand{}, fmt.Errorf("current_limit must be >= 0, got %.3f", limit)
}
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindPanel,
CurrentLimitA: &limit,
}, nil
case "Settings/Standby", "RemotePanel/Standby":
standby, err := toBool(value)
if err != nil {
return writeCommand{}, fmt.Errorf("invalid standby payload: %w", err)
}
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindStandby,
Standby: &standby,
}, nil
case "Settings/CGwacs/AcPowerSetPoint":
setpoint, err := toFloat64(value)
if err != nil {
return writeCommand{}, fmt.Errorf("invalid ESS setpoint payload: %w", err)
}
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindESSSet,
FloatValue: &setpoint,
}, nil
case "Settings/CGwacs/MaxChargePower":
maxCharge, err := toFloat64(value)
if err != nil {
return writeCommand{}, fmt.Errorf("invalid ESS max charge power payload: %w", err)
}
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindESSMaxC,
FloatValue: &maxCharge,
}, nil
case "Settings/CGwacs/MaxDischargePower":
maxDischarge, err := toFloat64(value)
if err != nil {
return writeCommand{}, fmt.Errorf("invalid ESS max discharge power payload: %w", err)
}
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindESSMaxD,
FloatValue: &maxDischarge,
}, nil
case "Settings/CGwacs/BatteryLife/State":
modeValue, err := toInt(value)
if err != nil {
return writeCommand{}, fmt.Errorf("invalid ESS battery life state payload: %w", err)
}
modeFloat := float64(modeValue)
return writeCommand{
Source: mk2driver.CommandSourceMQTT,
Kind: commandKindESSMode,
Value: int16(modeValue),
FloatValue: &modeFloat,
}, nil
default:
return writeCommand{}, fmt.Errorf("unsupported Venus write path %q for service %q", path, service)
}
}
func parseVenusWriteTopic(config Config, topic string) (service string, path string, err error) {
withoutPrefix := stripVenusTopicPrefix(config, topic)
withoutPrefix = strings.Trim(withoutPrefix, "/")
parts := strings.Split(withoutPrefix, "/")
if len(parts) < 4 || parts[0] != "W" {
return "", "", fmt.Errorf("topic %q does not appear to be a Venus write topic", topic)
}
if parts[1] != venusPortalID(config) {
return "", "", fmt.Errorf("topic %q is for portal %q, expected %q", topic, parts[1], venusPortalID(config))
}
remainder := parts[2:]
primary := strings.Split(venusService(config), "/")
if len(remainder) >= len(primary) && strings.EqualFold(strings.Join(remainder[:len(primary)], "/"), strings.Join(primary, "/")) {
return strings.Join(primary, "/"), strings.Join(remainder[len(primary):], "/"), nil
}
guide := []string{"settings", "0"}
if config.Venus.GuideCompat && len(remainder) >= len(guide) && strings.EqualFold(strings.Join(remainder[:len(guide)], "/"), strings.Join(guide, "/")) {
return "settings/0", strings.Join(remainder[len(guide):], "/"), nil
}
return "", "", fmt.Errorf("topic %q does not match expected Venus services", topic)
}
func decodeVenusValue(payload []byte) (any, error) {
var envelope venusValueEnvelope
if err := json.Unmarshal(payload, &envelope); err == nil {
if envelope.Value != nil {
return envelope.Value, nil
}
}
var out any
if err := json.Unmarshal(payload, &out); err != nil {
return nil, fmt.Errorf("invalid Venus payload: %w", err)
}
return out, nil
}
func decodePanelSwitchFromAny(value any) (mk2driver.PanelSwitchState, string, error) {
switch typed := value.(type) {
case string:
return normalizePanelSwitch(typed)
case float64:
asInt := int(typed)
if typed != float64(asInt) {
return 0, "", fmt.Errorf("mode must be integer-like, got %.3f", typed)
}
return normalizePanelSwitch(strconv.Itoa(asInt))
default:
return 0, "", fmt.Errorf("unsupported mode payload type %T", value)
}
}
func toFloat64(value any) (float64, error) {
switch typed := value.(type) {
case float64:
return typed, nil
case string:
return strconv.ParseFloat(strings.TrimSpace(typed), 64)
case int:
return float64(typed), nil
case int64:
return float64(typed), nil
default:
return 0, fmt.Errorf("unsupported numeric payload type %T", value)
}
}
func toInt(value any) (int, error) {
switch typed := value.(type) {
case int:
return typed, nil
case int64:
return int(typed), nil
case float64:
return int(typed), nil
case string:
return strconv.Atoi(strings.TrimSpace(typed))
default:
return 0, fmt.Errorf("unsupported integer payload type %T", value)
}
}
func toBool(value any) (bool, error) {
switch typed := value.(type) {
case bool:
return typed, nil
case float64:
if typed == 0 {
return false, nil
}
if typed == 1 {
return true, nil
}
return false, fmt.Errorf("unsupported numeric boolean value %.3f", typed)
case string:
return parseStandbyText(typed)
default:
return false, fmt.Errorf("unsupported boolean payload type %T", value)
}
}
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)
log.WithFields(logrus.Fields{
"topic": topic,
"component": def.Component,
"object_id": def.ObjectID,
}).Debug("Publishing Home Assistant discovery definition")
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 buildTelemetrySnapshot(info *mk2driver.Mk2Info) telemetrySnapshot {
snapshot := telemetrySnapshot{
Timestamp: info.Timestamp.UTC(),
Valid: info.Valid,
InputVoltage: info.InVoltage,
InputCurrent: info.InCurrent,
InputFrequency: info.InFrequency,
OutputVoltage: info.OutVoltage,
OutputCurrent: info.OutCurrent,
OutputFrequency: info.OutFrequency,
BatteryVoltage: info.BatVoltage,
BatteryCurrent: info.BatCurrent,
BatteryCharge: info.ChargeState * 100,
InputPower: info.InVoltage * info.InCurrent,
OutputPower: info.OutVoltage * info.OutCurrent,
BatteryPower: info.BatVoltage * info.BatCurrent,
LEDs: map[string]string{},
}
if snapshot.Timestamp.IsZero() {
snapshot.Timestamp = time.Now().UTC()
}
for led, state := range info.LEDs {
name, ok := mk2driver.LedNames[led]
if !ok {
continue
}
if stateName, exists := mk2driver.StateNames[state]; exists {
snapshot.LEDs[name] = stateName
}
}
return snapshot
}
func newHistoryTracker(max int) *historyTracker {
if max <= 0 {
max = 120
}
return &historyTracker{
max: max,
}
}
func (h *historyTracker) Add(snapshot telemetrySnapshot, state operatingState, faultCount uint64, lastFaultAt *time.Time) historySummary {
h.mu.Lock()
defer h.mu.Unlock()
sample := historySample{
Timestamp: snapshot.Timestamp,
InputPowerVA: snapshot.InputPower,
OutputPowerVA: snapshot.OutputPower,
BatteryPowerW: snapshot.BatteryPower,
BatteryVoltage: snapshot.BatteryVoltage,
Valid: snapshot.Valid,
State: state,
}
if h.lastSample != nil && sample.Timestamp.After(h.lastSample.Timestamp) {
delta := sample.Timestamp.Sub(h.lastSample.Timestamp)
deltaHours := delta.Hours()
if sample.InputPowerVA > 0 {
h.energyInWh += sample.InputPowerVA * deltaHours
}
if sample.OutputPowerVA > 0 {
h.energyOutWh += sample.OutputPowerVA * deltaHours
}
if sample.BatteryPowerW > 0 {
h.batteryChargeWh += sample.BatteryPowerW * deltaHours
}
if sample.BatteryPowerW < 0 {
h.batteryDischargeWh += -sample.BatteryPowerW * deltaHours
}
if sample.Valid && sample.State != operatingStateOff {
h.uptimeSeconds += delta.Seconds()
}
}
h.samples = append(h.samples, sample)
if len(h.samples) > h.max {
h.samples = h.samples[len(h.samples)-h.max:]
}
sampleCopy := sample
h.lastSample = &sampleCopy
h.lastState = state
h.lastFaultCount = faultCount
if lastFaultAt != nil {
h.lastFaultAt = lastFaultAt.UTC()
}
return h.summaryLocked(state, faultCount, lastFaultAt)
}
func (h *historyTracker) Summary(state operatingState, faultCount uint64, lastFaultAt *time.Time) historySummary {
h.mu.Lock()
defer h.mu.Unlock()
return h.summaryLocked(state, faultCount, lastFaultAt)
}
func (h *historyTracker) summaryLocked(state operatingState, faultCount uint64, lastFaultAt *time.Time) historySummary {
summary := summarizeHistory(h.samples)
summary.EnergyInWh = h.energyInWh
summary.EnergyOutWh = h.energyOutWh
summary.BatteryChargeWh = h.batteryChargeWh
summary.BatteryDischargeWh = h.batteryDischargeWh
summary.UptimeSeconds = h.uptimeSeconds
if faultCount == 0 {
faultCount = h.lastFaultCount
}
summary.FaultCount = faultCount
if lastFaultAt == nil && !h.lastFaultAt.IsZero() {
lastFaultAt = &h.lastFaultAt
}
if lastFaultAt != nil && !lastFaultAt.IsZero() {
summary.LastFaultAt = lastFaultAt.UTC().Format(time.RFC3339)
}
if state == "" {
state = h.lastState
}
summary.CurrentState = string(state)
return summary
}
func summarizeHistory(samples []historySample) historySummary {
summary := historySummary{
Samples: len(samples),
}
if len(samples) == 0 {
return summary
}
first := samples[0]
last := samples[len(samples)-1]
if last.Timestamp.After(first.Timestamp) {
summary.WindowSeconds = last.Timestamp.Sub(first.Timestamp).Seconds()
}
minBatteryVoltage := samples[0].BatteryVoltage
maxOutputPower := samples[0].OutputPowerVA
var inTotal, outTotal, batTotal float64
for _, sample := range samples {
inTotal += sample.InputPowerVA
outTotal += sample.OutputPowerVA
batTotal += sample.BatteryPowerW
if sample.BatteryVoltage < minBatteryVoltage {
minBatteryVoltage = sample.BatteryVoltage
}
if sample.OutputPowerVA > maxOutputPower {
maxOutputPower = sample.OutputPowerVA
}
}
count := float64(len(samples))
summary.AverageInputPower = inTotal / count
summary.AverageOutputPower = outTotal / count
summary.AverageBatteryPower = batTotal / count
summary.MaxOutputPower = maxOutputPower
summary.MinBatteryVoltage = minBatteryVoltage
return summary
}
func newAlarmTracker() *alarmTracker {
return &alarmTracker{
active: map[string]alarmState{},
rules: map[string]*alarmRule{},
debounceOn: 1500 * time.Millisecond,
debounceOff: 3 * time.Second,
}
}
func (a *alarmTracker) Update(snapshot telemetrySnapshot) []alarmState {
now := snapshot.Timestamp
if now.IsZero() {
now = time.Now().UTC()
}
a.mu.Lock()
defer a.mu.Unlock()
a.setLocked("invalid_data", "critical", "MK2 data frame marked invalid", !snapshot.Valid, now)
a.setFromLEDLocked(snapshot, "led_overload", "overload", "critical", "Inverter overload alarm", now)
a.setFromLEDLocked(snapshot, "led_over_temp", "over_temperature", "critical", "Inverter over temperature alarm", now)
a.setFromLEDLocked(snapshot, "led_bat_low", "battery_low", "warning", "Battery low warning", now)
a.setLocked("command_failure", "warning", "Recent command failed", false, now)
return a.activeLocked()
}
func (a *alarmTracker) RecordCommandFailure(err error, now time.Time) []alarmState {
if now.IsZero() {
now = time.Now().UTC()
}
a.mu.Lock()
defer a.mu.Unlock()
message := "Recent command failed"
if err != nil {
message = fmt.Sprintf("Command failed: %v", err)
}
a.setLocked("command_failure", "warning", message, true, now)
return a.activeLocked()
}
func (a *alarmTracker) RecordCommandSuccess(now time.Time) []alarmState {
if now.IsZero() {
now = time.Now().UTC()
}
a.mu.Lock()
defer a.mu.Unlock()
a.setLocked("command_failure", "warning", "Recent command failed", false, now)
return a.activeLocked()
}
func (a *alarmTracker) Stats() (uint64, *time.Time) {
a.mu.Lock()
defer a.mu.Unlock()
if a.lastFault.IsZero() {
return a.faultCount, nil
}
last := a.lastFault
return a.faultCount, &last
}
func (a *alarmTracker) activeLocked() []alarmState {
alarms := make([]alarmState, 0, len(a.active))
for _, alarm := range a.active {
alarms = append(alarms, alarm)
}
sort.Slice(alarms, func(i, j int) bool {
return alarms[i].Code < alarms[j].Code
})
return alarms
}
func (a *alarmTracker) getRuleLocked(code, level, message string) *alarmRule {
rule, ok := a.rules[code]
if !ok {
rule = &alarmRule{
code: code,
level: level,
message: message,
}
a.rules[code] = rule
return rule
}
rule.level = level
rule.message = message
return rule
}
func (a *alarmTracker) setFromLEDLocked(snapshot telemetrySnapshot, ledKey, code, level, message string, now time.Time) {
state := strings.ToLower(strings.TrimSpace(snapshot.LEDs[ledKey]))
active := state == "on" || state == "blink"
a.setLocked(code, level, message, active, now)
}
func (a *alarmTracker) setLocked(code, level, message string, active bool, now time.Time) {
rule := a.getRuleLocked(code, level, message)
if active {
rule.clearFrom = time.Time{}
if rule.active {
a.active[code] = alarmState{
Code: code,
Level: level,
Message: message,
ActiveSince: rule.activeSince,
}
return
}
if rule.pendingFrom.IsZero() {
rule.pendingFrom = now
}
if now.Sub(rule.pendingFrom) >= a.debounceOn {
rule.active = true
rule.activeSince = rule.pendingFrom
a.active[code] = alarmState{
Code: code,
Level: level,
Message: message,
ActiveSince: rule.activeSince,
}
a.faultCount++
a.lastFault = now
}
return
}
rule.pendingFrom = time.Time{}
if !rule.active {
delete(a.active, code)
return
}
if rule.clearFrom.IsZero() {
rule.clearFrom = now
return
}
if now.Sub(rule.clearFrom) >= a.debounceOff {
rule.active = false
rule.activeSince = time.Time{}
rule.clearFrom = time.Time{}
delete(a.active, code)
}
}
func deriveOperatingState(snapshot telemetrySnapshot, panel panelStateSnapshot, alarms []alarmState) operatingState {
for _, alarm := range alarms {
if strings.EqualFold(alarm.Level, "critical") {
return operatingStateFault
}
}
if !snapshot.Valid {
return operatingStateFault
}
if panel.HasSwitch && panel.Switch == "off" {
return operatingStateOff
}
mainsState := strings.ToLower(strings.TrimSpace(snapshot.LEDs["led_mains"]))
inverterState := strings.ToLower(strings.TrimSpace(snapshot.LEDs["led_inverter"]))
mainsOn := mainsState == "on" || mainsState == "blink"
inverterOn := inverterState == "on" || inverterState == "blink"
charging := snapshot.BatteryCurrent > 0.2
if inverterOn && !mainsOn {
return operatingStateInverter
}
if mainsOn && snapshot.OutputPower > 20 {
if charging {
return operatingStateCharger
}
return operatingStatePassthru
}
if charging {
return operatingStateCharger
}
if snapshot.OutputPower > 20 {
return operatingStateInverter
}
return operatingStateOff
}
func publishOrchestrationTopics(client mqtt.Client, config Config, snapshot telemetrySnapshot, panel panelStateSnapshot, summary historySummary, alarms []alarmState, state operatingState) error {
deviceRoot := deviceTopicRoot(config)
payload := orchestrationState{
DeviceID: normalizedDeviceID(config),
InstanceID: config.InstanceID,
Phase: strings.ToUpper(strings.TrimSpace(config.Phase)),
PhaseGroup: strings.TrimSpace(config.PhaseGroup),
Timestamp: snapshot.Timestamp,
OperatingState: string(state),
Telemetry: snapshot,
PanelState: panel,
History: summary,
Alarms: alarms,
}
if err := publishJSON(client, fmt.Sprintf("%s/state", deviceRoot), payload, 0, false); err != nil {
return err
}
if err := publishJSON(client, fmt.Sprintf("%s/history/summary", deviceRoot), summary, 0, false); err != nil {
return err
}
if err := publishActiveAlarms(client, config, alarms); err != nil {
return err
}
groupPhase := strings.ToUpper(strings.TrimSpace(config.Phase))
if groupPhase == "" {
groupPhase = "L1"
}
groupTopic := fmt.Sprintf("%s/groups/%s/%s/state", mqttTopicRoot(config.Topic), normalizeID(config.PhaseGroup), groupPhase)
if err := publishJSON(client, groupTopic, payload, 0, false); err != nil {
return err
}
return nil
}
func publishActiveAlarms(client mqtt.Client, config Config, alarms []alarmState) error {
return publishJSON(client, fmt.Sprintf("%s/alarms/active", deviceTopicRoot(config)), alarms, 1, true)
}
func publishDiagnostics(client mqtt.Client, config Config, driverDiag mk2driver.DriverDiagnostics, commands []mk2driver.CommandEvent) error {
diag := diagnosticsBundle{
DeviceID: normalizedDeviceID(config),
InstanceID: config.InstanceID,
Phase: strings.ToUpper(strings.TrimSpace(config.Phase)),
PhaseGroup: strings.TrimSpace(config.PhaseGroup),
GeneratedAt: time.Now().UTC(),
HealthScore: driverDiag.HealthScore,
Driver: &driverDiag,
Commands: commands,
}
return publishJSON(client, fmt.Sprintf("%s/diagnostics", deviceTopicRoot(config)), diag, 1, false)
}
func publishVenusServiceInfo(client mqtt.Client, config Config) error {
if !config.Venus.Enabled {
return nil
}
if err := publishVenusValue(client, config, "Connected", 1, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "ProductName", "invertergui", true); err != nil {
return err
}
if err := publishVenusValue(client, config, "DeviceInstance", config.InstanceID, true); err != nil {
return err
}
return nil
}
func publishVenusTelemetry(client mqtt.Client, config Config, snapshot telemetrySnapshot, panel panelStateSnapshot, alarms []alarmState, state operatingState, history historySummary, ess essControlSnapshot) error {
phase := strings.ToUpper(strings.TrimSpace(config.Phase))
if phase == "" {
phase = "L1"
}
if err := publishVenusValue(client, config, "Connected", 1, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "State", string(state), true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Dc/0/Voltage", snapshot.BatteryVoltage, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Dc/0/Current", snapshot.BatteryCurrent, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Dc/0/Power", snapshot.BatteryPower, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Soc", snapshot.BatteryCharge, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/ActiveIn/%s/V", phase), snapshot.InputVoltage, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/ActiveIn/%s/I", phase), snapshot.InputCurrent, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/ActiveIn/%s/F", phase), snapshot.InputFrequency, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/ActiveIn/%s/P", phase), snapshot.InputPower, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/Out/%s/V", phase), snapshot.OutputVoltage, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/Out/%s/I", phase), snapshot.OutputCurrent, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/Out/%s/F", phase), snapshot.OutputFrequency, true); err != nil {
return err
}
if err := publishVenusValue(client, config, fmt.Sprintf("Ac/Out/%s/P", phase), snapshot.OutputPower, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "History/EnergyIn", history.EnergyInWh, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "History/EnergyOut", history.EnergyOutWh, true); err != nil {
return err
}
if err := publishVenusValue(client, config, "History/FaultCount", history.FaultCount, true); err != nil {
return err
}
if err := publishVenusESSState(client, config, ess); err != nil {
return err
}
if panel.HasSwitch {
if err := publishVenusValue(client, config, "Mode", panel.Switch, true); err != nil {
return err
}
}
if panel.HasCurrent {
if err := publishVenusValue(client, config, "Ac/ActiveIn/CurrentLimit", panel.CurrentLimit, true); err != nil {
return err
}
}
if panel.HasStandby {
if err := publishVenusValue(client, config, "Settings/Standby", panel.Standby, true); err != nil {
return err
}
}
alarmLevels := venusAlarmLevels(alarms)
if err := publishVenusValue(client, config, "Alarms/LowBattery", alarmLevels["battery_low"], true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Alarms/HighTemperature", alarmLevels["over_temperature"], true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Alarms/Overload", alarmLevels["overload"], true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Alarms/Communication", alarmLevels["invalid_data"], true); err != nil {
return err
}
if err := publishVenusValue(client, config, "Alarms/Command", alarmLevels["command_failure"], true); err != nil {
return err
}
return nil
}
func publishVenusControlState(client mqtt.Client, config Config, cmd writeCommand, panel panelStateSnapshot) error {
switch cmd.Kind {
case commandKindPanel:
if panel.HasSwitch {
if err := publishVenusValue(client, config, "Mode", panel.Switch, true); err != nil {
return err
}
}
if panel.HasCurrent {
if err := publishVenusValue(client, config, "Ac/ActiveIn/CurrentLimit", panel.CurrentLimit, true); err != nil {
return err
}
}
case commandKindStandby:
if panel.HasStandby {
if err := publishVenusValue(client, config, "Settings/Standby", panel.Standby, true); err != nil {
return err
}
}
}
return nil
}
func publishVenusESSState(client mqtt.Client, config Config, ess essControlSnapshot) error {
if !config.Venus.GuideCompat {
return nil
}
if ess.HasSetpoint {
if err := publishVenusValueWithService(client, config, "settings/0", "Settings/CGwacs/AcPowerSetPoint", ess.SetpointW, true); err != nil {
return err
}
}
if ess.HasMaxCharge {
if err := publishVenusValueWithService(client, config, "settings/0", "Settings/CGwacs/MaxChargePower", ess.MaxChargeW, true); err != nil {
return err
}
}
if ess.HasMaxDischarge {
if err := publishVenusValueWithService(client, config, "settings/0", "Settings/CGwacs/MaxDischargePower", ess.MaxDischargeW, true); err != nil {
return err
}
}
if ess.HasBatteryLife {
if err := publishVenusValueWithService(client, config, "settings/0", "Settings/CGwacs/BatteryLife/State", ess.BatteryLifeState, true); err != nil {
return err
}
}
return nil
}
func venusAlarmLevels(alarms []alarmState) map[string]int {
levels := map[string]int{
"battery_low": 0,
"over_temperature": 0,
"overload": 0,
"invalid_data": 0,
"command_failure": 0,
}
for _, alarm := range alarms {
level := 1
if strings.EqualFold(alarm.Level, "critical") {
level = 2
}
if existing, ok := levels[alarm.Code]; ok && level > existing {
levels[alarm.Code] = level
}
}
return levels
}
func publishVenusValue(client mqtt.Client, config Config, path string, value any, retained bool) error {
topic := venusNotifyTopic(config, path)
payload := venusValueEnvelope{Value: value}
return publishJSON(client, topic, payload, 1, retained)
}
func publishVenusValueWithService(client mqtt.Client, config Config, service, path string, value any, retained bool) error {
topic := venusNotifyTopicWithService(config, service, path)
payload := venusValueEnvelope{Value: value}
return publishJSON(client, topic, payload, 1, retained)
}
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()
}
log.WithFields(logrus.Fields{
"topic": topic,
"qos": qos,
"retained": retained,
"bytes": len(data),
}).Debug("Published JSON message")
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()
}
log.WithFields(logrus.Fields{
"topic": topic,
"qos": qos,
"retained": retained,
"payload": payload,
}).Debug("Published string message")
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 normalizedDeviceID(config Config) string {
deviceID := normalizeID(config.DeviceID)
if deviceID == "" {
deviceID = normalizeID(config.ClientID)
}
if deviceID == "" {
return "invertergui"
}
return deviceID
}
func deviceTopicRoot(config Config) string {
return fmt.Sprintf("%s/devices/%s", mqttTopicRoot(config.Topic), normalizedDeviceID(config))
}
func venusPortalID(config Config) string {
portal := normalizeID(config.Venus.PortalID)
if portal == "" {
return normalizedDeviceID(config)
}
return portal
}
func venusService(config Config) string {
service := strings.Trim(strings.TrimSpace(config.Venus.Service), "/")
if service == "" {
return "vebus/257"
}
return service
}
func venusNotifyTopic(config Config, path string) string {
return venusNotifyTopicWithService(config, venusService(config), path)
}
func venusNotifyTopicWithService(config Config, service, path string) string {
cleanPath := strings.Trim(strings.TrimSpace(path), "/")
cleanService := strings.Trim(strings.TrimSpace(service), "/")
base := fmt.Sprintf("N/%s/%s/%s", venusPortalID(config), cleanService, cleanPath)
return venusPrefixedTopic(config, base)
}
func venusWriteTopicRoot(config Config) string {
return venusWriteTopicRootWithService(config, venusService(config))
}
func venusWriteTopicRootWithService(config Config, service string) string {
cleanService := strings.Trim(strings.TrimSpace(service), "/")
base := fmt.Sprintf("W/%s/%s", venusPortalID(config), cleanService)
return venusPrefixedTopic(config, base)
}
func venusWriteTopicWildcard(config Config) string {
return fmt.Sprintf("%s/#", venusWriteTopicRoot(config))
}
func venusWriteTopicWildcardWithService(config Config, service string) string {
return fmt.Sprintf("%s/#", venusWriteTopicRootWithService(config, service))
}
func venusPrefixedTopic(config Config, topic string) string {
prefix := strings.Trim(strings.TrimSpace(config.Venus.TopicPrefix), "/")
cleanTopic := strings.Trim(strings.TrimSpace(topic), "/")
if prefix == "" {
return cleanTopic
}
return fmt.Sprintf("%s/%s", prefix, cleanTopic)
}
func stripVenusTopicPrefix(config Config, topic string) string {
clean := strings.Trim(strings.TrimSpace(topic), "/")
prefix := strings.Trim(strings.TrimSpace(config.Venus.TopicPrefix), "/")
if prefix == "" {
return clean
}
if strings.HasPrefix(clean, prefix+"/") {
return strings.TrimPrefix(clean, prefix+"/")
}
return clean
}
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 float64Ptr(in float64) *float64 {
return &in
}
func getOpts(config Config) *mqtt.ClientOptions {
opts := mqtt.NewClientOptions()
opts.AddBroker(config.Broker)
opts.SetClientID(config.ClientID)
if config.Username != "" {
opts.SetUsername(config.Username)
}
if config.Password != "" {
opts.SetPassword(config.Password)
}
if config.HomeAssistant.Enabled {
opts.SetWill(haAvailabilityTopic(config), "offline", 1, true)
} else if config.Venus.Enabled {
opts.SetWill(venusNotifyTopic(config, "Connected"), `{"value":0}`, 1, true)
}
opts.SetKeepAlive(keepAlive)
opts.SetOnConnectHandler(func(mqtt.Client) {
log.Info("Client connected to broker")
})
opts.SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Errorf("Client connection to broker lost: %v", err)
})
return opts
}