diff --git a/README.md b/README.md index 60cff21..c6d2539 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,56 @@ docker run --name invertergui --device /dev/ttyUSB0:/dev/ttyUSB0 -p 8080:8080 re This project makes use of [Go Modules](https://github.com/golang/go/wiki/Modules). The minimum supported version for Go is 1.22 +## Driver API: Metadata + Safe Transactions + +The MK2 driver now includes a metadata and transaction safety layer via the +`mk2driver.MetadataControl` interface: + +- Register metadata lookup (`RegisterMetadata`, `ListRegisterMetadata`) +- Generic register reads by kind/id (`ReadRegister`) +- Transactional writes with retry and verify (`WriteRegister`) + +`WriteRegister` supports: + +- `ReadBeforeWrite` +- `VerifyAfterWrite` +- configurable retry count and delay + +This layer is additive and does not replace existing `WriteSetting`, `WriteRAMVar`, +or panel control APIs. + +## Advanced Control + Orchestration Features + +The codebase now includes: + +- Full MK2 command-path coverage in driver APIs for: + - `0x0E` device state read/write + - register read by id (`0x30`, `0x31`) + - selected read/write flows (`0x32`, `0x33`, `0x34`, `0x35`) + - RAM var metadata/info (`0x36`) + - write-by-id flows (`0x37`, `0x38`) +- Register metadata with `unit`, `min/max`, `scale`, `writable`, and `safety_class`. +- Transaction-safe writes with read-before-write, verify-after-write, retry/backoff, and timeout classes. +- Snapshot/diff/restore register workflows with rollback on partial restore failure. +- Alarm engine with LED/state alarms + command-failure alarms, including debounce and clear behavior. +- Venus-like derived operating state model: `Off`, `Inverter`, `Charger`, `Passthru`, `Fault`. +- Historical counters for energy and availability: + - `energy_in_wh`, `energy_out_wh` + - battery charge/discharge Wh + - uptime seconds + - fault count + last fault timestamp +- Multi-device orchestration fields and topics: + - `device_id`, `instance_id`, `phase`, `phase_group` + - per-device topics and phase-group fanout topics +- Command arbitration/policy layer: + - single serialized write path + - lockout windows + - source tagging (`ui`, `mqtt`, `automation`) + - max current guardrail + mode rate limit + maintenance/read-only profiles +- Venus-compatible MQTT mode (`N/...` + optional `W/...`) for HA/Node-RED/VRM-style workflows. +- Guide-style Victron ESS MQTT paths (`settings/0/Settings/CGwacs/*`) with `victron/N/...` and `victron/W/...` prefix compatibility. +- Structured diagnostics bundle topics with protocol traces, recent command history, and health score. + ## Getting started ```bash @@ -48,13 +98,28 @@ Application Options: --mqtt.topic= Set the MQTT topic updates published to. (default: invertergui/updates) [$MQTT_TOPIC] --mqtt.command_topic= Set the MQTT topic that receives write commands for Victron settings/RAM variables. (default: invertergui/settings/set) [$MQTT_COMMAND_TOPIC] --mqtt.status_topic= Set the MQTT topic where write command status updates are published. (default: invertergui/settings/status) [$MQTT_STATUS_TOPIC] + --mqtt.device_id= Set logical device ID used for per-device orchestration topics. (default: invertergui) [$MQTT_DEVICE_ID] + --mqtt.history_size= Number of samples retained for rolling history summaries. (default: 120) [$MQTT_HISTORY_SIZE] + --mqtt.instance_id= Device instance ID for multi-device orchestration and Venus compatibility. (default: 0) [$MQTT_INSTANCE_ID] + --mqtt.phase= Electrical phase label for this instance (L1/L2/L3). (default: L1) [$MQTT_PHASE] + --mqtt.phase_group= Grouping key for parallel/3-phase system aggregation topics. (default: default) [$MQTT_PHASE_GROUP] --mqtt.ha.enabled Enable Home Assistant MQTT discovery integration. [$MQTT_HA_ENABLED] --mqtt.ha.discovery_prefix= Set Home Assistant MQTT discovery prefix. (default: homeassistant) [$MQTT_HA_DISCOVERY_PREFIX] --mqtt.ha.node_id= Set Home Assistant node ID used for discovery topics and unique IDs. (default: invertergui) [$MQTT_HA_NODE_ID] --mqtt.ha.device_name= Set Home Assistant device display name. (default: Victron Inverter) [$MQTT_HA_DEVICE_NAME] + --mqtt.venus.enabled Enable Venus-style MQTT compatibility topics (N/W model). [$MQTT_VENUS_ENABLED] + --mqtt.venus.portal_id= Set Venus portal ID segment used in N/W topics. (default: invertergui) [$MQTT_VENUS_PORTAL_ID] + --mqtt.venus.service= Set Venus service segment used in N/W topics. (default: vebus/257) [$MQTT_VENUS_SERVICE] + --mqtt.venus.subscribe_writes Subscribe to Venus W/... topics and map to MK2 commands. [$MQTT_VENUS_SUBSCRIBE_WRITES] + --mqtt.venus.topic_prefix= Optional topic prefix before Venus N/W topics, e.g. victron. [$MQTT_VENUS_TOPIC_PREFIX] + --mqtt.venus.guide_compat Enable guide-style settings/0/Settings/CGwacs compatibility paths. [$MQTT_VENUS_GUIDE_COMPAT] --mqtt.username= Set the MQTT username [$MQTT_USERNAME] --mqtt.password= Set the MQTT password [$MQTT_PASSWORD] --mqtt.password-file= Path to a file containing the MQTT password [$MQTT_PASSWORD_FILE] + --control.profile= Write policy profile: normal, maintenance, or read_only. (default: normal) [$CONTROL_PROFILE] + --control.max_current_limit= Optional max AC current limit guardrail in amps (0 disables). (default: 0) [$CONTROL_MAX_CURRENT_LIMIT] + --control.mode_change_min_interval= Minimum time between mode changes. (default: 3s) [$CONTROL_MODE_CHANGE_MIN_INTERVAL] + --control.lockout_window= Post-command lockout window for command arbitration. (default: 0s) [$CONTROL_LOCKOUT_WINDOW] --loglevel= The log level to generate logs at. ("panic", "fatal", "error", "warn", "info", "debug", "trace") (default: info) [$LOGLEVEL] Help Options: @@ -84,6 +149,26 @@ services: command: ["--mqtt.enabled", "--mqtt.broker=tcp://192.168.1.1:1883", "--loglevel=info"] ``` +### Home Assistant Guide-Style ESS Control + +To mimic the Victron community ESS control approach (MQTT `N/...` state + `W/...` writes under `settings/0/Settings/CGwacs/...`): + +1. Start invertergui with: + - `MQTT_VENUS_ENABLED=true` + - `MQTT_VENUS_GUIDE_COMPAT=true` + - `MQTT_VENUS_TOPIC_PREFIX=victron` + - `MQTT_VENUS_PORTAL_ID=invertergui` (or your chosen portal id) +2. In Home Assistant: + - Use the included custom integration (`custom_components/victron_mk2_mqtt`) which now exposes ESS-style entities and service calls. + - Or use `homeassistant/packages/invertergui_mqtt.yaml`, which includes guide-style MQTT entities: + - `number.victron_ess_grid_setpoint` + - `number.victron_ess_max_charge_power` + - `number.victron_ess_max_discharge_power` + - `switch.victron_ess_optimized_mode` + +Compatibility note: +- MK2/VE.Bus does not expose every Venus ESS feature one-to-one. This project maps ESS-style commands onto available MK2 controls (mode/current-limit/policy-safe behavior) to provide similar Home Assistant control flow. + ## Port 8080 The default HTTP server port is hosted on port 8080. This exposes the HTTP server that hosts the: @@ -336,10 +421,16 @@ The MQTT client will publish updates to the given broker at the set topic. --mqtt.topic= Set the MQTT topic updates published to. (default: invertergui/updates) [$MQTT_TOPIC] --mqtt.command_topic= Set the MQTT topic that receives write commands for Victron settings/RAM variables. (default: invertergui/settings/set) [$MQTT_COMMAND_TOPIC] --mqtt.status_topic= Set the MQTT topic where write command status updates are published. (default: invertergui/settings/status) [$MQTT_STATUS_TOPIC] +--mqtt.device_id= Set logical device ID used for per-device orchestration topics. (default: invertergui) [$MQTT_DEVICE_ID] +--mqtt.history_size= Number of samples retained for rolling history summaries. (default: 120) [$MQTT_HISTORY_SIZE] --mqtt.ha.enabled Enable Home Assistant MQTT discovery integration. [$MQTT_HA_ENABLED] --mqtt.ha.discovery_prefix= Set Home Assistant MQTT discovery prefix. (default: homeassistant) [$MQTT_HA_DISCOVERY_PREFIX] --mqtt.ha.node_id= Set Home Assistant node ID used for discovery topics and unique IDs. (default: invertergui) [$MQTT_HA_NODE_ID] --mqtt.ha.device_name= Set Home Assistant device display name. (default: Victron Inverter) [$MQTT_HA_DEVICE_NAME] +--mqtt.venus.enabled Enable Venus-style MQTT compatibility topics (N/W model). [$MQTT_VENUS_ENABLED] +--mqtt.venus.portal_id= Set Venus portal ID segment used in N/W topics. (default: invertergui) [$MQTT_VENUS_PORTAL_ID] +--mqtt.venus.service= Set Venus service segment used in N/W topics. (default: vebus/257) [$MQTT_VENUS_SERVICE] +--mqtt.venus.subscribe_writes Subscribe to Venus W/... topics and map to MK2 commands. [$MQTT_VENUS_SUBSCRIBE_WRITES] --mqtt.username= Set the MQTT username [$MQTT_USERNAME] --mqtt.password= Set the MQTT password [$MQTT_PASSWORD] --mqtt.password-file= Path to a file containing the MQTT password [$MQTT_PASSWORD_FILE] @@ -402,6 +493,60 @@ Low-level writes are still supported: `kind` supports `panel_state`, `setting`, and `ram_var` (with aliases for each). The result is published to `--mqtt.status_topic` with status `ok` or `error`. +### MQTT Device Orchestration Topics + +For multi-device deployments on one MQTT broker, `invertergui` now also publishes +device-scoped orchestration topics under: + +- `{topic-root}/devices/{device_id}/state` +- `{topic-root}/devices/{device_id}/history/summary` +- `{topic-root}/devices/{device_id}/alarms/active` + +Set `--mqtt.device_id` (or `MQTT_DEVICE_ID`) per inverter instance so each instance +publishes to a unique device path. + +Rolling history depth for the summary window is set by `--mqtt.history_size`. + +### Venus-Style MQTT Compatibility + +Enable Venus-compatible topics with: + +```bash +--mqtt.venus.enabled +``` + +When enabled, `invertergui` publishes Venus-style notifications to: + +- `N/{portal_id}/{service}/...` + +Defaults: + +- `portal_id`: `invertergui` (`--mqtt.venus.portal_id`) +- `service`: `vebus/257` (`--mqtt.venus.service`) + +Published paths include common VE.Bus style values such as: + +- `Dc/0/Voltage`, `Dc/0/Current`, `Dc/0/Power`, `Soc` +- `Ac/ActiveIn/L1/V`, `Ac/ActiveIn/L1/I`, `Ac/ActiveIn/L1/F`, `Ac/ActiveIn/L1/P` +- `Ac/Out/L1/V`, `Ac/Out/L1/I`, `Ac/Out/L1/F`, `Ac/Out/L1/P` +- alarm paths (`Alarms/LowBattery`, `Alarms/HighTemperature`, `Alarms/Overload`, `Alarms/Communication`) + +Optional write compatibility can be enabled with: + +```bash +--mqtt.venus.subscribe_writes +``` + +This subscribes to: + +- `W/{portal_id}/{service}/#` + +and maps supported write paths to MK2 control operations: + +- `Mode` -> remote panel mode +- `Ac/ActiveIn/CurrentLimit` -> remote panel current limit +- `Settings/Standby` / `RemotePanel/Standby` -> standby control + ### Home Assistant Enable Home Assistant auto-discovery with: diff --git a/cmd/invertergui/config.go b/cmd/invertergui/config.go index 7fe1778..3ee3c16 100644 --- a/cmd/invertergui/config.go +++ b/cmd/invertergui/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/jessevdk/go-flags" ) @@ -11,7 +12,13 @@ import ( type config struct { Address string `long:"address" env:"ADDRESS" default:":8080" description:"The IP/DNS and port of the machine that the application is running on."` ReadOnly bool `long:"read_only" env:"READ_ONLY" description:"Disable all write operations and run in monitoring-only mode."` - Data struct { + Control struct { + Profile string `long:"control.profile" env:"CONTROL_PROFILE" default:"normal" description:"Write policy profile: normal, maintenance, or read_only."` + MaxCurrentLimit float64 `long:"control.max_current_limit" env:"CONTROL_MAX_CURRENT_LIMIT" default:"0" description:"Optional max AC current limit guardrail in amps (0 disables)."` + ModeChangeMinInterval time.Duration `long:"control.mode_change_min_interval" env:"CONTROL_MODE_CHANGE_MIN_INTERVAL" default:"3s" description:"Minimum time between mode changes."` + LockoutWindow time.Duration `long:"control.lockout_window" env:"CONTROL_LOCKOUT_WINDOW" default:"0s" description:"Post-command lockout window for command arbitration."` + } + Data struct { Source string `long:"data.source" env:"DATA_SOURCE" default:"serial" description:"Set the source of data for the inverter gui. \"serial\", \"tcp\" or \"mock\""` Host string `long:"data.host" env:"DATA_HOST" default:"localhost:8139" description:"Host to connect when source is set to tcp."` Device string `long:"data.device" env:"DATA_DEVICE" default:"/dev/ttyUSB0" description:"TTY device to use when source is set to serial."` @@ -26,12 +33,25 @@ type config struct { Topic string `long:"mqtt.topic" env:"MQTT_TOPIC" default:"invertergui/updates" description:"Set the MQTT topic updates published to."` CommandTopic string `long:"mqtt.command_topic" env:"MQTT_COMMAND_TOPIC" default:"invertergui/settings/set" description:"Set the MQTT topic that receives write commands for Victron settings/RAM variables."` StatusTopic string `long:"mqtt.status_topic" env:"MQTT_STATUS_TOPIC" default:"invertergui/settings/status" description:"Set the MQTT topic where write command status updates are published."` + DeviceID string `long:"mqtt.device_id" env:"MQTT_DEVICE_ID" default:"invertergui" description:"Set the logical device ID used for per-device orchestration topics."` + HistorySize int `long:"mqtt.history_size" env:"MQTT_HISTORY_SIZE" default:"120" description:"Number of telemetry samples retained for rolling history summaries."` + InstanceID int `long:"mqtt.instance_id" env:"MQTT_INSTANCE_ID" default:"0" description:"Device instance ID for multi-device orchestration and Venus compatibility."` + Phase string `long:"mqtt.phase" env:"MQTT_PHASE" default:"L1" description:"Electrical phase label for this instance (L1/L2/L3)."` + PhaseGroup string `long:"mqtt.phase_group" env:"MQTT_PHASE_GROUP" default:"default" description:"Grouping key for parallel/3-phase system aggregation topics."` HA struct { Enabled bool `long:"mqtt.ha.enabled" env:"MQTT_HA_ENABLED" description:"Enable Home Assistant MQTT discovery integration."` DiscoveryPrefix string `long:"mqtt.ha.discovery_prefix" env:"MQTT_HA_DISCOVERY_PREFIX" default:"homeassistant" description:"Set Home Assistant MQTT discovery prefix."` NodeID string `long:"mqtt.ha.node_id" env:"MQTT_HA_NODE_ID" default:"invertergui" description:"Set Home Assistant node ID used for discovery topics and unique IDs."` DeviceName string `long:"mqtt.ha.device_name" env:"MQTT_HA_DEVICE_NAME" default:"Victron Inverter" description:"Set Home Assistant device display name."` } + Venus struct { + Enabled bool `long:"mqtt.venus.enabled" env:"MQTT_VENUS_ENABLED" description:"Enable Venus-style MQTT compatibility topics (N/W topic model)."` + PortalID string `long:"mqtt.venus.portal_id" env:"MQTT_VENUS_PORTAL_ID" default:"invertergui" description:"Set Venus portal ID segment used in N/W topics."` + Service string `long:"mqtt.venus.service" env:"MQTT_VENUS_SERVICE" default:"vebus/257" description:"Set Venus service segment used in N/W topics."` + SubscribeWrites bool `long:"mqtt.venus.subscribe_writes" env:"MQTT_VENUS_SUBSCRIBE_WRITES" default:"true" description:"Subscribe to Venus write topics and map them to MK2 control commands."` + TopicPrefix string `long:"mqtt.venus.topic_prefix" env:"MQTT_VENUS_TOPIC_PREFIX" default:"" description:"Optional topic prefix before Venus N/W topics, for example 'victron'."` + GuideCompat bool `long:"mqtt.venus.guide_compat" env:"MQTT_VENUS_GUIDE_COMPAT" default:"true" description:"Enable guide-style settings/0/Settings/CGwacs compatibility paths for Home Assistant controls."` + } Username string `long:"mqtt.username" env:"MQTT_USERNAME" default:"" description:"Set the MQTT username"` Password string `long:"mqtt.password" env:"MQTT_PASSWORD" default:"" description:"Set the MQTT password"` PasswordFile string `long:"mqtt.password-file" env:"MQTT_PASSWORD_FILE" default:"" description:"Path to a file containing the MQTT password"` diff --git a/cmd/invertergui/main.go b/cmd/invertergui/main.go index 93e9923..dfd1345 100644 --- a/cmd/invertergui/main.go +++ b/cmd/invertergui/main.go @@ -36,6 +36,7 @@ import ( "net" "net/http" "os" + "strings" "git.coadcorp.com/nathan/invertergui/mk2core" "git.coadcorp.com/nathan/invertergui/mk2driver" @@ -77,7 +78,15 @@ func main() { "mqtt_topic": conf.MQTT.Topic, "mqtt_command_topic": conf.MQTT.CommandTopic, "mqtt_status_topic": conf.MQTT.StatusTopic, + "mqtt_device_id": conf.MQTT.DeviceID, + "mqtt_history_size": conf.MQTT.HistorySize, "mqtt_ha_enabled": conf.MQTT.HA.Enabled, + "mqtt_venus_enabled": conf.MQTT.Venus.Enabled, + "mqtt_venus_portal": conf.MQTT.Venus.PortalID, + "mqtt_venus_service": conf.MQTT.Venus.Service, + "mqtt_venus_prefix": conf.MQTT.Venus.TopicPrefix, + "mqtt_venus_guide": conf.MQTT.Venus.GuideCompat, + "control_profile": conf.Control.Profile, }).Info("Configuration loaded") mk2, err := getMk2Device(conf.Data.Source, conf.Data.Host, conf.Data.Device) @@ -109,6 +118,36 @@ func main() { log.Info("READ_ONLY enabled") } writer = nil + } else if writer != nil { + policyProfile := mk2driver.WriterProfile(strings.ToLower(strings.TrimSpace(conf.Control.Profile))) + if policyProfile == "" { + policyProfile = mk2driver.WriterProfileNormal + } + if policyProfile != mk2driver.WriterProfileNormal && + policyProfile != mk2driver.WriterProfileMaintenance && + policyProfile != mk2driver.WriterProfileReadOnly { + log.WithField("profile", conf.Control.Profile).Warn("Unknown control profile; defaulting to normal") + policyProfile = mk2driver.WriterProfileNormal + } + + var maxCurrentLimit *float64 + if conf.Control.MaxCurrentLimit > 0 { + limit := conf.Control.MaxCurrentLimit + maxCurrentLimit = &limit + } + + writer = mk2driver.NewManagedWriter(writer, mk2driver.WriterPolicy{ + Profile: policyProfile, + MaxCurrentLimitA: maxCurrentLimit, + ModeChangeMinInterval: conf.Control.ModeChangeMinInterval, + LockoutWindow: conf.Control.LockoutWindow, + }) + log.WithFields(logrus.Fields{ + "profile": policyProfile, + "max_current_limit": conf.Control.MaxCurrentLimit, + "mode_change_min_interval": conf.Control.ModeChangeMinInterval, + "lockout_window": conf.Control.LockoutWindow, + }).Info("Write policy/arbitration layer enabled") } gui := webui.NewWebGui(core.NewSubscription(), writer) http.Handle("/", static.New()) @@ -136,12 +175,25 @@ func main() { CommandTopic: conf.MQTT.CommandTopic, StatusTopic: conf.MQTT.StatusTopic, ClientID: conf.MQTT.ClientID, + DeviceID: conf.MQTT.DeviceID, + HistorySize: conf.MQTT.HistorySize, + InstanceID: conf.MQTT.InstanceID, + Phase: conf.MQTT.Phase, + PhaseGroup: conf.MQTT.PhaseGroup, HomeAssistant: mqttclient.HomeAssistantConfig{ Enabled: conf.MQTT.HA.Enabled, DiscoveryPrefix: conf.MQTT.HA.DiscoveryPrefix, NodeID: conf.MQTT.HA.NodeID, DeviceName: conf.MQTT.HA.DeviceName, }, + Venus: mqttclient.VenusConfig{ + Enabled: conf.MQTT.Venus.Enabled, + PortalID: conf.MQTT.Venus.PortalID, + Service: conf.MQTT.Venus.Service, + SubscribeWrites: conf.MQTT.Venus.SubscribeWrites, + TopicPrefix: conf.MQTT.Venus.TopicPrefix, + GuideCompat: conf.MQTT.Venus.GuideCompat, + }, Username: conf.MQTT.Username, Password: conf.MQTT.Password, } diff --git a/custom_components/victron_mk2_mqtt/__init__.py b/custom_components/victron_mk2_mqtt/__init__.py index bcd61ce..81e48ce 100644 --- a/custom_components/victron_mk2_mqtt/__init__.py +++ b/custom_components/victron_mk2_mqtt/__init__.py @@ -12,21 +12,32 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import ( + ATTR_ESS_MAX_CHARGE_POWER, + ATTR_ESS_MAX_DISCHARGE_POWER, + ATTR_ESS_MODE, + ATTR_ESS_SETPOINT, ATTR_CURRENT_LIMIT, ATTR_MODE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, CONF_STATUS_TOPIC, CONF_TOPIC_ROOT, + CONF_VENUS_GUIDE_COMPAT, + CONF_VENUS_PORTAL_ID, + CONF_VENUS_TOPIC_PREFIX, DATA_BRIDGE, DEFAULT_COMMAND_TOPIC, DEFAULT_NAME, DEFAULT_STATE_TOPIC, DEFAULT_STATUS_TOPIC, DEFAULT_TOPIC_ROOT, + DEFAULT_VENUS_GUIDE_COMPAT, + DEFAULT_VENUS_PORTAL_ID, + DEFAULT_VENUS_TOPIC_PREFIX, DOMAIN, PANEL_MODES, PLATFORMS, + SERVICE_SET_ESS_CONTROL, SERVICE_SET_REMOTE_PANEL_STATE, ) from .coordinator import VictronMqttBridge @@ -42,6 +53,9 @@ CONFIG_SCHEMA = vol.Schema( ): cv.string, vol.Optional(CONF_STATUS_TOPIC, default=DEFAULT_STATUS_TOPIC): cv.string, vol.Optional(CONF_TOPIC_ROOT): cv.string, + vol.Optional(CONF_VENUS_PORTAL_ID, default=DEFAULT_VENUS_PORTAL_ID): cv.string, + vol.Optional(CONF_VENUS_TOPIC_PREFIX, default=DEFAULT_VENUS_TOPIC_PREFIX): cv.string, + vol.Optional(CONF_VENUS_GUIDE_COMPAT, default=DEFAULT_VENUS_GUIDE_COMPAT): cv.boolean, } ) }, @@ -56,6 +70,16 @@ SERVICE_SET_REMOTE_PANEL_STATE_SCHEMA = vol.Schema( extra=vol.PREVENT_EXTRA, ) +SERVICE_SET_ESS_CONTROL_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ESS_SETPOINT): vol.Coerce(float), + vol.Optional(ATTR_ESS_MAX_CHARGE_POWER): vol.Coerce(float), + vol.Optional(ATTR_ESS_MAX_DISCHARGE_POWER): vol.Coerce(float), + vol.Optional(ATTR_ESS_MODE): vol.Coerce(int), + }, + extra=vol.PREVENT_EXTRA, +) + def mqtt_topic_root(topic: str) -> str: """Match invertergui MQTT root behavior.""" @@ -123,3 +147,40 @@ async def _register_services(hass: HomeAssistant, bridge: VictronMqttBridge) -> handle_set_remote_panel_state, schema=SERVICE_SET_REMOTE_PANEL_STATE_SCHEMA, ) + + async def handle_set_ess_control(call: ServiceCall) -> None: + setpoint = call.data.get(ATTR_ESS_SETPOINT) + max_charge = call.data.get(ATTR_ESS_MAX_CHARGE_POWER) + max_discharge = call.data.get(ATTR_ESS_MAX_DISCHARGE_POWER) + ess_mode = call.data.get(ATTR_ESS_MODE) + + if all(value is None for value in (setpoint, max_charge, max_discharge, ess_mode)): + raise HomeAssistantError( + "Provide at least one of ess_setpoint, ess_max_charge_power, ess_max_discharge_power, or ess_mode" + ) + if max_charge is not None and max_charge < 0: + raise HomeAssistantError("ess_max_charge_power must be >= 0") + if max_discharge is not None and max_discharge < 0: + raise HomeAssistantError("ess_max_discharge_power must be >= 0") + if ess_mode is not None and ess_mode not in (9, 10): + raise HomeAssistantError("ess_mode must be 9 or 10") + + commands: list[dict[str, Any]] = [] + if setpoint is not None: + commands.append({"kind": "ess_setpoint", "value": float(setpoint)}) + if max_charge is not None: + commands.append({"kind": "ess_max_charge_power", "value": float(max_charge)}) + if max_discharge is not None: + commands.append({"kind": "ess_max_discharge_power", "value": float(max_discharge)}) + if ess_mode is not None: + commands.append({"kind": "ess_mode", "value": int(ess_mode)}) + + for payload in commands: + await bridge.async_publish_command(payload) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_ESS_CONTROL, + handle_set_ess_control, + schema=SERVICE_SET_ESS_CONTROL_SCHEMA, + ) diff --git a/custom_components/victron_mk2_mqtt/const.py b/custom_components/victron_mk2_mqtt/const.py index 6c12783..cfc32aa 100644 --- a/custom_components/victron_mk2_mqtt/const.py +++ b/custom_components/victron_mk2_mqtt/const.py @@ -9,12 +9,18 @@ CONF_COMMAND_TOPIC = "command_topic" CONF_STATUS_TOPIC = "status_topic" CONF_TOPIC_ROOT = "topic_root" CONF_NAME = "name" +CONF_VENUS_PORTAL_ID = "venus_portal_id" +CONF_VENUS_TOPIC_PREFIX = "venus_topic_prefix" +CONF_VENUS_GUIDE_COMPAT = "venus_guide_compat" DEFAULT_STATE_TOPIC = "invertergui/updates" DEFAULT_COMMAND_TOPIC = "invertergui/settings/set" DEFAULT_STATUS_TOPIC = "invertergui/settings/status" DEFAULT_TOPIC_ROOT = "invertergui" DEFAULT_NAME = "Victron Inverter" +DEFAULT_VENUS_PORTAL_ID = "invertergui" +DEFAULT_VENUS_TOPIC_PREFIX = "" +DEFAULT_VENUS_GUIDE_COMPAT = True PLATFORMS = ("sensor", "binary_sensor", "select", "number", "switch") @@ -22,8 +28,13 @@ DATA_BRIDGE = "bridge" ATTR_MODE = "mode" ATTR_CURRENT_LIMIT = "current_limit" +ATTR_ESS_SETPOINT = "ess_setpoint" +ATTR_ESS_MAX_CHARGE_POWER = "ess_max_charge_power" +ATTR_ESS_MAX_DISCHARGE_POWER = "ess_max_discharge_power" +ATTR_ESS_MODE = "ess_mode" SERVICE_SET_REMOTE_PANEL_STATE = "set_remote_panel_state" +SERVICE_SET_ESS_CONTROL = "set_ess_control" PANEL_MODE_CHARGER_ONLY = "charger_only" PANEL_MODE_INVERTER_ONLY = "inverter_only" diff --git a/custom_components/victron_mk2_mqtt/coordinator.py b/custom_components/victron_mk2_mqtt/coordinator.py index b4ce95d..b6f2304 100644 --- a/custom_components/victron_mk2_mqtt/coordinator.py +++ b/custom_components/victron_mk2_mqtt/coordinator.py @@ -18,6 +18,9 @@ from .const import ( CONF_STATE_TOPIC, CONF_STATUS_TOPIC, CONF_TOPIC_ROOT, + CONF_VENUS_GUIDE_COMPAT, + CONF_VENUS_PORTAL_ID, + CONF_VENUS_TOPIC_PREFIX, DOMAIN, PANEL_MODES, ) @@ -36,6 +39,9 @@ class VictronMqttBridge: self.command_topic: str = config[CONF_COMMAND_TOPIC] self.status_topic: str = config[CONF_STATUS_TOPIC] self.topic_root: str = config[CONF_TOPIC_ROOT] + self.venus_portal_id: str = config[CONF_VENUS_PORTAL_ID] + self.venus_topic_prefix: str = config[CONF_VENUS_TOPIC_PREFIX] + self.venus_guide_compat: bool = bool(config[CONF_VENUS_GUIDE_COMPAT]) self.panel_mode_state_topic = f"{self.topic_root}/homeassistant/remote_panel_mode/state" self.current_limit_state_topic = ( @@ -48,10 +54,23 @@ class VictronMqttBridge: self.current_limit: float | None = None self.standby: bool | None = None self.last_error: str | None = None + self.ess_setpoint: float | None = None + self.ess_max_charge_power: float | None = None + self.ess_max_discharge_power: float | None = None + self.ess_mode: int | None = None self._listeners: set[Callable[[], None]] = set() self._unsubscribers: list[Callable[[], None]] = [] + venus_base = f"N/{self.venus_portal_id}/settings/0/Settings/CGwacs" + prefix = self.venus_topic_prefix.strip().strip("/") + if prefix: + venus_base = f"{prefix}/{venus_base}" + self.ess_setpoint_state_topic = f"{venus_base}/AcPowerSetPoint" + self.ess_max_charge_state_topic = f"{venus_base}/MaxChargePower" + self.ess_max_discharge_state_topic = f"{venus_base}/MaxDischargePower" + self.ess_mode_state_topic = f"{venus_base}/BatteryLife/State" + @property def device_info(self) -> DeviceInfo: """Return shared Home Assistant device metadata.""" @@ -96,6 +115,39 @@ class VictronMqttBridge: self.hass, self.standby_state_topic, self._handle_standby_message, qos=1 ) ) + if self.venus_guide_compat: + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, + self.ess_setpoint_state_topic, + self._handle_ess_setpoint_message, + qos=1, + ) + ) + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, + self.ess_max_charge_state_topic, + self._handle_ess_max_charge_message, + qos=1, + ) + ) + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, + self.ess_max_discharge_state_topic, + self._handle_ess_max_discharge_message, + qos=1, + ) + ) + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, + self.ess_mode_state_topic, + self._handle_ess_mode_message, + qos=1, + ) + ) if self.status_topic: self._unsubscribers.append( await mqtt.async_subscribe( @@ -204,6 +256,55 @@ class VictronMqttBridge: self.last_error = None self._notify_listeners() + @callback + def _handle_ess_setpoint_message(self, msg: Any) -> None: + value = self._decode_venus_numeric(msg.payload) + if value is None: + return + self.ess_setpoint = value + self._notify_listeners() + + @callback + def _handle_ess_max_charge_message(self, msg: Any) -> None: + value = self._decode_venus_numeric(msg.payload) + if value is None: + return + self.ess_max_charge_power = value + self._notify_listeners() + + @callback + def _handle_ess_max_discharge_message(self, msg: Any) -> None: + value = self._decode_venus_numeric(msg.payload) + if value is None: + return + self.ess_max_discharge_power = value + self._notify_listeners() + + @callback + def _handle_ess_mode_message(self, msg: Any) -> None: + value = self._decode_venus_numeric(msg.payload) + if value is None: + return + self.ess_mode = int(value) + self._notify_listeners() + + def _decode_venus_numeric(self, payload: Any) -> float | None: + raw_payload = self._payload_text(payload) + try: + data = json.loads(raw_payload) + except json.JSONDecodeError: + _LOGGER.debug("Ignoring invalid Venus payload %r", raw_payload) + return None + + value = data.get("value") if isinstance(data, dict) else None + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + _LOGGER.debug("Ignoring non-numeric Venus payload value %r", value) + return None + async def async_publish_command(self, payload: dict[str, Any]) -> None: """Publish a control command payload to invertergui command topic.""" if not self.command_topic: diff --git a/custom_components/victron_mk2_mqtt/number.py b/custom_components/victron_mk2_mqtt/number.py index ddcc36b..95016c7 100644 --- a/custom_components/victron_mk2_mqtt/number.py +++ b/custom_components/victron_mk2_mqtt/number.py @@ -21,7 +21,16 @@ async def async_setup_platform( ) -> None: """Set up Victron number entities.""" bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE] - async_add_entities([VictronRemotePanelCurrentLimitNumber(bridge)]) + entities: list[NumberEntity] = [VictronRemotePanelCurrentLimitNumber(bridge)] + if bridge.venus_guide_compat: + entities.extend( + [ + VictronESSGridSetpointNumber(bridge), + VictronESSMaxChargePowerNumber(bridge), + VictronESSMaxDischargePowerNumber(bridge), + ] + ) + async_add_entities(entities) class VictronRemotePanelCurrentLimitNumber(VictronMqttEntity, NumberEntity): @@ -52,3 +61,75 @@ class VictronRemotePanelCurrentLimitNumber(VictronMqttEntity, NumberEntity): await self.bridge.async_publish_command( {"kind": "panel_state", "current_limit": float(value)} ) + + +class _VictronESSNumberBase(VictronMqttEntity, NumberEntity): + """Base class for ESS compatibility numbers.""" + + _attr_mode = NumberMode.BOX + _attr_native_step = 1.0 + _attr_native_min_value = -20000.0 + _attr_native_max_value = 20000.0 + _attr_native_unit_of_measurement = "W" + _attr_icon = "mdi:transmission-tower-export" + + @property + def available(self) -> bool: + return bool(self.bridge.command_topic and self.bridge.venus_guide_compat) + + +class VictronESSGridSetpointNumber(_VictronESSNumberBase): + """Guide-compatible ESS AC power setpoint.""" + + _attr_name = "ESS Grid Setpoint" + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_ess_grid_setpoint" + + @property + def native_value(self) -> float | None: + return self.bridge.ess_setpoint + + async def async_set_native_value(self, value: float) -> None: + await self.bridge.async_publish_command({"kind": "ess_setpoint", "value": float(value)}) + + +class VictronESSMaxChargePowerNumber(_VictronESSNumberBase): + """Guide-compatible ESS max charge power.""" + + _attr_name = "ESS Max Charge Power" + _attr_native_min_value = 0.0 + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_ess_max_charge_power" + + @property + def native_value(self) -> float | None: + return self.bridge.ess_max_charge_power + + async def async_set_native_value(self, value: float) -> None: + await self.bridge.async_publish_command( + {"kind": "ess_max_charge_power", "value": float(value)} + ) + + +class VictronESSMaxDischargePowerNumber(_VictronESSNumberBase): + """Guide-compatible ESS max discharge power.""" + + _attr_name = "ESS Max Discharge Power" + _attr_native_min_value = 0.0 + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_ess_max_discharge_power" + + @property + def native_value(self) -> float | None: + return self.bridge.ess_max_discharge_power + + async def async_set_native_value(self, value: float) -> None: + await self.bridge.async_publish_command( + {"kind": "ess_max_discharge_power", "value": float(value)} + ) diff --git a/custom_components/victron_mk2_mqtt/services.yaml b/custom_components/victron_mk2_mqtt/services.yaml index 54e2a8b..479c6e0 100644 --- a/custom_components/victron_mk2_mqtt/services.yaml +++ b/custom_components/victron_mk2_mqtt/services.yaml @@ -25,3 +25,51 @@ set_remote_panel_state: step: 0.1 unit_of_measurement: A mode: box + +set_ess_control: + name: Set ESS Control + description: Set ESS-style control values compatible with guide CGwacs paths. + fields: + ess_setpoint: + name: ESS Setpoint + description: AC power setpoint in watts. Positive charges/imports, negative discharges/exports. + required: false + selector: + number: + min: -20000 + max: 20000 + step: 1 + unit_of_measurement: W + mode: box + ess_max_charge_power: + name: ESS Max Charge Power + description: Maximum allowed charge/import power in watts. + required: false + selector: + number: + min: 0 + max: 20000 + step: 1 + unit_of_measurement: W + mode: box + ess_max_discharge_power: + name: ESS Max Discharge Power + description: Maximum allowed discharge/export power in watts. + required: false + selector: + number: + min: 0 + max: 20000 + step: 1 + unit_of_measurement: W + mode: box + ess_mode: + name: ESS Mode + description: ESS battery life mode value (10 optimized, 9 keep charged). + required: false + selector: + number: + min: 9 + max: 10 + step: 1 + mode: box diff --git a/custom_components/victron_mk2_mqtt/switch.py b/custom_components/victron_mk2_mqtt/switch.py index e6b0835..9c110f4 100644 --- a/custom_components/victron_mk2_mqtt/switch.py +++ b/custom_components/victron_mk2_mqtt/switch.py @@ -20,7 +20,10 @@ async def async_setup_platform( ) -> None: """Set up Victron switch entities.""" bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE] - async_add_entities([VictronRemotePanelStandbySwitch(bridge)]) + entities: list[SwitchEntity] = [VictronRemotePanelStandbySwitch(bridge)] + if bridge.venus_guide_compat: + entities.append(VictronESSOptimizedModeSwitch(bridge)) + async_add_entities(entities) class VictronRemotePanelStandbySwitch(VictronMqttEntity, SwitchEntity): @@ -46,3 +49,28 @@ class VictronRemotePanelStandbySwitch(VictronMqttEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: await self.bridge.async_publish_command({"kind": "standby", "standby": False}) + + +class VictronESSOptimizedModeSwitch(VictronMqttEntity, SwitchEntity): + """Guide-compatible ESS optimized mode switch.""" + + _attr_name = "ESS Optimized Mode" + _attr_icon = "mdi:battery-sync" + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_ess_optimized_mode" + + @property + def is_on(self) -> bool: + return self.bridge.ess_mode == 10 + + @property + def available(self) -> bool: + return bool(self.bridge.command_topic and self.bridge.venus_guide_compat) + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.bridge.async_publish_command({"kind": "ess_mode", "value": 10}) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.bridge.async_publish_command({"kind": "ess_mode", "value": 9}) diff --git a/homeassistant/dashboards/invertergui_mqtt_dashboard.yaml b/homeassistant/dashboards/invertergui_mqtt_dashboard.yaml index 7abe1f0..2ee650b 100644 --- a/homeassistant/dashboards/invertergui_mqtt_dashboard.yaml +++ b/homeassistant/dashboards/invertergui_mqtt_dashboard.yaml @@ -45,6 +45,18 @@ views: name: Target Standby - entity: script.victron_mqtt_set_remote_panel_standby name: Apply Standby + - type: entities + title: ESS Guide-Style Control (MQTT) + show_header_toggle: false + entities: + - entity: number.victron_ess_grid_setpoint + name: Grid Setpoint (W) + - entity: number.victron_ess_max_charge_power + name: Max Charge Power (W) + - entity: number.victron_ess_max_discharge_power + name: Max Discharge Power (W) + - entity: switch.victron_ess_optimized_mode + name: Optimized Mode (10 on / 9 off) - type: grid columns: 3 diff --git a/homeassistant/packages/invertergui_mqtt.yaml b/homeassistant/packages/invertergui_mqtt.yaml index 6cb10ac..573f414 100644 --- a/homeassistant/packages/invertergui_mqtt.yaml +++ b/homeassistant/packages/invertergui_mqtt.yaml @@ -4,6 +4,11 @@ # Requirements: # - invertergui started with MQTT publishing enabled. # - invertergui MQTT discovery disabled (`--mqtt.ha.enabled=false`) when using this package. +# - for guide-style ESS controls below, enable: +# `MQTT_VENUS_ENABLED=true` +# `MQTT_VENUS_GUIDE_COMPAT=true` +# `MQTT_VENUS_TOPIC_PREFIX=victron` +# `MQTT_VENUS_PORTAL_ID=invertergui` mqtt: sensor: @@ -205,6 +210,42 @@ mqtt: max: 100 step: 0.1 icon: mdi:current-ac + - name: Victron ESS Grid Setpoint + unique_id: invertergui_mqtt_ess_grid_setpoint + state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/AcPowerSetPoint + value_template: "{{ value_json.value | float(0) }}" + command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/AcPowerSetPoint + command_template: '{"value":{{ value | float(0) | round(0) }}}' + unit_of_measurement: W + mode: box + min: -20000 + max: 20000 + step: 1 + icon: mdi:transmission-tower-export + - name: Victron ESS Max Charge Power + unique_id: invertergui_mqtt_ess_max_charge_power + state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/MaxChargePower + value_template: "{{ value_json.value | float(0) }}" + command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/MaxChargePower + command_template: '{"value":{{ value | float(0) | round(0) }}}' + unit_of_measurement: W + mode: box + min: 0 + max: 20000 + step: 1 + icon: mdi:battery-plus + - name: Victron ESS Max Discharge Power + unique_id: invertergui_mqtt_ess_max_discharge_power + state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/MaxDischargePower + value_template: "{{ value_json.value | float(0) }}" + command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/MaxDischargePower + command_template: '{"value":{{ value | float(0) | round(0) }}}' + unit_of_measurement: W + mode: box + min: 0 + max: 20000 + step: 1 + icon: mdi:battery-minus switch: - name: Victron Remote Panel Standby @@ -216,6 +257,16 @@ mqtt: state_on: "ON" state_off: "OFF" icon: mdi:power-sleep + - name: Victron ESS Optimized Mode + unique_id: invertergui_mqtt_ess_optimized_mode + state_topic: victron/N/invertergui/settings/0/Settings/CGwacs/BatteryLife/State + value_template: "{{ value_json.value | int(9) }}" + command_topic: victron/W/invertergui/settings/0/Settings/CGwacs/BatteryLife/State + payload_on: '{"value":10}' + payload_off: '{"value":9}' + state_on: "10" + state_off: "9" + icon: mdi:battery-sync input_select: victron_remote_panel_mode_target: diff --git a/mk2driver/managed_writer.go b/mk2driver/managed_writer.go new file mode 100644 index 0000000..071d30c --- /dev/null +++ b/mk2driver/managed_writer.go @@ -0,0 +1,194 @@ +package mk2driver + +import ( + "errors" + "fmt" + "sync" + "time" +) + +type WriterProfile string + +const ( + WriterProfileNormal WriterProfile = "normal" + WriterProfileMaintenance WriterProfile = "maintenance" + WriterProfileReadOnly WriterProfile = "read_only" +) + +type WriterPolicy struct { + Profile WriterProfile + MaxCurrentLimitA *float64 + ModeChangeMinInterval time.Duration + LockoutWindow time.Duration +} + +type CommandEvent struct { + Timestamp time.Time `json:"timestamp"` + Source CommandSource `json:"source"` + Kind string `json:"kind"` + Allowed bool `json:"allowed"` + Error string `json:"error,omitempty"` +} + +type ManagedWriter struct { + writer SettingsWriter + policy WriterPolicy + + mu sync.Mutex + lastModeChange time.Time + lockoutUntil time.Time + events []CommandEvent +} + +var _ SettingsWriter = (*ManagedWriter)(nil) +var _ SourceAwareSettingsWriter = (*ManagedWriter)(nil) + +func NewManagedWriter(writer SettingsWriter, policy WriterPolicy) *ManagedWriter { + if policy.Profile == "" { + policy.Profile = WriterProfileNormal + } + return &ManagedWriter{ + writer: writer, + policy: policy, + events: make([]CommandEvent, 0, 100), + } +} + +func (m *ManagedWriter) WriteRAMVar(id uint16, value int16) error { + return m.WriteRAMVarWithSource(CommandSourceUnknown, id, value) +} + +func (m *ManagedWriter) WriteSetting(id uint16, value int16) error { + return m.WriteSettingWithSource(CommandSourceUnknown, id, value) +} + +func (m *ManagedWriter) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error { + return m.SetPanelStateWithSource(CommandSourceUnknown, switchState, currentLimitA) +} + +func (m *ManagedWriter) SetStandby(enabled bool) error { + return m.SetStandbyWithSource(CommandSourceUnknown, enabled) +} + +func (m *ManagedWriter) WriteRAMVarWithSource(source CommandSource, id uint16, value int16) error { + return m.apply(source, "write_ram_var", func() error { + if err := m.ensureProfileAllows("write_ram_var"); err != nil { + return err + } + return m.baseWriter().WriteRAMVar(id, value) + }) +} + +func (m *ManagedWriter) WriteSettingWithSource(source CommandSource, id uint16, value int16) error { + return m.apply(source, "write_setting", func() error { + if err := m.ensureProfileAllows("write_setting"); err != nil { + return err + } + return m.baseWriter().WriteSetting(id, value) + }) +} + +func (m *ManagedWriter) SetPanelStateWithSource(source CommandSource, switchState PanelSwitchState, currentLimitA *float64) error { + return m.apply(source, "set_panel_state", func() error { + if err := m.ensureProfileAllows("set_panel_state"); err != nil { + return err + } + if m.policy.MaxCurrentLimitA != nil && currentLimitA != nil && *currentLimitA > *m.policy.MaxCurrentLimitA { + return fmt.Errorf("current limit %.2fA exceeds configured policy max %.2fA", *currentLimitA, *m.policy.MaxCurrentLimitA) + } + if m.policy.Profile == WriterProfileMaintenance && switchState != PanelSwitchOff { + return errors.New("maintenance profile only allows panel switch off") + } + if m.policy.ModeChangeMinInterval > 0 && !m.lastModeChange.IsZero() && time.Since(m.lastModeChange) < m.policy.ModeChangeMinInterval { + return fmt.Errorf("mode change denied due to rate limit; wait %s", m.policy.ModeChangeMinInterval-time.Since(m.lastModeChange)) + } + if err := m.baseWriter().SetPanelState(switchState, currentLimitA); err != nil { + return err + } + m.lastModeChange = time.Now().UTC() + return nil + }) +} + +func (m *ManagedWriter) SetStandbyWithSource(source CommandSource, enabled bool) error { + return m.apply(source, "set_standby", func() error { + if err := m.ensureProfileAllows("set_standby"); err != nil { + return err + } + return m.baseWriter().SetStandby(enabled) + }) +} + +func (m *ManagedWriter) History(limit int) []CommandEvent { + m.mu.Lock() + defer m.mu.Unlock() + + if limit <= 0 || limit > len(m.events) { + limit = len(m.events) + } + out := make([]CommandEvent, limit) + if limit > 0 { + copy(out, m.events[len(m.events)-limit:]) + } + return out +} + +func (m *ManagedWriter) apply(source CommandSource, kind string, fn func() error) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.writer == nil { + err := errors.New("settings writer is not available") + m.recordLocked(source, kind, false, err) + return err + } + if m.policy.LockoutWindow > 0 && time.Now().UTC().Before(m.lockoutUntil) { + err := fmt.Errorf("command denied during lockout window until %s", m.lockoutUntil.Format(time.RFC3339)) + m.recordLocked(source, kind, false, err) + return err + } + + if err := fn(); err != nil { + m.recordLocked(source, kind, false, err) + return err + } + if m.policy.LockoutWindow > 0 { + m.lockoutUntil = time.Now().UTC().Add(m.policy.LockoutWindow) + } + m.recordLocked(source, kind, true, nil) + return nil +} + +func (m *ManagedWriter) ensureProfileAllows(kind string) error { + switch m.policy.Profile { + case WriterProfileReadOnly: + return errors.New("write denied by read-only profile") + case WriterProfileMaintenance: + if kind == "set_standby" || kind == "set_panel_state" { + return nil + } + return fmt.Errorf("maintenance profile blocks %s", kind) + default: + return nil + } +} + +func (m *ManagedWriter) recordLocked(source CommandSource, kind string, allowed bool, err error) { + event := CommandEvent{ + Timestamp: time.Now().UTC(), + Source: source, + Kind: kind, + Allowed: allowed, + } + if err != nil { + event.Error = err.Error() + } + m.events = append(m.events, event) + if len(m.events) > 100 { + m.events = m.events[len(m.events)-100:] + } +} + +func (m *ManagedWriter) baseWriter() SettingsWriter { + return m.writer +} diff --git a/mk2driver/managed_writer_test.go b/mk2driver/managed_writer_test.go new file mode 100644 index 0000000..1e2c760 --- /dev/null +++ b/mk2driver/managed_writer_test.go @@ -0,0 +1,79 @@ +package mk2driver + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type writerStub struct { + settingWrites int + ramWrites int + panelWrites int + standbyWrites int +} + +func (w *writerStub) WriteRAMVar(id uint16, value int16) error { + w.ramWrites++ + return nil +} + +func (w *writerStub) WriteSetting(id uint16, value int16) error { + w.settingWrites++ + return nil +} + +func (w *writerStub) SetPanelState(switchState PanelSwitchState, currentLimitA *float64) error { + w.panelWrites++ + return nil +} + +func (w *writerStub) SetStandby(enabled bool) error { + w.standbyWrites++ + return nil +} + +func TestManagedWriterReadOnlyProfile(t *testing.T) { + base := &writerStub{} + managed := NewManagedWriter(base, WriterPolicy{ + Profile: WriterProfileReadOnly, + }) + + err := managed.WriteSettingWithSource(CommandSourceMQTT, 1, 1) + assert.Error(t, err) + assert.Equal(t, 0, base.settingWrites) + history := managed.History(10) + if assert.Len(t, history, 1) { + assert.False(t, history[0].Allowed) + assert.Equal(t, CommandSourceMQTT, history[0].Source) + } +} + +func TestManagedWriterCurrentLimitGuard(t *testing.T) { + base := &writerStub{} + max := 16.0 + managed := NewManagedWriter(base, WriterPolicy{ + Profile: WriterProfileNormal, + MaxCurrentLimitA: &max, + }) + + limit := 20.0 + err := managed.SetPanelStateWithSource(CommandSourceUI, PanelSwitchOn, &limit) + assert.Error(t, err) + assert.Equal(t, 0, base.panelWrites) +} + +func TestManagedWriterModeRateLimit(t *testing.T) { + base := &writerStub{} + managed := NewManagedWriter(base, WriterPolicy{ + Profile: WriterProfileNormal, + ModeChangeMinInterval: 10 * time.Second, + }) + + err := managed.SetPanelStateWithSource(CommandSourceAutomation, PanelSwitchOn, nil) + assert.NoError(t, err) + err = managed.SetPanelStateWithSource(CommandSourceAutomation, PanelSwitchOff, nil) + assert.Error(t, err) + assert.Equal(t, 1, base.panelWrites) +} diff --git a/mk2driver/metadata.go b/mk2driver/metadata.go new file mode 100644 index 0000000..546f0a8 --- /dev/null +++ b/mk2driver/metadata.go @@ -0,0 +1,332 @@ +package mk2driver + +import ( + "fmt" + "math" + "sort" + "time" +) + +const ( + defaultTransactionRetries = 2 + defaultTransactionRetryDelay = 200 * time.Millisecond + defaultTransactionBackoff = 1.5 + fastCommandTimeout = 1500 * time.Millisecond + standardCommandTimeout = 3 * time.Second + slowCommandTimeout = 6 * time.Second +) + +type registerKey struct { + kind RegisterKind + id uint16 +} + +func int16Ptr(v int16) *int16 { + return &v +} + +var knownRegisterMetadata = map[registerKey]RegisterMetadata{ + {kind: RegisterKindRAMVar, id: ramVarVMains}: { + Kind: RegisterKindRAMVar, + ID: ramVarVMains, + Name: "mains_voltage", + Description: "AC input mains voltage (scaled)", + Unit: "V", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarIMains}: { + Kind: RegisterKindRAMVar, + ID: ramVarIMains, + Name: "mains_current", + Description: "AC input mains current (scaled)", + Unit: "A", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarVInverter}: { + Kind: RegisterKindRAMVar, + ID: ramVarVInverter, + Name: "inverter_voltage", + Description: "AC output inverter voltage (scaled)", + Unit: "V", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarIInverter}: { + Kind: RegisterKindRAMVar, + ID: ramVarIInverter, + Name: "inverter_current", + Description: "AC output inverter current (scaled)", + Unit: "A", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarVBat}: { + Kind: RegisterKindRAMVar, + ID: ramVarVBat, + Name: "battery_voltage", + Description: "Battery voltage (scaled)", + Unit: "V", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarIBat}: { + Kind: RegisterKindRAMVar, + ID: ramVarIBat, + Name: "battery_current", + Description: "Battery current (scaled)", + Unit: "A", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarVBatRipple}: { + Kind: RegisterKindRAMVar, + ID: ramVarVBatRipple, + Name: "battery_voltage_ripple", + Description: "Battery ripple voltage (scaled)", + Unit: "V", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarInverterPeriod}: { + Kind: RegisterKindRAMVar, + ID: ramVarInverterPeriod, + Name: "inverter_period", + Description: "Inverter period source value", + Unit: "ticks", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarMainPeriod}: { + Kind: RegisterKindRAMVar, + ID: ramVarMainPeriod, + Name: "mains_period", + Description: "Mains period source value", + Unit: "ticks", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarIACLoad}: { + Kind: RegisterKindRAMVar, + ID: ramVarIACLoad, + Name: "ac_load_current", + Description: "AC load current (scaled)", + Unit: "A", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarVirSwitchPos}: { + Kind: RegisterKindRAMVar, + ID: ramVarVirSwitchPos, + Name: "virtual_switch_position", + Description: "Virtual switch position", + Unit: "state", + Scale: 1, + Writable: true, + Signed: false, + MinValue: int16Ptr(0), + MaxValue: int16Ptr(255), + SafetyClass: RegisterSafetyGuarded, + }, + {kind: RegisterKindRAMVar, id: ramVarIgnACInState}: { + Kind: RegisterKindRAMVar, + ID: ramVarIgnACInState, + Name: "ignored_ac_input_state", + Description: "AC input state as seen by firmware", + Unit: "state", + Scale: 1, + Writable: false, + Signed: false, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarMultiFuncRelay}: { + Kind: RegisterKindRAMVar, + ID: ramVarMultiFuncRelay, + Name: "multifunction_relay", + Description: "Multifunction relay state", + Unit: "state", + Scale: 1, + Writable: true, + Signed: false, + MinValue: int16Ptr(0), + MaxValue: int16Ptr(1), + SafetyClass: RegisterSafetyOperational, + }, + {kind: RegisterKindRAMVar, id: ramVarChargeState}: { + Kind: RegisterKindRAMVar, + ID: ramVarChargeState, + Name: "battery_charge_state", + Description: "Battery charge state fraction", + Unit: "fraction", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarInverterPower1}: { + Kind: RegisterKindRAMVar, + ID: ramVarInverterPower1, + Name: "inverter_power_1", + Description: "Inverter power source register 1", + Unit: "W", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarInverterPower2}: { + Kind: RegisterKindRAMVar, + ID: ramVarInverterPower2, + Name: "inverter_power_2", + Description: "Inverter power source register 2", + Unit: "W", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, + {kind: RegisterKindRAMVar, id: ramVarOutPower}: { + Kind: RegisterKindRAMVar, + ID: ramVarOutPower, + Name: "output_power", + Description: "Output power source register", + Unit: "W", + Scale: 1, + Writable: false, + Signed: true, + SafetyClass: RegisterSafetyReadOnly, + }, +} + +func normalizeTransactionOptions(opts TransactionOptions) TransactionOptions { + if opts.Retries < 0 { + opts.Retries = 0 + } + if opts.RetryDelay < 0 { + opts.RetryDelay = 0 + } + if opts.BackoffFactor <= 0 { + opts.BackoffFactor = defaultTransactionBackoff + } + if opts.Retries == 0 && opts.RetryDelay == 0 && !opts.ReadBeforeWrite && !opts.VerifyAfterWrite { + opts.Retries = defaultTransactionRetries + opts.RetryDelay = defaultTransactionRetryDelay + opts.BackoffFactor = defaultTransactionBackoff + opts.ReadBeforeWrite = true + opts.VerifyAfterWrite = true + opts.TimeoutClass = TimeoutClassStandard + return opts + } + if opts.RetryDelay == 0 { + opts.RetryDelay = defaultTransactionRetryDelay + } + if opts.TimeoutClass == "" { + opts.TimeoutClass = TimeoutClassStandard + } + return opts +} + +func lookupRegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool) { + meta, ok := knownRegisterMetadata[registerKey{kind: kind, id: id}] + if ok { + meta = withMetadataDefaults(meta) + } + return meta, ok +} + +func listRegisterMetadata() []RegisterMetadata { + out := make([]RegisterMetadata, 0, len(knownRegisterMetadata)) + for _, meta := range knownRegisterMetadata { + out = append(out, withMetadataDefaults(meta)) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].ID < out[j].ID + }) + return out +} + +func validateValueAgainstMetadata(meta RegisterMetadata, value int16) error { + if meta.MinValue != nil && value < *meta.MinValue { + return fmt.Errorf("value %d is below minimum %d for %s:%d", value, *meta.MinValue, meta.Kind, meta.ID) + } + if meta.MaxValue != nil && value > *meta.MaxValue { + return fmt.Errorf("value %d is above maximum %d for %s:%d", value, *meta.MaxValue, meta.Kind, meta.ID) + } + return nil +} + +func withMetadataDefaults(meta RegisterMetadata) RegisterMetadata { + if meta.Scale == 0 { + meta.Scale = 1 + } + if meta.SafetyClass == "" { + if meta.Writable { + meta.SafetyClass = RegisterSafetyGuarded + } else { + meta.SafetyClass = RegisterSafetyReadOnly + } + } + return meta +} + +func resolveCommandTimeout(opts TransactionOptions) time.Duration { + if opts.CommandTimeout > 0 { + return opts.CommandTimeout + } + switch opts.TimeoutClass { + case TimeoutClassFast: + return fastCommandTimeout + case TimeoutClassSlow: + return slowCommandTimeout + default: + return standardCommandTimeout + } +} + +func retryDelayForAttempt(opts TransactionOptions, attempt int) time.Duration { + if opts.RetryDelay <= 0 || attempt <= 1 { + return opts.RetryDelay + } + factor := math.Pow(opts.BackoffFactor, float64(attempt-1)) + delay := float64(opts.RetryDelay) * factor + return time.Duration(delay) +} + +func defaultWritableRegisterAddresses() []RegisterAddress { + metas := listRegisterMetadata() + out := make([]RegisterAddress, 0, len(metas)) + for _, meta := range metas { + if !meta.Writable { + continue + } + out = append(out, RegisterAddress{ + Kind: meta.Kind, + ID: meta.ID, + }) + } + return out +} diff --git a/mk2driver/mk2.go b/mk2driver/mk2.go index ceeb831..2df9bda 100644 --- a/mk2driver/mk2.go +++ b/mk2driver/mk2.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "math" + "strings" "sync" "sync/atomic" "time" @@ -133,9 +134,22 @@ type mk2Ser struct { stateAck chan struct{} ifaceAck chan byte wg sync.WaitGroup + + diagMu sync.Mutex + traceLimit int + traces []ProtocolTrace + lastFrameAt time.Time + recentErrors []string + + commandTimeouts atomic.Uint64 + commandFailures atomic.Uint64 + checksumErrors atomic.Uint64 } var _ ProtocolControl = (*mk2Ser)(nil) +var _ MetadataControl = (*mk2Ser)(nil) +var _ SnapshotControl = (*mk2Ser)(nil) +var _ DiagnosticsControl = (*mk2Ser)(nil) func parseFrameLength(raw byte) (payloadLength byte, hasLEDStatus bool) { if raw&frameLengthLEDBit != 0 { @@ -175,6 +189,9 @@ func NewMk2Connection(dev io.ReadWriter) (Mk2, error) { mk2.winmonAck = make(chan winmonResponse, 32) mk2.stateAck = make(chan struct{}, 1) mk2.ifaceAck = make(chan byte, 1) + mk2.traceLimit = 200 + mk2.traces = make([]ProtocolTrace, 0, mk2.traceLimit) + mk2.recentErrors = make([]string, 0, 20) mk2.setTarget() mk2.run = make(chan struct{}) mk2.infochan = make(chan *Mk2Info) @@ -270,6 +287,298 @@ func (m *mk2Ser) C() chan *Mk2Info { return m.infochan } +func (m *mk2Ser) RegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool) { + return lookupRegisterMetadata(kind, id) +} + +func (m *mk2Ser) ListRegisterMetadata() []RegisterMetadata { + return listRegisterMetadata() +} + +func (m *mk2Ser) ReadRegister(kind RegisterKind, id uint16) (int16, error) { + m.beginCommand() + defer m.endCommand() + return m.readRegisterLocked(kind, id) +} + +func (m *mk2Ser) WriteRegister(kind RegisterKind, id uint16, value int16, opts TransactionOptions) (RegisterTransactionResult, error) { + options := normalizeTransactionOptions(opts) + commandTimeout := resolveCommandTimeout(options) + start := time.Now() + result := RegisterTransactionResult{ + Kind: kind, + ID: id, + TargetValue: value, + Timeout: commandTimeout, + } + + if meta, ok := lookupRegisterMetadata(kind, id); ok { + if !meta.Writable { + return result, fmt.Errorf("register %s:%d (%s) is marked read-only", kind, id, meta.Name) + } + if err := validateValueAgainstMetadata(meta, value); err != nil { + return result, err + } + } + + m.beginCommand() + defer m.endCommand() + + if options.ReadBeforeWrite { + prev, err := m.readRegisterLockedWithTimeout(kind, id, commandTimeout) + if err != nil { + result.Duration = time.Since(start) + return result, fmt.Errorf("could not read current value for %s:%d: %w", kind, id, err) + } + result.PreviousValue = int16Ptr(prev) + } + + var lastErr error + maxAttempts := options.Retries + 1 + for attempt := 1; attempt <= maxAttempts; attempt++ { + result.Attempts = attempt + + err := m.writeRegisterLockedWithTimeout(kind, id, value, commandTimeout) + if err != nil { + lastErr = err + if attempt < maxAttempts { + delay := retryDelayForAttempt(options, attempt) + if delay > 0 { + time.Sleep(delay) + } + continue + } + break + } + + if !options.VerifyAfterWrite { + result.Duration = time.Since(start) + return result, nil + } + + verified, err := m.readRegisterLockedWithTimeout(kind, id, commandTimeout) + if err != nil { + lastErr = fmt.Errorf("verification read failed for %s:%d: %w", kind, id, err) + if attempt < maxAttempts { + delay := retryDelayForAttempt(options, attempt) + if delay > 0 { + time.Sleep(delay) + } + continue + } + break + } + result.VerifiedValue = int16Ptr(verified) + if verified != value { + lastErr = fmt.Errorf("verification mismatch for %s:%d expected %d got %d", kind, id, value, verified) + if attempt < maxAttempts { + delay := retryDelayForAttempt(options, attempt) + if delay > 0 { + time.Sleep(delay) + } + continue + } + break + } + + result.Duration = time.Since(start) + return result, nil + } + + if lastErr == nil { + lastErr = fmt.Errorf("transaction failed for %s:%d", kind, id) + } + result.Duration = time.Since(start) + return result, lastErr +} + +func (m *mk2Ser) CaptureSnapshot(addresses []RegisterAddress) (RegisterSnapshot, error) { + if len(addresses) == 0 { + addresses = defaultWritableRegisterAddresses() + } + snapshotTime := time.Now().UTC() + snapshot := RegisterSnapshot{ + CapturedAt: snapshotTime, + Entries: make([]RegisterSnapshotEntry, 0, len(addresses)), + } + for _, address := range addresses { + value, err := m.ReadRegister(address.Kind, address.ID) + if err != nil { + return snapshot, fmt.Errorf("capture snapshot read failed for %s:%d: %w", address.Kind, address.ID, err) + } + meta, ok := m.RegisterMetadata(address.Kind, address.ID) + entry := RegisterSnapshotEntry{ + Kind: address.Kind, + ID: address.ID, + Value: value, + CapturedAt: snapshotTime, + } + if ok { + entry.Name = meta.Name + entry.Writable = meta.Writable + entry.Safety = meta.SafetyClass + } + snapshot.Entries = append(snapshot.Entries, entry) + } + return snapshot, nil +} + +func (m *mk2Ser) DiffSnapshot(snapshot RegisterSnapshot) ([]SnapshotDiff, error) { + diffs := make([]SnapshotDiff, 0, len(snapshot.Entries)) + for _, entry := range snapshot.Entries { + current, err := m.ReadRegister(entry.Kind, entry.ID) + if err != nil { + return diffs, fmt.Errorf("snapshot diff read failed for %s:%d: %w", entry.Kind, entry.ID, err) + } + meta, ok := m.RegisterMetadata(entry.Kind, entry.ID) + name := entry.Name + writable := entry.Writable + safety := entry.Safety + if ok { + name = meta.Name + writable = meta.Writable + safety = meta.SafetyClass + } + diff := SnapshotDiff{ + Kind: entry.Kind, + ID: entry.ID, + Name: name, + Current: current, + Target: entry.Value, + Changed: current != entry.Value, + Writable: writable, + Safety: safety, + DiffValue: int32(entry.Value) - int32(current), + } + diffs = append(diffs, diff) + } + return diffs, nil +} + +func (m *mk2Ser) RestoreSnapshot(snapshot RegisterSnapshot, opts TransactionOptions) (SnapshotRestoreResult, error) { + restoreResult := SnapshotRestoreResult{ + Applied: make([]RegisterTransactionResult, 0, len(snapshot.Entries)), + } + diffs, err := m.DiffSnapshot(snapshot) + if err != nil { + return restoreResult, err + } + for _, diff := range diffs { + if !diff.Changed || !diff.Writable { + continue + } + txResult, txErr := m.WriteRegister(diff.Kind, diff.ID, diff.Target, opts) + if txErr == nil { + restoreResult.Applied = append(restoreResult.Applied, txResult) + continue + } + + restoreResult.RolledBack = true + rollbackErrs := m.rollbackAppliedTransactions(restoreResult.Applied, opts) + restoreResult.RollbackErrors = append(restoreResult.RollbackErrors, rollbackErrs...) + return restoreResult, fmt.Errorf("restore failed for %s:%d: %w", diff.Kind, diff.ID, txErr) + } + return restoreResult, nil +} + +func (m *mk2Ser) rollbackAppliedTransactions(applied []RegisterTransactionResult, opts TransactionOptions) []string { + errs := make([]string, 0) + rollbackOpts := normalizeTransactionOptions(TransactionOptions{ + Retries: 1, + RetryDelay: opts.RetryDelay, + BackoffFactor: opts.BackoffFactor, + VerifyAfterWrite: true, + TimeoutClass: opts.TimeoutClass, + CommandTimeout: opts.CommandTimeout, + }) + for i := len(applied) - 1; i >= 0; i-- { + tx := applied[i] + if tx.PreviousValue == nil { + continue + } + if _, err := m.WriteRegister(tx.Kind, tx.ID, *tx.PreviousValue, rollbackOpts); err != nil { + errs = append(errs, fmt.Sprintf("rollback failed for %s:%d: %v", tx.Kind, tx.ID, err)) + } + } + return errs +} + +func (m *mk2Ser) DriverDiagnostics(limit int) DriverDiagnostics { + m.diagMu.Lock() + defer m.diagMu.Unlock() + + traceLimit := limit + if traceLimit <= 0 || traceLimit > len(m.traces) { + traceLimit = len(m.traces) + } + traces := make([]ProtocolTrace, traceLimit) + if traceLimit > 0 { + copy(traces, m.traces[len(m.traces)-traceLimit:]) + } + recentErrors := append([]string(nil), m.recentErrors...) + + diag := DriverDiagnostics{ + GeneratedAt: time.Now().UTC(), + CommandTimeouts: m.commandTimeouts.Load(), + CommandFailures: m.commandFailures.Load(), + ChecksumFailures: m.checksumErrors.Load(), + RecentErrors: recentErrors, + Traces: traces, + } + if !m.lastFrameAt.IsZero() { + last := m.lastFrameAt + diag.LastFrameAt = &last + } + diag.HealthScore = calculateDriverHealthScore(diag) + return diag +} + +func (m *mk2Ser) readRegisterLocked(kind RegisterKind, id uint16) (int16, error) { + return m.readRegisterLockedWithTimeout(kind, id, writeResponseTimeout) +} + +func (m *mk2Ser) readRegisterLockedWithTimeout(kind RegisterKind, id uint16, timeout time.Duration) (int16, error) { + switch kind { + case RegisterKindRAMVar: + return m.readValueByIDWithTimeout(commandReadRAMVar, commandReadRAMResponse, id, timeout) + case RegisterKindSetting: + return m.readValueByIDWithTimeout(commandReadSetting, commandReadSettingResponse, id, timeout) + default: + return 0, fmt.Errorf("unsupported register kind %q", kind) + } +} + +func (m *mk2Ser) writeRegisterLocked(kind RegisterKind, id uint16, value int16) error { + return m.writeRegisterLockedWithTimeout(kind, id, value, writeResponseTimeout) +} + +func (m *mk2Ser) writeRegisterLockedWithTimeout(kind RegisterKind, id uint16, value int16, timeout time.Duration) error { + switch kind { + case RegisterKindRAMVar: + err := m.writeByIDOnlyWithTimeout(commandWriteRAMViaID, commandWriteRAMViaIDResponse, id, value, timeout) + if err != nil && !errors.Is(err, errWriteRejected) { + mk2log.WithFields(logrus.Fields{ + "id": id, + "value": value, + }).WithError(err).Warn("WriteRegister RAM by-id failed, trying legacy write path") + err = m.writeBySelectionWithTimeout(commandWriteRAMVar, commandWriteRAMResponse, id, value, timeout) + } + return err + case RegisterKindSetting: + err := m.writeByIDOnlyWithTimeout(commandWriteViaID, commandWriteViaIDResponse, id, value, timeout) + if err != nil && !errors.Is(err, errWriteRejected) { + mk2log.WithFields(logrus.Fields{ + "id": id, + "value": value, + }).WithError(err).Warn("WriteRegister setting by-id failed, trying legacy write path") + err = m.writeBySelectionWithTimeout(commandWriteSetting, commandWriteSettingResponse, id, value, timeout) + } + return err + default: + return fmt.Errorf("unsupported register kind %q", kind) + } +} + func (m *mk2Ser) WriteRAMVar(id uint16, value int16) error { mk2log.WithFields(logrus.Fields{ "id": id, @@ -340,6 +649,39 @@ func (m *mk2Ser) WriteRAMVarByID(id uint16, value int16) error { return m.writeByIDOnly(commandWriteRAMViaID, commandWriteRAMViaIDResponse, id, value) } +func (m *mk2Ser) WriteSettingBySelection(id uint16, value int16) error { + m.beginCommand() + defer m.endCommand() + return m.writeBySelection(commandWriteSetting, commandWriteSettingResponse, id, value) +} + +func (m *mk2Ser) WriteRAMVarBySelection(id uint16, value int16) error { + m.beginCommand() + defer m.endCommand() + return m.writeBySelection(commandWriteRAMVar, commandWriteRAMResponse, id, value) +} + +func (m *mk2Ser) WriteSelectedData(value int16) error { + m.beginCommand() + defer m.endCommand() + + m.clearWriteResponses() + raw := uint16(value) + m.sendCommandLocked([]byte{ + winmonFrame, + commandWriteData, + byte(raw), + byte(raw >> 8), + }) + _, err := m.waitForAnyWriteResponseWithTimeout([]byte{ + commandWriteRAMResponse, + commandWriteSettingResponse, + commandWriteViaIDResponse, + commandWriteRAMViaIDResponse, + }, writeResponseTimeout) + return err +} + func (m *mk2Ser) GetDeviceState() (DeviceState, error) { m.beginCommand() defer m.endCommand() @@ -552,6 +894,10 @@ func encodePanelCurrentLimit(currentLimitA *float64) (uint16, error) { } func (m *mk2Ser) writeByIDOnly(writeCommand, expectedResponse byte, id uint16, value int16) error { + return m.writeByIDOnlyWithTimeout(writeCommand, expectedResponse, id, value, writeResponseTimeout) +} + +func (m *mk2Ser) writeByIDOnlyWithTimeout(writeCommand, expectedResponse byte, id uint16, value int16, timeout time.Duration) error { m.clearWriteResponses() rawValue := uint16(value) m.sendCommandLocked([]byte{ @@ -562,10 +908,14 @@ func (m *mk2Ser) writeByIDOnly(writeCommand, expectedResponse byte, id uint16, v byte(rawValue), byte(rawValue >> 8), }) - return m.waitForWriteResponse(expectedResponse) + return m.waitForWriteResponseWithTimeout(expectedResponse, timeout) } func (m *mk2Ser) writeBySelection(selectCommand, expectedResponse byte, id uint16, value int16) error { + return m.writeBySelectionWithTimeout(selectCommand, expectedResponse, id, value, writeResponseTimeout) +} + +func (m *mk2Ser) writeBySelectionWithTimeout(selectCommand, expectedResponse byte, id uint16, value int16, timeout time.Duration) error { m.clearWriteResponses() rawValue := uint16(value) m.sendCommandLocked([]byte{ @@ -581,10 +931,14 @@ func (m *mk2Ser) writeBySelection(selectCommand, expectedResponse byte, id uint1 byte(rawValue >> 8), }) - return m.waitForWriteResponse(expectedResponse) + return m.waitForWriteResponseWithTimeout(expectedResponse, timeout) } func (m *mk2Ser) readValueByID(readCommand, expectedResponse byte, id uint16) (int16, error) { + return m.readValueByIDWithTimeout(readCommand, expectedResponse, id, writeResponseTimeout) +} + +func (m *mk2Ser) readValueByIDWithTimeout(readCommand, expectedResponse byte, id uint16, timeout time.Duration) (int16, error) { m.clearWinmonResponses() m.sendCommandLocked([]byte{ winmonFrame, @@ -593,7 +947,7 @@ func (m *mk2Ser) readValueByID(readCommand, expectedResponse byte, id uint16) (i byte(id >> 8), }) - resp, err := m.waitForWinmonResponse(expectedResponse) + resp, err := m.waitForWinmonResponseWithTimeout(expectedResponse, timeout) if err != nil { return 0, err } @@ -670,6 +1024,10 @@ func (m *mk2Ser) clearWriteResponses() { } func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error { + return m.waitForWriteResponseWithTimeout(expectedResponse, writeResponseTimeout) +} + +func (m *mk2Ser) waitForWriteResponseWithTimeout(expectedResponse byte, timeout time.Duration) error { if m.writeAck == nil { return errors.New("write response channel is not initialized") } @@ -684,15 +1042,56 @@ func (m *mk2Ser) waitForWriteResponse(expectedResponse byte) error { case expectedResponse: return nil case commandUnsupportedResponse: + m.noteCommandFailure(fmt.Errorf("received unsupported response 0x%02x", response)) return errCommandUnsupported case commandWriteNotAllowedResponse: + m.noteCommandFailure(fmt.Errorf("received write rejected response 0x%02x", response)) return errWriteRejected default: - return fmt.Errorf("unexpected write response 0x%02x", response) + err := fmt.Errorf("unexpected write response 0x%02x", response) + m.noteCommandFailure(err) + return err } - case <-time.After(writeResponseTimeout): - mk2log.WithField("expected_response", fmt.Sprintf("0x%02x", expectedResponse)).Error("Timed out waiting for write acknowledgement") - return fmt.Errorf("timed out waiting for write response after %s", writeResponseTimeout) + case <-time.After(timeout): + err := fmt.Errorf("timed out waiting for write response after %s", timeout) + mk2log.WithField("expected_response", fmt.Sprintf("0x%02x", expectedResponse)).WithError(err).Error("Timed out waiting for write acknowledgement") + m.noteCommandTimeout(err) + return err + } +} + +func (m *mk2Ser) waitForAnyWriteResponseWithTimeout(expectedResponses []byte, timeout time.Duration) (byte, error) { + if m.writeAck == nil { + return 0, errors.New("write response channel is not initialized") + } + expected := make(map[byte]struct{}, len(expectedResponses)) + for _, response := range expectedResponses { + expected[response] = struct{}{} + } + + select { + case response := <-m.writeAck: + if _, ok := expected[response]; ok { + return response, nil + } + switch response { + case commandUnsupportedResponse: + err := fmt.Errorf("received unsupported write response 0x%02x", response) + m.noteCommandFailure(err) + return response, errCommandUnsupported + case commandWriteNotAllowedResponse: + err := fmt.Errorf("received write rejected response 0x%02x", response) + m.noteCommandFailure(err) + return response, errWriteRejected + default: + err := fmt.Errorf("unexpected write response 0x%02x", response) + m.noteCommandFailure(err) + return response, err + } + case <-time.After(timeout): + err := fmt.Errorf("timed out waiting for write response after %s", timeout) + m.noteCommandTimeout(err) + return 0, err } } @@ -722,11 +1121,15 @@ func (m *mk2Ser) clearWinmonResponses() { } func (m *mk2Ser) waitForWinmonResponse(expectedResponse byte) (winmonResponse, error) { + return m.waitForWinmonResponseWithTimeout(expectedResponse, writeResponseTimeout) +} + +func (m *mk2Ser) waitForWinmonResponseWithTimeout(expectedResponse byte, timeout time.Duration) (winmonResponse, error) { if m.winmonAck == nil { return winmonResponse{}, errors.New("winmon response channel is not initialized") } - timeout := time.After(writeResponseTimeout) + timeoutChan := time.After(timeout) for { select { case response := <-m.winmonAck: @@ -740,8 +1143,10 @@ func (m *mk2Ser) waitForWinmonResponse(expectedResponse byte) (winmonResponse, e case expectedResponse: return response, nil case commandUnsupportedResponse: + m.noteCommandFailure(fmt.Errorf("received unsupported winmon response 0x%02x", response.command)) return winmonResponse{}, errCommandUnsupported case commandWriteNotAllowedResponse: + m.noteCommandFailure(fmt.Errorf("received write rejected winmon response 0x%02x", response.command)) return winmonResponse{}, errWriteRejected default: mk2log.WithFields(logrus.Fields{ @@ -749,8 +1154,10 @@ func (m *mk2Ser) waitForWinmonResponse(expectedResponse byte) (winmonResponse, e "received_response": fmt.Sprintf("0x%02x", response.command), }).Debug("Ignoring unrelated winmon response while waiting") } - case <-timeout: - return winmonResponse{}, fmt.Errorf("timed out waiting for winmon response 0x%02x after %s", expectedResponse, writeResponseTimeout) + case <-timeoutChan: + err := fmt.Errorf("timed out waiting for winmon response 0x%02x after %s", expectedResponse, timeout) + m.noteCommandTimeout(err) + return winmonResponse{}, err } } } @@ -783,6 +1190,10 @@ func (m *mk2Ser) clearStateResponses() { } func (m *mk2Ser) waitForStateResponse() error { + return m.waitForStateResponseWithTimeout(writeResponseTimeout) +} + +func (m *mk2Ser) waitForStateResponseWithTimeout(timeout time.Duration) error { if m.stateAck == nil { return errors.New("panel state response channel is not initialized") } @@ -791,9 +1202,11 @@ func (m *mk2Ser) waitForStateResponse() error { case <-m.stateAck: mk2log.Debug("Received panel state acknowledgement") return nil - case <-time.After(writeResponseTimeout): - mk2log.Error("Timed out waiting for panel state acknowledgement") - return fmt.Errorf("timed out waiting for panel state response after %s", writeResponseTimeout) + case <-time.After(timeout): + err := fmt.Errorf("timed out waiting for panel state response after %s", timeout) + mk2log.WithError(err).Error("Timed out waiting for panel state acknowledgement") + m.noteCommandTimeout(err) + return err } } @@ -823,6 +1236,10 @@ func (m *mk2Ser) clearInterfaceResponses() { } func (m *mk2Ser) waitForInterfaceResponse(expectedStandby bool) error { + return m.waitForInterfaceResponseWithTimeout(expectedStandby, writeResponseTimeout) +} + +func (m *mk2Ser) waitForInterfaceResponseWithTimeout(expectedStandby bool, timeout time.Duration) error { if m.ifaceAck == nil { return errors.New("interface response channel is not initialized") } @@ -836,12 +1253,16 @@ func (m *mk2Ser) waitForInterfaceResponse(expectedStandby bool) error { "actual_standby": standbyEnabled, }).Debug("Received standby interface acknowledgement") if standbyEnabled != expectedStandby { - return fmt.Errorf("unexpected standby line state 0x%02x", lineState) + err := fmt.Errorf("unexpected standby line state 0x%02x", lineState) + m.noteCommandFailure(err) + return err } return nil - case <-time.After(writeResponseTimeout): - mk2log.WithField("expected_standby", expectedStandby).Error("Timed out waiting for standby acknowledgement") - return fmt.Errorf("timed out waiting for standby response after %s", writeResponseTimeout) + case <-time.After(timeout): + err := fmt.Errorf("timed out waiting for standby response after %s", timeout) + mk2log.WithField("expected_standby", expectedStandby).WithError(err).Error("Timed out waiting for standby acknowledgement") + m.noteCommandTimeout(err) + return err } } @@ -874,6 +1295,18 @@ func (m *mk2Ser) addError(err error) { } m.info.Errors = append(m.info.Errors, err) m.info.Valid = false + m.recordError(err) +} + +func (m *mk2Ser) noteCommandTimeout(err error) { + m.commandTimeouts.Add(1) + m.commandFailures.Add(1) + m.recordError(err) +} + +func (m *mk2Ser) noteCommandFailure(err error) { + m.commandFailures.Add(1) + m.recordError(err) } // Updates report. @@ -905,6 +1338,8 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte, appendedLED []byte) { return } if checkChecksum(l, frame[0], frame[1:]) { + m.markFrameSeen() + m.recordRXTrace(frame) switch frame[0] { case bootupFrameHeader: if m.pollPaused.Load() { @@ -988,8 +1423,10 @@ func (m *mk2Ser) handleFrame(l byte, frame []byte, appendedLED []byte) { mk2log.Warnf("[handleFrame] Invalid frame %v", frame[0]) } } else { + m.checksumErrors.Add(1) mk2log.Errorf("[handleFrame] Invalid incoming frame checksum: %x", frame) m.frameLock = false + m.recordError(fmt.Errorf("invalid incoming frame checksum")) } } @@ -1220,13 +1657,127 @@ func (m *mk2Ser) sendCommandLocked(data []byte) { dataOut[l+2] = cr mk2log.Debugf("sendCommand %#v", dataOut) + m.recordTXTrace(dataOut, data) _, err := m.p.Write(dataOut) if err != nil { mk2log.WithError(err).Error("Failed to send MK2 command") m.addError(fmt.Errorf("Write error: %v", err)) + m.noteCommandFailure(err) } } +func (m *mk2Ser) markFrameSeen() { + m.diagMu.Lock() + m.lastFrameAt = time.Now().UTC() + m.diagMu.Unlock() +} + +func (m *mk2Ser) recordError(err error) { + if err == nil { + return + } + m.diagMu.Lock() + defer m.diagMu.Unlock() + m.recentErrors = append(m.recentErrors, err.Error()) + if len(m.recentErrors) > 20 { + m.recentErrors = m.recentErrors[len(m.recentErrors)-20:] + } +} + +func (m *mk2Ser) recordTXTrace(fullFrame []byte, payload []byte) { + command := "" + frame := "" + if len(payload) > 0 { + frame = fmt.Sprintf("0x%02x", payload[0]) + command = decodeTraceCommandName(payload) + } + m.appendTrace(ProtocolTrace{ + Timestamp: time.Now().UTC(), + Direction: TraceDirectionTX, + Frame: frame, + Command: command, + BytesHex: strings.ToUpper(fmt.Sprintf("%X", fullFrame)), + }) +} + +func (m *mk2Ser) recordRXTrace(frame []byte) { + command := "" + frameName := "" + if len(frame) > 0 { + frameName = fmt.Sprintf("0x%02x", frame[0]) + command = decodeTraceCommandName(frame) + } + m.appendTrace(ProtocolTrace{ + Timestamp: time.Now().UTC(), + Direction: TraceDirectionRX, + Frame: frameName, + Command: command, + BytesHex: strings.ToUpper(fmt.Sprintf("%X", frame)), + }) +} + +func decodeTraceCommandName(frame []byte) string { + if len(frame) == 0 { + return "" + } + switch frame[0] { + case winmonFrame: + if len(frame) < 2 { + return "winmon" + } + return fmt.Sprintf("winmon:0x%02x", frame[1]) + case stateFrame: + return "panel_state" + case interfaceFrame: + return "interface" + case infoReqFrame: + return "info_request" + case ledFrame: + return "led_status" + case vFrame: + return "version" + case setTargetFrame: + return "set_target" + default: + if frame[0] == frameHeader && len(frame) > 1 && frame[1] == winmonFrame && len(frame) > 2 { + return fmt.Sprintf("winmon:0x%02x", frame[2]) + } + return "" + } +} + +func (m *mk2Ser) appendTrace(trace ProtocolTrace) { + m.diagMu.Lock() + defer m.diagMu.Unlock() + m.traces = append(m.traces, trace) + limit := m.traceLimit + if limit <= 0 { + limit = 200 + } + if len(m.traces) > limit { + m.traces = m.traces[len(m.traces)-limit:] + } +} + +func calculateDriverHealthScore(diag DriverDiagnostics) int { + score := 100 + score -= int(diag.CommandTimeouts) * 5 + score -= int(diag.CommandFailures) * 2 + score -= int(diag.ChecksumFailures) * 3 + if diag.LastFrameAt == nil { + score -= 10 + } else if time.Since(diag.LastFrameAt.UTC()) > 30*time.Second { + score -= 10 + } + if score < 0 { + return 0 + } + if score > 100 { + return 100 + } + return score +} + // Checks the frame crc. func checkChecksum(l, t byte, d []byte) bool { cr := (uint16(l) + uint16(t)) % 256 diff --git a/mk2driver/mk2_test.go b/mk2driver/mk2_test.go index 4621a57..0b58486 100644 --- a/mk2driver/mk2_test.go +++ b/mk2driver/mk2_test.go @@ -463,6 +463,110 @@ func Test_mk2Ser_WriteRAMVarByID(t *testing.T) { assert.Equal(t, expected, writeBuffer.Bytes()) } +func Test_mk2Ser_RegisterMetadata(t *testing.T) { + m := &mk2Ser{} + + meta, ok := m.RegisterMetadata(RegisterKindRAMVar, ramVarVBat) + assert.True(t, ok) + assert.Equal(t, RegisterKindRAMVar, meta.Kind) + assert.Equal(t, uint16(ramVarVBat), meta.ID) + assert.Equal(t, "battery_voltage", meta.Name) + assert.False(t, meta.Writable) + + _, ok = m.RegisterMetadata(RegisterKindSetting, 9999) + assert.False(t, ok) + + all := m.ListRegisterMetadata() + assert.NotEmpty(t, all) +} + +func Test_mk2Ser_WriteRegister_Verified(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 2), + winmonAck: make(chan winmonResponse, 4), + } + + go func() { + time.Sleep(10 * time.Millisecond) + // Read-before-write response. + m.pushWinmonResponse(commandReadSettingResponse, []byte{0x01, 0x00}) + time.Sleep(10 * time.Millisecond) + // Write acknowledgement. + m.pushWriteResponse(commandWriteViaIDResponse) + time.Sleep(10 * time.Millisecond) + // Verify-after-write response. + m.pushWinmonResponse(commandReadSettingResponse, []byte{0x34, 0x12}) + }() + + result, err := m.WriteRegister(RegisterKindSetting, 0x0042, 0x1234, TransactionOptions{ + ReadBeforeWrite: true, + VerifyAfterWrite: true, + }) + assert.NoError(t, err) + assert.Equal(t, 1, result.Attempts) + if assert.NotNil(t, result.PreviousValue) { + assert.Equal(t, int16(1), *result.PreviousValue) + } + if assert.NotNil(t, result.VerifiedValue) { + assert.Equal(t, int16(0x1234), *result.VerifiedValue) + } + + expected := append([]byte{}, buildSentCommand(winmonFrame, commandReadSetting, 0x42, 0x00)...) + expected = append(expected, buildSentCommand(winmonFrame, commandWriteViaID, 0x42, 0x00, 0x34, 0x12)...) + expected = append(expected, buildSentCommand(winmonFrame, commandReadSetting, 0x42, 0x00)...) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_WriteRegister_VerifyRetry(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 4), + winmonAck: make(chan winmonResponse, 8), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteViaIDResponse) + time.Sleep(10 * time.Millisecond) + // First verify mismatch. + m.pushWinmonResponse(commandReadSettingResponse, []byte{0x00, 0x00}) + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteViaIDResponse) + time.Sleep(10 * time.Millisecond) + // Second verify matches expected value. + m.pushWinmonResponse(commandReadSettingResponse, []byte{0x78, 0x56}) + }() + + result, err := m.WriteRegister(RegisterKindSetting, 0x0042, 0x5678, TransactionOptions{ + Retries: 1, + RetryDelay: 1 * time.Millisecond, + VerifyAfterWrite: true, + }) + assert.NoError(t, err) + assert.Equal(t, 2, result.Attempts) + if assert.NotNil(t, result.VerifiedValue) { + assert.Equal(t, int16(0x5678), *result.VerifiedValue) + } +} + +func Test_mk2Ser_WriteRegister_ReadOnlyMetadata(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 1), + } + + _, err := m.WriteRegister(RegisterKindRAMVar, ramVarVBat, 1, TransactionOptions{ + VerifyAfterWrite: true, + }) + assert.Error(t, err) + assert.ErrorContains(t, err, "read-only") + assert.Empty(t, writeBuffer.Bytes()) +} + func Test_mk2Ser_GetDeviceState(t *testing.T) { testIO := NewIOStub(nil) m := &mk2Ser{ @@ -726,6 +830,149 @@ func Test_mk2Ser_SetStandby_Disabled(t *testing.T) { assert.Equal(t, expected, writeBuffer.Bytes()) } +func Test_mk2Ser_WriteSettingBySelection(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteSettingResponse) + }() + + err := m.WriteSettingBySelection(0x0020, 0x0011) + assert.NoError(t, err) + + expected := append([]byte{}, buildSentCommand(winmonFrame, commandWriteSetting, 0x20, 0x00)...) + expected = append(expected, buildSentCommand(winmonFrame, commandWriteData, 0x11, 0x00)...) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_WriteSelectedData(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + writeAck: make(chan byte, 1), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWriteResponse(commandWriteRAMResponse) + }() + + err := m.WriteSelectedData(0x0022) + assert.NoError(t, err) + + expected := buildSentCommand(winmonFrame, commandWriteData, 0x22, 0x00) + assert.Equal(t, expected, writeBuffer.Bytes()) +} + +func Test_mk2Ser_CaptureAndDiffSnapshot(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 2), + } + + go func() { + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandReadRAMResponse, []byte{0x03, 0x00}) + time.Sleep(10 * time.Millisecond) + m.pushWinmonResponse(commandReadRAMResponse, []byte{0x04, 0x00}) + }() + + addresses := []RegisterAddress{ + {Kind: RegisterKindRAMVar, ID: ramVarVirSwitchPos}, + } + snapshot, err := m.CaptureSnapshot(addresses) + assert.NoError(t, err) + if assert.Len(t, snapshot.Entries, 1) { + assert.Equal(t, RegisterSafetyGuarded, snapshot.Entries[0].Safety) + assert.Equal(t, int16(3), snapshot.Entries[0].Value) + } + + diffs, err := m.DiffSnapshot(snapshot) + assert.NoError(t, err) + if assert.Len(t, diffs, 1) { + assert.True(t, diffs[0].Changed) + assert.Equal(t, int16(4), diffs[0].Current) + assert.Equal(t, int16(3), diffs[0].Target) + } +} + +func Test_mk2Ser_RestoreSnapshot(t *testing.T) { + testIO := NewIOStub(nil) + m := &mk2Ser{ + p: testIO, + winmonAck: make(chan winmonResponse, 4), + writeAck: make(chan byte, 2), + } + + go func() { + time.Sleep(10 * time.Millisecond) + // DiffSnapshot current value. + m.pushWinmonResponse(commandReadRAMResponse, []byte{0x04, 0x00}) + time.Sleep(10 * time.Millisecond) + // WriteRegister read-before-write. + m.pushWinmonResponse(commandReadRAMResponse, []byte{0x04, 0x00}) + time.Sleep(10 * time.Millisecond) + // Write ack. + m.pushWriteResponse(commandWriteRAMViaIDResponse) + time.Sleep(10 * time.Millisecond) + // Verify-after-write. + m.pushWinmonResponse(commandReadRAMResponse, []byte{0x03, 0x00}) + }() + + result, err := m.RestoreSnapshot(RegisterSnapshot{ + Entries: []RegisterSnapshotEntry{ + { + Kind: RegisterKindRAMVar, + ID: ramVarVirSwitchPos, + Value: 3, + Writable: true, + }, + }, + }, TransactionOptions{ + ReadBeforeWrite: true, + VerifyAfterWrite: true, + RetryDelay: 1 * time.Millisecond, + }) + assert.NoError(t, err) + assert.False(t, result.RolledBack) + if assert.Len(t, result.Applied, 1) { + if assert.NotNil(t, result.Applied[0].PreviousValue) { + assert.Equal(t, int16(4), *result.Applied[0].PreviousValue) + } + } +} + +func Test_mk2Ser_DriverDiagnostics(t *testing.T) { + m := &mk2Ser{ + traceLimit: 10, + traces: make([]ProtocolTrace, 0, 10), + } + m.appendTrace(ProtocolTrace{ + Timestamp: time.Now().UTC(), + Direction: TraceDirectionTX, + Frame: "0x57", + Command: "winmon:0x30", + BytesHex: "AA", + }) + m.noteCommandFailure(assert.AnError) + m.noteCommandTimeout(assert.AnError) + m.checksumErrors.Add(1) + m.markFrameSeen() + + diag := m.DriverDiagnostics(10) + assert.NotEmpty(t, diag.Traces) + assert.Greater(t, diag.CommandFailures, uint64(0)) + assert.Greater(t, diag.CommandTimeouts, uint64(0)) + assert.Greater(t, diag.ChecksumFailures, uint64(0)) + assert.GreaterOrEqual(t, diag.HealthScore, 0) +} + func Test_parseFrameLength(t *testing.T) { tests := []struct { name string diff --git a/mk2driver/mk2interface.go b/mk2driver/mk2interface.go index 1a7640c..d0f2873 100644 --- a/mk2driver/mk2interface.go +++ b/mk2driver/mk2interface.go @@ -151,8 +151,202 @@ type ProtocolControl interface { ReadSelected() (int16, error) // ReadRAMVarInfo reads RAM variable metadata via command 0x36. ReadRAMVarInfo(id uint16) (RAMVarInfo, error) + // WriteSelectedData writes to the currently selected register via command 0x34. + WriteSelectedData(value int16) error + // WriteSettingBySelection performs 0x33 (select setting) followed by 0x34 (write data). + WriteSettingBySelection(id uint16, value int16) error + // WriteRAMVarBySelection performs 0x32 (select RAM var) followed by 0x34 (write data). + WriteRAMVarBySelection(id uint16, value int16) error // WriteSettingByID writes a setting via command 0x37. WriteSettingByID(id uint16, value int16) error // WriteRAMVarByID writes a RAM variable via command 0x38. WriteRAMVarByID(id uint16, value int16) error } + +type RegisterKind string + +const ( + RegisterKindSetting RegisterKind = "setting" + RegisterKindRAMVar RegisterKind = "ram_var" +) + +type RegisterSafetyClass string + +const ( + // RegisterSafetyReadOnly indicates no write path should be exposed. + RegisterSafetyReadOnly RegisterSafetyClass = "read_only" + // RegisterSafetyOperational indicates normal runtime write usage is expected. + RegisterSafetyOperational RegisterSafetyClass = "operational" + // RegisterSafetyGuarded indicates writes should be policy-guarded. + RegisterSafetyGuarded RegisterSafetyClass = "guarded" + // RegisterSafetyCritical indicates high-impact settings that need stricter controls. + RegisterSafetyCritical RegisterSafetyClass = "critical" +) + +type TimeoutClass string + +const ( + TimeoutClassFast TimeoutClass = "fast" + TimeoutClassStandard TimeoutClass = "standard" + TimeoutClassSlow TimeoutClass = "slow" +) + +// RegisterMetadata documents known MK2 register IDs and expected value behavior. +type RegisterMetadata struct { + Kind RegisterKind + ID uint16 + Name string + Description string + Unit string + Scale float64 + Writable bool + Signed bool + MinValue *int16 + MaxValue *int16 + SafetyClass RegisterSafetyClass +} + +// TransactionOptions controls retry and verification semantics for safe writes. +type TransactionOptions struct { + // Retries is the number of additional write attempts after the first try. + Retries int + // RetryDelay is slept between retries. Zero uses a sensible default. + RetryDelay time.Duration + // BackoffFactor multiplies retry delay for each additional attempt (1 disables backoff). + BackoffFactor float64 + // ReadBeforeWrite captures previous value before writing when possible. + ReadBeforeWrite bool + // VerifyAfterWrite reads the register back and compares with written value. + VerifyAfterWrite bool + // TimeoutClass applies standard timeout buckets when CommandTimeout is not set. + TimeoutClass TimeoutClass + // CommandTimeout overrides timeout class for each protocol command inside the transaction. + CommandTimeout time.Duration +} + +// RegisterTransactionResult reports details about a transactional register write. +type RegisterTransactionResult struct { + Kind RegisterKind + ID uint16 + TargetValue int16 + PreviousValue *int16 + VerifiedValue *int16 + Attempts int + Timeout time.Duration + Duration time.Duration +} + +// MetadataControl adds register metadata and transactional safety helpers. +type MetadataControl interface { + ProtocolControl + // RegisterMetadata returns metadata for a known register. + RegisterMetadata(kind RegisterKind, id uint16) (RegisterMetadata, bool) + // ListRegisterMetadata returns all known register metadata. + ListRegisterMetadata() []RegisterMetadata + // ReadRegister reads a setting or RAM var by kind and id. + ReadRegister(kind RegisterKind, id uint16) (int16, error) + // WriteRegister performs a safe transactional write with optional retry/verify. + WriteRegister(kind RegisterKind, id uint16, value int16, opts TransactionOptions) (RegisterTransactionResult, error) +} + +type RegisterAddress struct { + Kind RegisterKind `json:"kind"` + ID uint16 `json:"id"` +} + +type RegisterSnapshotEntry struct { + Kind RegisterKind `json:"kind"` + ID uint16 `json:"id"` + Name string `json:"name,omitempty"` + Value int16 `json:"value"` + Writable bool `json:"writable"` + Safety RegisterSafetyClass `json:"safety_class,omitempty"` + CapturedAt time.Time `json:"captured_at"` +} + +type RegisterSnapshot struct { + CapturedAt time.Time `json:"captured_at"` + Entries []RegisterSnapshotEntry `json:"entries"` +} + +type SnapshotDiff struct { + Kind RegisterKind `json:"kind"` + ID uint16 `json:"id"` + Name string `json:"name,omitempty"` + Current int16 `json:"current"` + Target int16 `json:"target"` + Changed bool `json:"changed"` + Writable bool `json:"writable"` + Safety RegisterSafetyClass `json:"safety_class,omitempty"` + DiffValue int32 `json:"diff_value"` +} + +type SnapshotRestoreResult struct { + Applied []RegisterTransactionResult `json:"applied"` + RolledBack bool `json:"rolled_back"` + RollbackErrors []string `json:"rollback_errors,omitempty"` +} + +// SnapshotControl provides register snapshot, diff preview, and rollback-aware restore. +type SnapshotControl interface { + MetadataControl + // CaptureSnapshot reads the provided register list. Empty addresses captures known writable registers. + CaptureSnapshot(addresses []RegisterAddress) (RegisterSnapshot, error) + // DiffSnapshot compares current values against a snapshot. + DiffSnapshot(snapshot RegisterSnapshot) ([]SnapshotDiff, error) + // RestoreSnapshot applies snapshot target values; if restore fails mid-way it attempts rollback. + RestoreSnapshot(snapshot RegisterSnapshot, opts TransactionOptions) (SnapshotRestoreResult, error) +} + +type TraceDirection string + +const ( + TraceDirectionTX TraceDirection = "tx" + TraceDirectionRX TraceDirection = "rx" +) + +type ProtocolTrace struct { + Timestamp time.Time `json:"timestamp"` + Direction TraceDirection `json:"direction"` + Frame string `json:"frame"` + Command string `json:"command,omitempty"` + BytesHex string `json:"bytes_hex"` +} + +type DriverDiagnostics struct { + GeneratedAt time.Time `json:"generated_at"` + HealthScore int `json:"health_score"` + LastFrameAt *time.Time `json:"last_frame_at,omitempty"` + CommandTimeouts uint64 `json:"command_timeouts"` + CommandFailures uint64 `json:"command_failures"` + ChecksumFailures uint64 `json:"checksum_failures"` + RecentErrors []string `json:"recent_errors,omitempty"` + Traces []ProtocolTrace `json:"traces"` +} + +// DiagnosticsControl exposes recent protocol traces and health information for troubleshooting bundles. +type DiagnosticsControl interface { + DriverDiagnostics(limit int) DriverDiagnostics +} + +type CommandSource string + +const ( + CommandSourceUnknown CommandSource = "unknown" + CommandSourceUI CommandSource = "ui" + CommandSourceMQTT CommandSource = "mqtt" + CommandSourceAutomation CommandSource = "automation" +) + +// SourceAwareSettingsWriter accepts source tags for arbitration and diagnostics. +type SourceAwareSettingsWriter interface { + SettingsWriter + WriteRAMVarWithSource(source CommandSource, id uint16, value int16) error + WriteSettingWithSource(source CommandSource, id uint16, value int16) error + SetPanelStateWithSource(source CommandSource, switchState PanelSwitchState, currentLimitA *float64) error + SetStandbyWithSource(source CommandSource, enabled bool) error +} + +type CommandHistoryProvider interface { + History(limit int) []CommandEvent +} diff --git a/mk2driver/mockmk2.go b/mk2driver/mockmk2.go index b937131..ac7152f 100644 --- a/mk2driver/mockmk2.go +++ b/mk2driver/mockmk2.go @@ -98,6 +98,18 @@ func (m *mock) WriteRAMVarByID(_ uint16, _ int16) error { return nil } +func (m *mock) WriteSelectedData(_ int16) error { + return nil +} + +func (m *mock) WriteSettingBySelection(_ uint16, _ int16) error { + return nil +} + +func (m *mock) WriteRAMVarBySelection(_ uint16, _ int16) error { + return nil +} + func (m *mock) genMockValues() { mult := 1.0 ledState := LedOff diff --git a/plugins/mqttclient/mqtt.go b/plugins/mqttclient/mqtt.go index f44a3de..ee7383a 100644 --- a/plugins/mqttclient/mqtt.go +++ b/plugins/mqttclient/mqtt.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strconv" "strings" "sync" @@ -23,6 +24,10 @@ const ( 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" @@ -35,6 +40,15 @@ type HomeAssistantConfig struct { 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 @@ -42,16 +56,24 @@ type Config struct { 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 @@ -77,6 +99,7 @@ type writeStatus struct { 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"` @@ -84,6 +107,35 @@ type writeStatus struct { 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 @@ -95,19 +147,192 @@ type panelStateCache struct { 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)) @@ -115,6 +340,12 @@ func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) erro 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 { @@ -127,6 +358,12 @@ func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) erro 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) + } } } @@ -134,7 +371,7 @@ func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) erro 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 := 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()) @@ -143,10 +380,59 @@ func New(mk2 mk2driver.Mk2, writer mk2driver.SettingsWriter, config Config) erro } } + 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 || !e.Valid { - log.Debug("Skipping invalid/nil MK2 event for MQTT publish") + 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 { @@ -192,7 +478,55 @@ func subscribeHAPanelModeState(client mqtt.Client, config Config, cache *panelSt return t.Error() } -func commandHandler(client mqtt.Client, writer mk2driver.SettingsWriter, config Config, cache *panelStateCache) mqtt.MessageHandler { +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{} } @@ -214,50 +548,105 @@ func commandHandler(client mqtt.Client, writer mk2driver.SettingsWriter, config 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) - } - } - } - + 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 @@ -281,16 +670,221 @@ func (c *panelStateCache) resolvePanelCommand(cmd writeCommand) (writeCommand, e } 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 + 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() - log.WithField("switch", cmd.SwitchName).Debug("Remembered panel switch state in cache") +} + +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: ¤tLimit, + } + 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) { @@ -333,6 +927,7 @@ func decodeWriteCommand(payload []byte) (writeCommand, error) { } return writeCommand{ + Source: mk2driver.CommandSourceMQTT, RequestID: msg.RequestID, Kind: normalizedKind, HasSwitch: hasSwitch, @@ -349,12 +944,41 @@ func decodeWriteCommand(payload []byte) (writeCommand, error) { } 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"`) } @@ -364,6 +988,7 @@ func decodeWriteCommand(payload []byte) (writeCommand, error) { } return writeCommand{ + Source: mk2driver.CommandSourceMQTT, RequestID: msg.RequestID, Kind: normalizedKind, ID: *msg.ID, @@ -381,6 +1006,14 @@ func normalizeWriteKind(raw string) (string, error) { 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) } @@ -405,6 +1038,30 @@ func executeWriteCommand(writer mk2driver.SettingsWriter, cmd writeCommand) erro 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 { @@ -441,6 +1098,13 @@ func formatWriteCommandLog(cmd writeCommand) string { return fmt.Sprintf("kind=%s standby=", 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=", 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) } @@ -458,6 +1122,70 @@ func decodeInt16Value(raw json.RawMessage) (int16, error) { 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 @@ -473,12 +1201,7 @@ func decodeStandbyValue(msg writeCommandPayload) (bool, error) { 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 - } + return parseStandbyText(stringValue) } var intValue int @@ -504,6 +1227,245 @@ func publishWriteStatus(client mqtt.Client, topic string, status writeStatus) { } } +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) @@ -687,6 +1649,592 @@ func publishHAControlState(client mqtt.Client, config Config, cmd writeCommand) 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") @@ -745,6 +2293,87 @@ func mqttTopicRoot(topic string) string { 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)) } @@ -819,6 +2448,10 @@ func copyBoolPtr(in *bool) *bool { return &value } +func float64Ptr(in float64) *float64 { + return &in +} + func getOpts(config Config) *mqtt.ClientOptions { opts := mqtt.NewClientOptions() opts.AddBroker(config.Broker) @@ -832,6 +2465,8 @@ func getOpts(config Config) *mqtt.ClientOptions { } 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) diff --git a/plugins/mqttclient/mqtt_test.go b/plugins/mqttclient/mqtt_test.go index 4d25b5c..5b68b45 100644 --- a/plugins/mqttclient/mqtt_test.go +++ b/plugins/mqttclient/mqtt_test.go @@ -2,6 +2,7 @@ package mqttclient import ( "testing" + "time" "git.coadcorp.com/nathan/invertergui/mk2driver" "github.com/stretchr/testify/assert" @@ -57,6 +58,7 @@ func Test_decodeWriteCommand(t *testing.T) { payload: `{"request_id":"abc","kind":"setting","id":15,"value":-5}`, check: func(t *testing.T, got writeCommand) { assert.Equal(t, writeCommand{ + Source: mk2driver.CommandSourceMQTT, RequestID: "abc", Kind: commandKindSetting, ID: 15, @@ -69,9 +71,10 @@ func Test_decodeWriteCommand(t *testing.T) { payload: `{"type":"ramvar","id":2,"value":7}`, check: func(t *testing.T, got writeCommand) { assert.Equal(t, writeCommand{ - Kind: commandKindRAMVar, - ID: 2, - Value: 7, + Source: mk2driver.CommandSourceMQTT, + Kind: commandKindRAMVar, + ID: 2, + Value: 7, }, got) }, }, @@ -305,12 +308,196 @@ func Test_panelStateCacheResolvePanelCommand(t *testing.T) { 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(" ")) } + +func Test_decodeVenusWriteCommand(t *testing.T) { + cfg := Config{ + ClientID: "inverter-gui", + Venus: VenusConfig{ + Enabled: true, + PortalID: "site01", + Service: "vebus/257", + GuideCompat: true, + }, + } + + tests := []struct { + name string + topic string + payload string + assertion func(*testing.T, writeCommand) + wantErr string + }{ + { + name: "mode numeric", + topic: "W/site01/vebus/257/Mode", + payload: `{"value":3}`, + assertion: func(t *testing.T, cmd writeCommand) { + assert.Equal(t, commandKindPanel, cmd.Kind) + assert.True(t, cmd.HasSwitch) + assert.Equal(t, mk2driver.PanelSwitchOn, cmd.SwitchState) + assert.Equal(t, "on", cmd.SwitchName) + }, + }, + { + name: "current limit", + topic: "W/site01/vebus/257/Ac/ActiveIn/CurrentLimit", + payload: `{"value":16.5}`, + assertion: func(t *testing.T, cmd writeCommand) { + assert.Equal(t, commandKindPanel, cmd.Kind) + if assert.NotNil(t, cmd.CurrentLimitA) { + assert.Equal(t, 16.5, *cmd.CurrentLimitA) + } + }, + }, + { + name: "standby", + topic: "W/site01/vebus/257/Settings/Standby", + payload: `{"value":true}`, + assertion: func(t *testing.T, cmd writeCommand) { + assert.Equal(t, commandKindStandby, cmd.Kind) + if assert.NotNil(t, cmd.Standby) { + assert.True(t, *cmd.Standby) + } + }, + }, + { + name: "invalid topic", + topic: "W/site01/vebus/257/Unknown", + payload: `{"value":1}`, + wantErr: "unsupported Venus write path", + }, + { + name: "guide ess setpoint", + topic: "W/site01/settings/0/Settings/CGwacs/AcPowerSetPoint", + payload: `{"value":-1200}`, + assertion: func(t *testing.T, cmd writeCommand) { + assert.Equal(t, commandKindESSSet, cmd.Kind) + if assert.NotNil(t, cmd.FloatValue) { + assert.InDelta(t, -1200, *cmd.FloatValue, 0.01) + } + }, + }, + { + name: "guide ess mode with prefix", + topic: "victron/W/site01/settings/0/Settings/CGwacs/BatteryLife/State", + payload: `{"value":10}`, + assertion: func(t *testing.T, cmd writeCommand) { + assert.Equal(t, commandKindESSMode, cmd.Kind) + assert.Equal(t, int16(10), cmd.Value) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testCfg := cfg + if tt.name == "guide ess mode with prefix" { + testCfg.Venus.TopicPrefix = "victron" + } + cmd, err := decodeVenusWriteCommand(testCfg, tt.topic, []byte(tt.payload)) + if tt.wantErr != "" { + assert.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr) + return + } + assert.NoError(t, err) + tt.assertion(t, cmd) + }) + } +} + +func Test_panelStateCacheRememberTracksFields(t *testing.T) { + cache := &panelStateCache{} + limit := 12.0 + standby := true + + cache.remember(writeCommand{ + Kind: commandKindPanel, + HasSwitch: true, + SwitchName: "on", + SwitchState: mk2driver.PanelSwitchOn, + CurrentLimitA: &limit, + }) + cache.remember(writeCommand{ + Kind: commandKindStandby, + Standby: &standby, + }) + + s := cache.snapshot() + assert.True(t, s.HasSwitch) + assert.Equal(t, "on", s.Switch) + assert.True(t, s.HasCurrent) + assert.InDelta(t, 12.0, s.CurrentLimit, 0.001) + assert.True(t, s.HasStandby) + assert.True(t, s.Standby) +} + +func Test_historyTrackerSummary(t *testing.T) { + h := newHistoryTracker(2) + now := time.Now().UTC() + + h.Add(telemetrySnapshot{ + Timestamp: now, + InputPower: 100, + OutputPower: 90, + BatteryPower: -10, + BatteryVoltage: 25.0, + }, operatingStatePassthru, 0, nil) + summary := h.Add(telemetrySnapshot{ + Timestamp: now.Add(1 * time.Second), + InputPower: 200, + OutputPower: 180, + BatteryPower: -20, + BatteryVoltage: 24.5, + }, operatingStateInverter, 2, &now) + + assert.Equal(t, 2, summary.Samples) + assert.InDelta(t, 150, summary.AverageInputPower, 0.01) + assert.InDelta(t, 135, summary.AverageOutputPower, 0.01) + assert.InDelta(t, 180, summary.MaxOutputPower, 0.01) + assert.InDelta(t, 24.5, summary.MinBatteryVoltage, 0.01) + assert.Equal(t, uint64(2), summary.FaultCount) +} + +func Test_resolveESSWriteCommand(t *testing.T) { + ess := newESSControlCache() + telemetry := &telemetryCache{} + telemetry.set(telemetrySnapshot{InputVoltage: 230}) + + setpoint := 920.0 + mapped, err := resolveESSWriteCommand(writeCommand{ + Kind: commandKindESSSet, + FloatValue: &setpoint, + }, ess, telemetry) + assert.NoError(t, err) + if assert.NotNil(t, mapped) { + assert.Equal(t, commandKindPanel, mapped.Kind) + assert.Equal(t, "charger_only", mapped.SwitchName) + if assert.NotNil(t, mapped.CurrentLimitA) { + assert.InDelta(t, 4.0, *mapped.CurrentLimitA, 0.01) + } + } + + maxDischarge := 1000.0 + _, err = resolveESSWriteCommand(writeCommand{ + Kind: commandKindESSMaxD, + FloatValue: &maxDischarge, + }, ess, telemetry) + assert.NoError(t, err) + + dischargeSetpoint := -2000.0 + mapped, err = resolveESSWriteCommand(writeCommand{ + Kind: commandKindESSSet, + FloatValue: &dischargeSetpoint, + }, ess, telemetry) + assert.NoError(t, err) + if assert.NotNil(t, mapped) { + assert.Equal(t, commandKindPanel, mapped.Kind) + assert.Equal(t, "inverter_only", mapped.SwitchName) + } +} diff --git a/plugins/webui/webgui.go b/plugins/webui/webgui.go index 1c0f202..94180fc 100644 --- a/plugins/webui/webgui.go +++ b/plugins/webui/webgui.go @@ -208,13 +208,19 @@ func (w *WebGui) handleSetRemotePanelState(rw http.ResponseWriter, r *http.Reque return } - if err := w.writer.SetPanelState(switchState, req.CurrentLimit); err != nil { - logEntry.WithError(err).Error("Failed to apply remote panel state") + var setErr error + if sourceAware, ok := w.writer.(mk2driver.SourceAwareSettingsWriter); ok { + setErr = sourceAware.SetPanelStateWithSource(mk2driver.CommandSourceUI, switchState, req.CurrentLimit) + } else { + setErr = w.writer.SetPanelState(switchState, req.CurrentLimit) + } + if setErr != nil { + logEntry.WithError(setErr).Error("Failed to apply remote panel state") w.updateRemotePanelState(func(state *remotePanelState) { state.LastCommand = "set_remote_panel_state" - state.LastError = err.Error() + state.LastError = setErr.Error() }) - http.Error(rw, err.Error(), http.StatusBadGateway) + http.Error(rw, setErr.Error(), http.StatusBadGateway) return } @@ -243,7 +249,13 @@ func (w *WebGui) handleSetRemotePanelStandby(rw http.ResponseWriter, r *http.Req } log.WithField("standby", req.Standby).Info("Applying standby state from API") - if err := w.writer.SetStandby(req.Standby); err != nil { + var err error + if sourceAware, ok := w.writer.(mk2driver.SourceAwareSettingsWriter); ok { + err = sourceAware.SetStandbyWithSource(mk2driver.CommandSourceUI, req.Standby) + } else { + err = w.writer.SetStandby(req.Standby) + } + if err != nil { log.WithError(err).WithField("standby", req.Standby).Error("Failed to apply standby state") w.updateRemotePanelState(func(state *remotePanelState) { state.LastCommand = "set_remote_panel_standby"