implement some features of Venus OS
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
145
README.md
145
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:
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
194
mk2driver/managed_writer.go
Normal file
194
mk2driver/managed_writer.go
Normal file
@@ -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
|
||||
}
|
||||
79
mk2driver/managed_writer_test.go
Normal file
79
mk2driver/managed_writer_test.go
Normal file
@@ -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)
|
||||
}
|
||||
332
mk2driver/metadata.go
Normal file
332
mk2driver/metadata.go
Normal file
@@ -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
|
||||
}
|
||||
585
mk2driver/mk2.go
585
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user