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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user