diff --git a/.gitignore b/.gitignore index a938edc..0834d58 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,8 @@ _testmain.go *.test *.prof -vendor/ \ No newline at end of file +vendor/ + +# Python cache files (for Home Assistant custom component) +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 069862a..60cff21 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The code has been updated to support more of the protocol published by Victron a This project is based on the original open source `invertergui` project by Hendrik van Wyk and contributors: - Original repository: https://github.com/diebietse/invertergui +- Home Assistant `victron-mk3-hass` inspiration: https://github.com/j9brown/victron-mk3-hass ## Demo @@ -428,6 +429,119 @@ plus remote panel controls for: The combined mode + current limit behavior is provided through the `panel_state` MQTT command kind, which mirrors `victron_mk3.set_remote_panel_state`. +### Home Assistant Custom Component (MQTT) + +This repository also includes a custom Home Assistant integration at: + +- `custom_components/victron_mk2_mqtt` + +This component is useful if you want HA entities/services that are explicitly tied to +`invertergui` MQTT topics, instead of relying only on MQTT auto-discovery entities. + +If you use this custom component, you can disable `--mqtt.ha.enabled` in `invertergui` +to avoid duplicate entities created by MQTT discovery. + +Install via HACS: + +1. Add the integration repository in Home Assistant: + [![Open your Home Assistant instance and open this repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=nathan&repository=invertergui&category=integration) +2. Install `Victron MK2 MQTT` from HACS. +3. Restart Home Assistant. +4. Add the YAML configuration shown below. + +If you are not mirroring this repo to GitHub, use the manual install method below. + +Manual install (alternative): + +```text +/custom_components/victron_mk2_mqtt +``` + +Then add YAML config: + +```yaml +victron_mk2_mqtt: + name: Victron Inverter + state_topic: invertergui/updates + command_topic: invertergui/settings/set + status_topic: invertergui/settings/status + # topic_root is optional; defaults to state_topic root (for example "invertergui") + # topic_root: invertergui +``` + +Provided entities include: + +- Telemetry sensors (battery/input/output voltage/current/frequency and derived power) +- `Remote Panel Mode` (`charger_only`, `inverter_only`, `on`, `off`) +- `Remote Panel Current Limit` (A) +- `Remote Panel Standby` +- Diagnostic entities (`Data Valid`, `Last Command Error`) + +Service exposed by the integration: + +- `victron_mk2_mqtt.set_remote_panel_state` + +Example service call: + +```yaml +service: victron_mk2_mqtt.set_remote_panel_state +data: + mode: on + current_limit: 16.0 +``` + +### Home Assistant MQTT-Only Dashboard (No Duplicate Entities) + +If you want the same control/telemetry experience but only via MQTT (without duplicate +entities from discovery/custom integrations), use the packaged Home Assistant files: + +- MQTT entity + control package: `homeassistant/packages/invertergui_mqtt.yaml` +- Lovelace dashboard: `homeassistant/dashboards/invertergui_mqtt_dashboard.yaml` + +The package assumes default topics (`invertergui/updates`, `invertergui/settings/set`, +`invertergui/settings/status`). If you use custom MQTT topics, update those values in +`homeassistant/packages/invertergui_mqtt.yaml`. + +Recommended for this mode: + +- Disable MQTT discovery output from `invertergui` (`--mqtt.ha.enabled=false`) +- Do not enable the `victron_mk2_mqtt` custom component at the same time + +1. Ensure HA packages are enabled (if not already): + +```yaml +homeassistant: + packages: !include_dir_named packages +``` + +2. Copy package file to your HA config: + +```text +/packages/invertergui_mqtt.yaml +``` + +3. Copy dashboard file to your HA config: + +```text +/dashboards/invertergui_mqtt_dashboard.yaml +``` + +4. Register the dashboard (YAML mode example): + +```yaml +lovelace: + mode: storage + dashboards: + invertergui-victron: + mode: yaml + title: Victron MQTT + icon: mdi:flash + show_in_sidebar: true + filename: dashboards/invertergui_mqtt_dashboard.yaml +``` + +5. Restart Home Assistant. + ## TTY Device The intertergui application makes use of a serial tty device to monitor the Multiplus. diff --git a/custom_components/victron_mk2_mqtt/__init__.py b/custom_components/victron_mk2_mqtt/__init__.py new file mode 100644 index 0000000..bcd61ce --- /dev/null +++ b/custom_components/victron_mk2_mqtt/__init__.py @@ -0,0 +1,125 @@ +"""Home Assistant integration for invertergui MQTT topics.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_CURRENT_LIMIT, + ATTR_MODE, + CONF_COMMAND_TOPIC, + CONF_STATE_TOPIC, + CONF_STATUS_TOPIC, + CONF_TOPIC_ROOT, + DATA_BRIDGE, + DEFAULT_COMMAND_TOPIC, + DEFAULT_NAME, + DEFAULT_STATE_TOPIC, + DEFAULT_STATUS_TOPIC, + DEFAULT_TOPIC_ROOT, + DOMAIN, + PANEL_MODES, + PLATFORMS, + SERVICE_SET_REMOTE_PANEL_STATE, +) +from .coordinator import VictronMqttBridge + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_STATE_TOPIC, default=DEFAULT_STATE_TOPIC): cv.string, + vol.Optional( + CONF_COMMAND_TOPIC, default=DEFAULT_COMMAND_TOPIC + ): cv.string, + vol.Optional(CONF_STATUS_TOPIC, default=DEFAULT_STATUS_TOPIC): cv.string, + vol.Optional(CONF_TOPIC_ROOT): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_SET_REMOTE_PANEL_STATE_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_MODE): vol.In(PANEL_MODES), + vol.Optional(ATTR_CURRENT_LIMIT): vol.Coerce(float), + }, + extra=vol.PREVENT_EXTRA, +) + + +def mqtt_topic_root(topic: str) -> str: + """Match invertergui MQTT root behavior.""" + cleaned = topic.strip().strip("/") + if not cleaned: + return DEFAULT_TOPIC_ROOT + if cleaned.endswith("/updates"): + root = cleaned[: -len("/updates")] + if root: + return root + return cleaned + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Victron MK2 MQTT integration from YAML.""" + conf = config.get(DOMAIN) + if conf is None: + return True + + setup_conf: dict[str, Any] = dict(conf) + if not setup_conf.get(CONF_TOPIC_ROOT): + setup_conf[CONF_TOPIC_ROOT] = mqtt_topic_root(setup_conf[CONF_STATE_TOPIC]) + + bridge = VictronMqttBridge(hass, setup_conf) + await bridge.async_setup() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][DATA_BRIDGE] = bridge + + await _register_services(hass, bridge) + + for platform in PLATFORMS: + hass.async_create_task( + discovery.async_load_platform(hass, platform, DOMAIN, {}, config) + ) + + return True + + +async def _register_services(hass: HomeAssistant, bridge: VictronMqttBridge) -> None: + """Register integration services.""" + if hass.services.has_service(DOMAIN, SERVICE_SET_REMOTE_PANEL_STATE): + return + + async def handle_set_remote_panel_state(call: ServiceCall) -> None: + mode = call.data.get(ATTR_MODE) + current_limit = call.data.get(ATTR_CURRENT_LIMIT) + + if mode is None and current_limit is None: + raise HomeAssistantError("Provide at least one of mode or current_limit") + if current_limit is not None and current_limit < 0: + raise HomeAssistantError("current_limit must be >= 0") + + payload: dict[str, Any] = {"kind": "panel_state"} + if mode is not None: + payload["switch"] = mode + if current_limit is not None: + payload["current_limit"] = float(current_limit) + + await bridge.async_publish_command(payload) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_REMOTE_PANEL_STATE, + handle_set_remote_panel_state, + schema=SERVICE_SET_REMOTE_PANEL_STATE_SCHEMA, + ) diff --git a/custom_components/victron_mk2_mqtt/binary_sensor.py b/custom_components/victron_mk2_mqtt/binary_sensor.py new file mode 100644 index 0000000..982d280 --- /dev/null +++ b/custom_components/victron_mk2_mqtt/binary_sensor.py @@ -0,0 +1,48 @@ +"""Binary sensors for Victron MK2 MQTT integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant + +from .const import DATA_BRIDGE, DOMAIN +from .coordinator import VictronMqttBridge +from .entity import VictronMqttEntity + + +async def async_setup_platform( + hass: HomeAssistant, + config: dict[str, Any], + async_add_entities, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up Victron binary sensors.""" + bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE] + async_add_entities([VictronDataValidBinarySensor(bridge)]) + + +class VictronDataValidBinarySensor(VictronMqttEntity, BinarySensorEntity): + """MQTT data validity sensor.""" + + _attr_name = "Data Valid" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:check-network-outline" + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_data_valid" + + @property + def is_on(self) -> bool: + value = self.bridge.metric("Valid") + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + normalized = value.strip().lower() + return normalized in {"1", "true", "on", "yes"} + return False diff --git a/custom_components/victron_mk2_mqtt/const.py b/custom_components/victron_mk2_mqtt/const.py new file mode 100644 index 0000000..6c12783 --- /dev/null +++ b/custom_components/victron_mk2_mqtt/const.py @@ -0,0 +1,37 @@ +"""Constants for the Victron MK2 MQTT integration.""" + +from __future__ import annotations + +DOMAIN = "victron_mk2_mqtt" + +CONF_STATE_TOPIC = "state_topic" +CONF_COMMAND_TOPIC = "command_topic" +CONF_STATUS_TOPIC = "status_topic" +CONF_TOPIC_ROOT = "topic_root" +CONF_NAME = "name" + +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" + +PLATFORMS = ("sensor", "binary_sensor", "select", "number", "switch") + +DATA_BRIDGE = "bridge" + +ATTR_MODE = "mode" +ATTR_CURRENT_LIMIT = "current_limit" + +SERVICE_SET_REMOTE_PANEL_STATE = "set_remote_panel_state" + +PANEL_MODE_CHARGER_ONLY = "charger_only" +PANEL_MODE_INVERTER_ONLY = "inverter_only" +PANEL_MODE_ON = "on" +PANEL_MODE_OFF = "off" +PANEL_MODES = ( + PANEL_MODE_CHARGER_ONLY, + PANEL_MODE_INVERTER_ONLY, + PANEL_MODE_ON, + PANEL_MODE_OFF, +) diff --git a/custom_components/victron_mk2_mqtt/coordinator.py b/custom_components/victron_mk2_mqtt/coordinator.py new file mode 100644 index 0000000..b4ce95d --- /dev/null +++ b/custom_components/victron_mk2_mqtt/coordinator.py @@ -0,0 +1,232 @@ +"""MQTT bridge for Victron MK2 integration.""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable +from typing import Any + +from homeassistant.components import mqtt +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import ( + CONF_COMMAND_TOPIC, + CONF_NAME, + CONF_STATE_TOPIC, + CONF_STATUS_TOPIC, + CONF_TOPIC_ROOT, + DOMAIN, + PANEL_MODES, +) + +_LOGGER = logging.getLogger(__name__) + + +class VictronMqttBridge: + """Maintain MQTT state and command publishing for Victron entities.""" + + def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: + self.hass = hass + + self.name: str = config[CONF_NAME] + self.state_topic: str = config[CONF_STATE_TOPIC] + 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.panel_mode_state_topic = f"{self.topic_root}/homeassistant/remote_panel_mode/state" + self.current_limit_state_topic = ( + f"{self.topic_root}/homeassistant/remote_panel_current_limit/state" + ) + self.standby_state_topic = f"{self.topic_root}/homeassistant/remote_panel_standby/state" + + self.telemetry: dict[str, Any] = {} + self.panel_mode: str | None = None + self.current_limit: float | None = None + self.standby: bool | None = None + self.last_error: str | None = None + + self._listeners: set[Callable[[], None]] = set() + self._unsubscribers: list[Callable[[], None]] = [] + + @property + def device_info(self) -> DeviceInfo: + """Return shared Home Assistant device metadata.""" + return DeviceInfo( + identifiers={(DOMAIN, self.topic_root)}, + name=self.name, + manufacturer="Victron Energy", + model="VE.Bus via invertergui MQTT", + ) + + async def async_setup(self) -> None: + """Subscribe to required MQTT topics.""" + _LOGGER.info( + "Subscribing Victron MQTT bridge topics state=%s command=%s status=%s", + self.state_topic, + self.command_topic, + self.status_topic, + ) + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, self.state_topic, self._handle_state_message, qos=1 + ) + ) + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, + self.panel_mode_state_topic, + self._handle_panel_mode_message, + qos=1, + ) + ) + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, + self.current_limit_state_topic, + self._handle_current_limit_message, + qos=1, + ) + ) + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, self.standby_state_topic, self._handle_standby_message, qos=1 + ) + ) + if self.status_topic: + self._unsubscribers.append( + await mqtt.async_subscribe( + self.hass, self.status_topic, self._handle_status_message, qos=1 + ) + ) + + async def async_shutdown(self) -> None: + """Unsubscribe all MQTT subscriptions.""" + while self._unsubscribers: + unsub = self._unsubscribers.pop() + unsub() + + @callback + def async_add_listener(self, listener: Callable[[], None]) -> Callable[[], None]: + """Register a state listener.""" + self._listeners.add(listener) + + def remove() -> None: + self._listeners.discard(listener) + + return remove + + @callback + def _notify_listeners(self) -> None: + """Notify all entities that state changed.""" + for listener in tuple(self._listeners): + listener() + + @staticmethod + def _payload_text(payload: Any) -> str: + if isinstance(payload, bytes): + return payload.decode("utf-8", errors="ignore") + if isinstance(payload, str): + return payload + return str(payload) + + @callback + def _handle_state_message(self, msg: Any) -> None: + raw_payload = self._payload_text(msg.payload) + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError as err: + _LOGGER.warning("Ignoring invalid state JSON from %s: %s", msg.topic, err) + return + + if not isinstance(payload, dict): + _LOGGER.warning("Ignoring state payload from %s: expected object", msg.topic) + return + + self.telemetry = payload + self._notify_listeners() + + @callback + def _handle_panel_mode_message(self, msg: Any) -> None: + mode = self._payload_text(msg.payload).strip().lower() + if mode not in PANEL_MODES: + _LOGGER.debug("Ignoring unknown panel mode payload %r", msg.payload) + return + + self.panel_mode = mode + self._notify_listeners() + + @callback + def _handle_current_limit_message(self, msg: Any) -> None: + payload = self._payload_text(msg.payload).strip() + if not payload: + self.current_limit = None + self._notify_listeners() + return + try: + self.current_limit = float(payload) + except ValueError: + _LOGGER.debug("Ignoring invalid current limit payload %r", msg.payload) + return + + self._notify_listeners() + + @callback + def _handle_standby_message(self, msg: Any) -> None: + value = self._payload_text(msg.payload).strip().lower() + if value in {"on", "1", "true"}: + self.standby = True + elif value in {"off", "0", "false"}: + self.standby = False + else: + _LOGGER.debug("Ignoring invalid standby payload %r", msg.payload) + return + + self._notify_listeners() + + @callback + def _handle_status_message(self, msg: Any) -> None: + raw_payload = self._payload_text(msg.payload) + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError: + return + if not isinstance(payload, dict): + return + + if payload.get("status") == "error": + err = payload.get("error") + self.last_error = str(err) if err is not None else "unknown error" + else: + self.last_error = None + self._notify_listeners() + + 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: + raise HomeAssistantError("MQTT command topic is not configured") + + mqtt.async_publish( + self.hass, + self.command_topic, + json.dumps(payload, separators=(",", ":")), + qos=1, + retain=False, + ) + + def metric(self, key: str) -> Any: + """Read a telemetry key.""" + return self.telemetry.get(key) + + def metric_float(self, key: str) -> float | None: + """Read and coerce telemetry value to float.""" + value = self.metric(key) + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/custom_components/victron_mk2_mqtt/entity.py b/custom_components/victron_mk2_mqtt/entity.py new file mode 100644 index 0000000..45b53e9 --- /dev/null +++ b/custom_components/victron_mk2_mqtt/entity.py @@ -0,0 +1,29 @@ +"""Shared entity base for Victron MK2 MQTT.""" + +from __future__ import annotations + +from homeassistant.helpers.entity import Entity + +from .coordinator import VictronMqttBridge + + +class VictronMqttEntity(Entity): + """Base entity bound to shared MQTT bridge.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, bridge: VictronMqttBridge) -> None: + self.bridge = bridge + + @property + def device_info(self): + """Return the shared device info.""" + return self.bridge.device_info + + async def async_added_to_hass(self) -> None: + """Register for coordinator updates.""" + self.async_on_remove(self.bridge.async_add_listener(self._handle_bridge_update)) + + def _handle_bridge_update(self) -> None: + self.async_write_ha_state() diff --git a/custom_components/victron_mk2_mqtt/manifest.json b/custom_components/victron_mk2_mqtt/manifest.json new file mode 100644 index 0000000..f1733dc --- /dev/null +++ b/custom_components/victron_mk2_mqtt/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "victron_mk2_mqtt", + "name": "Victron MK2 MQTT", + "version": "0.1.0", + "documentation": "https://git.coadcorp.com/nathan/invertergui", + "issue_tracker": "https://git.coadcorp.com/nathan/invertergui/issues", + "dependencies": [ + "mqtt" + ], + "codeowners": [ + "@nathan" + ], + "iot_class": "local_push" +} diff --git a/custom_components/victron_mk2_mqtt/number.py b/custom_components/victron_mk2_mqtt/number.py new file mode 100644 index 0000000..ddcc36b --- /dev/null +++ b/custom_components/victron_mk2_mqtt/number.py @@ -0,0 +1,54 @@ +"""Number entities for Victron MK2 MQTT integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode +from homeassistant.const import UnitOfElectricCurrent +from homeassistant.core import HomeAssistant + +from .const import DATA_BRIDGE, DOMAIN +from .coordinator import VictronMqttBridge +from .entity import VictronMqttEntity + + +async def async_setup_platform( + hass: HomeAssistant, + config: dict[str, Any], + async_add_entities, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up Victron number entities.""" + bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE] + async_add_entities([VictronRemotePanelCurrentLimitNumber(bridge)]) + + +class VictronRemotePanelCurrentLimitNumber(VictronMqttEntity, NumberEntity): + """Remote panel AC input current limit.""" + + _attr_name = "Remote Panel Current Limit" + _attr_icon = "mdi:current-ac" + _attr_native_min_value = 0.0 + _attr_native_max_value = 100.0 + _attr_native_step = 0.1 + _attr_mode = NumberMode.BOX + _attr_device_class = NumberDeviceClass.CURRENT + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_remote_panel_current_limit" + + @property + def native_value(self) -> float | None: + return self.bridge.current_limit + + @property + def available(self) -> bool: + return bool(self.bridge.command_topic) + + async def async_set_native_value(self, value: float) -> None: + await self.bridge.async_publish_command( + {"kind": "panel_state", "current_limit": float(value)} + ) diff --git a/custom_components/victron_mk2_mqtt/select.py b/custom_components/victron_mk2_mqtt/select.py new file mode 100644 index 0000000..416d528 --- /dev/null +++ b/custom_components/victron_mk2_mqtt/select.py @@ -0,0 +1,46 @@ +"""Select entities for Victron MK2 MQTT integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant + +from .const import DATA_BRIDGE, DOMAIN, PANEL_MODES +from .coordinator import VictronMqttBridge +from .entity import VictronMqttEntity + + +async def async_setup_platform( + hass: HomeAssistant, + config: dict[str, Any], + async_add_entities, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up Victron select entities.""" + bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE] + async_add_entities([VictronRemotePanelModeSelect(bridge)]) + + +class VictronRemotePanelModeSelect(VictronMqttEntity, SelectEntity): + """Remote panel mode select.""" + + _attr_name = "Remote Panel Mode" + _attr_options = list(PANEL_MODES) + _attr_icon = "mdi:transmission-tower-export" + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_remote_panel_mode" + + @property + def current_option(self) -> str | None: + return self.bridge.panel_mode + + @property + def available(self) -> bool: + return bool(self.bridge.command_topic) + + async def async_select_option(self, option: str) -> None: + await self.bridge.async_publish_command({"kind": "panel_state", "switch": option}) diff --git a/custom_components/victron_mk2_mqtt/sensor.py b/custom_components/victron_mk2_mqtt/sensor.py new file mode 100644 index 0000000..16b0238 --- /dev/null +++ b/custom_components/victron_mk2_mqtt/sensor.py @@ -0,0 +1,148 @@ +"""Sensor entities for Victron MK2 MQTT integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.const import ( + EntityCategory, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfFrequency, +) +from homeassistant.core import HomeAssistant + +from .const import DATA_BRIDGE, DOMAIN +from .coordinator import VictronMqttBridge +from .entity import VictronMqttEntity + + +@dataclass(frozen=True) +class MetricDescription: + """Description for a telemetry-backed sensor.""" + + key: str + name: str + value_fn: Callable[[VictronMqttBridge], Any] + unit: str | None = None + state_class: SensorStateClass | None = SensorStateClass.MEASUREMENT + entity_category: EntityCategory | None = None + + +METRICS: tuple[MetricDescription, ...] = ( + MetricDescription( + key="battery_voltage", + name="Battery Voltage", + value_fn=lambda bridge: bridge.metric_float("BatVoltage"), + unit=UnitOfElectricPotential.VOLT, + ), + MetricDescription( + key="battery_current", + name="Battery Current", + value_fn=lambda bridge: bridge.metric_float("BatCurrent"), + unit=UnitOfElectricCurrent.AMPERE, + ), + MetricDescription( + key="battery_charge", + name="Battery Charge", + value_fn=lambda bridge: ( + bridge.metric_float("ChargeState") * 100.0 + if bridge.metric_float("ChargeState") is not None + else None + ), + unit=PERCENTAGE, + ), + MetricDescription( + key="input_voltage", + name="Input Voltage", + value_fn=lambda bridge: bridge.metric_float("InVoltage"), + unit=UnitOfElectricPotential.VOLT, + ), + MetricDescription( + key="input_current", + name="Input Current", + value_fn=lambda bridge: bridge.metric_float("InCurrent"), + unit=UnitOfElectricCurrent.AMPERE, + ), + MetricDescription( + key="input_frequency", + name="Input Frequency", + value_fn=lambda bridge: bridge.metric_float("InFrequency"), + unit=UnitOfFrequency.HERTZ, + ), + MetricDescription( + key="output_voltage", + name="Output Voltage", + value_fn=lambda bridge: bridge.metric_float("OutVoltage"), + unit=UnitOfElectricPotential.VOLT, + ), + MetricDescription( + key="output_current", + name="Output Current", + value_fn=lambda bridge: bridge.metric_float("OutCurrent"), + unit=UnitOfElectricCurrent.AMPERE, + ), + MetricDescription( + key="output_frequency", + name="Output Frequency", + value_fn=lambda bridge: bridge.metric_float("OutFrequency"), + unit=UnitOfFrequency.HERTZ, + ), + MetricDescription( + key="input_power", + name="Input Power", + value_fn=lambda bridge: _product(bridge.metric_float("InVoltage"), bridge.metric_float("InCurrent")), + unit="VA", + ), + MetricDescription( + key="output_power", + name="Output Power", + value_fn=lambda bridge: _product(bridge.metric_float("OutVoltage"), bridge.metric_float("OutCurrent")), + unit="VA", + ), + MetricDescription( + key="last_command_error", + name="Last Command Error", + value_fn=lambda bridge: bridge.last_error, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +def _product(a: float | None, b: float | None) -> float | None: + if a is None or b is None: + return None + return a * b + + +async def async_setup_platform( + hass: HomeAssistant, + config: dict[str, Any], + async_add_entities, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up Victron telemetry sensors.""" + bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE] + async_add_entities(VictronMetricSensor(bridge, metric) for metric in METRICS) + + +class VictronMetricSensor(VictronMqttEntity, SensorEntity): + """Generic telemetry sensor.""" + + def __init__(self, bridge: VictronMqttBridge, description: MetricDescription) -> None: + super().__init__(bridge) + self.entity_description = description + self._attr_unique_id = f"{bridge.topic_root}_{description.key}" + self._attr_name = description.name + self._attr_native_unit_of_measurement = description.unit + self._attr_state_class = description.state_class + self._attr_entity_category = description.entity_category + + @property + def native_value(self): + return self.entity_description.value_fn(self.bridge) diff --git a/custom_components/victron_mk2_mqtt/services.yaml b/custom_components/victron_mk2_mqtt/services.yaml new file mode 100644 index 0000000..54e2a8b --- /dev/null +++ b/custom_components/victron_mk2_mqtt/services.yaml @@ -0,0 +1,27 @@ +set_remote_panel_state: + name: Set Remote Panel State + description: Set the remote panel mode and/or AC input current limit over MQTT. + fields: + mode: + name: Mode + description: Remote panel mode. + required: false + selector: + select: + mode: dropdown + options: + - charger_only + - inverter_only + - on + - off + current_limit: + name: Current Limit + description: AC input current limit in amps. + required: false + selector: + number: + min: 0 + max: 100 + step: 0.1 + unit_of_measurement: A + mode: box diff --git a/custom_components/victron_mk2_mqtt/switch.py b/custom_components/victron_mk2_mqtt/switch.py new file mode 100644 index 0000000..e6b0835 --- /dev/null +++ b/custom_components/victron_mk2_mqtt/switch.py @@ -0,0 +1,48 @@ +"""Switch entities for Victron MK2 MQTT integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant + +from .const import DATA_BRIDGE, DOMAIN +from .coordinator import VictronMqttBridge +from .entity import VictronMqttEntity + + +async def async_setup_platform( + hass: HomeAssistant, + config: dict[str, Any], + async_add_entities, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up Victron switch entities.""" + bridge: VictronMqttBridge = hass.data[DOMAIN][DATA_BRIDGE] + async_add_entities([VictronRemotePanelStandbySwitch(bridge)]) + + +class VictronRemotePanelStandbySwitch(VictronMqttEntity, SwitchEntity): + """Remote panel standby switch.""" + + _attr_name = "Remote Panel Standby" + _attr_icon = "mdi:power-sleep" + + def __init__(self, bridge: VictronMqttBridge) -> None: + super().__init__(bridge) + self._attr_unique_id = f"{bridge.topic_root}_remote_panel_standby" + + @property + def is_on(self) -> bool: + return bool(self.bridge.standby) + + @property + def available(self) -> bool: + return bool(self.bridge.command_topic) + + async def async_turn_on(self, **kwargs: Any) -> None: + await self.bridge.async_publish_command({"kind": "standby", "standby": True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.bridge.async_publish_command({"kind": "standby", "standby": False}) diff --git a/homeassistant/dashboards/invertergui_mqtt_dashboard.yaml b/homeassistant/dashboards/invertergui_mqtt_dashboard.yaml new file mode 100644 index 0000000..7abe1f0 --- /dev/null +++ b/homeassistant/dashboards/invertergui_mqtt_dashboard.yaml @@ -0,0 +1,120 @@ +title: Victron Inverter MQTT +views: + - title: Inverter + path: victron-inverter + icon: mdi:flash + badges: + - entity: binary_sensor.victron_online + - entity: binary_sensor.victron_data_valid + - entity: sensor.victron_last_command_status + cards: + - type: vertical-stack + cards: + - type: markdown + content: | + ## Remote Panel Control + Mode and current limit are published together over MQTT, matching `set_remote_panel_state`. + - type: entities + title: Current Remote State + state_color: true + show_header_toggle: false + entities: + - entity: select.victron_remote_panel_mode + name: Mode + - entity: number.victron_remote_panel_current_limit + name: AC Input Current Limit + - entity: switch.victron_remote_panel_standby + name: Prevent Sleep While Off + - entity: sensor.victron_last_command_error + name: Last Command Error + - type: entities + title: Apply Mode + Current Limit + show_header_toggle: false + entities: + - entity: input_select.victron_remote_panel_mode_target + name: Target Mode + - entity: input_number.victron_remote_panel_current_limit_target + name: Target Current Limit + - entity: script.victron_mqtt_set_remote_panel_state + name: Apply Mode + Current Limit + - type: entities + title: Apply Standby + show_header_toggle: false + entities: + - entity: input_boolean.victron_remote_panel_standby_target + name: Target Standby + - entity: script.victron_mqtt_set_remote_panel_standby + name: Apply Standby + + - type: grid + columns: 3 + square: false + cards: + - type: entities + title: Output + show_header_toggle: false + entities: + - entity: sensor.victron_output_current + name: Output Current + - entity: sensor.victron_output_voltage + name: Output Voltage + - entity: sensor.victron_output_frequency + name: Output Frequency + - entity: sensor.victron_output_power + name: Output Power + - type: entities + title: Input + show_header_toggle: false + entities: + - entity: sensor.victron_input_current + name: Input Current + - entity: sensor.victron_input_voltage + name: Input Voltage + - entity: sensor.victron_input_frequency + name: Input Frequency + - entity: sensor.victron_input_power + name: Input Power + - entity: sensor.victron_input_minus_output_power + name: Input - Output Power + - type: entities + title: Battery + show_header_toggle: false + entities: + - entity: sensor.victron_battery_current + name: Battery Current + - entity: sensor.victron_battery_voltage + name: Battery Voltage + - entity: sensor.victron_battery_power + name: Battery Power + - entity: sensor.victron_battery_charge + name: Battery Charge + + - type: entities + title: LED Status + show_header_toggle: false + entities: + - entity: sensor.victron_led_mains + name: Mains + - entity: sensor.victron_led_absorb + name: Absorb + - entity: sensor.victron_led_bulk + name: Bulk + - entity: sensor.victron_led_float + name: Float + - entity: sensor.victron_led_inverter + name: Inverter + - entity: sensor.victron_led_overload + name: Overload + - entity: sensor.victron_led_low_battery + name: Low Battery + - entity: sensor.victron_led_over_temp + name: Over Temperature + + - type: history-graph + title: Power (Last 2 Hours) + hours_to_show: 2 + refresh_interval: 60 + entities: + - entity: sensor.victron_input_power + - entity: sensor.victron_output_power + - entity: sensor.victron_battery_power diff --git a/homeassistant/packages/invertergui_mqtt.yaml b/homeassistant/packages/invertergui_mqtt.yaml new file mode 100644 index 0000000..6cb10ac --- /dev/null +++ b/homeassistant/packages/invertergui_mqtt.yaml @@ -0,0 +1,350 @@ +# MQTT-only Home Assistant package for invertergui. +# This avoids duplicate entities from MQTT auto-discovery/custom integration. +# +# Requirements: +# - invertergui started with MQTT publishing enabled. +# - invertergui MQTT discovery disabled (`--mqtt.ha.enabled=false`) when using this package. + +mqtt: + sensor: + - name: Victron Battery Voltage + unique_id: invertergui_mqtt_battery_voltage + state_topic: invertergui/updates + value_template: "{{ value_json.BatVoltage }}" + unit_of_measurement: V + device_class: voltage + state_class: measurement + - name: Victron Battery Current + unique_id: invertergui_mqtt_battery_current + state_topic: invertergui/updates + value_template: "{{ value_json.BatCurrent }}" + unit_of_measurement: A + device_class: current + state_class: measurement + - name: Victron Battery Charge + unique_id: invertergui_mqtt_battery_charge + state_topic: invertergui/updates + value_template: "{{ ((value_json.ChargeState | float(0)) * 100) | round(1) }}" + unit_of_measurement: "%" + device_class: battery + state_class: measurement + - name: Victron Input Voltage + unique_id: invertergui_mqtt_input_voltage + state_topic: invertergui/updates + value_template: "{{ value_json.InVoltage }}" + unit_of_measurement: V + device_class: voltage + state_class: measurement + - name: Victron Input Current + unique_id: invertergui_mqtt_input_current + state_topic: invertergui/updates + value_template: "{{ value_json.InCurrent }}" + unit_of_measurement: A + device_class: current + state_class: measurement + - name: Victron Input Frequency + unique_id: invertergui_mqtt_input_frequency + state_topic: invertergui/updates + value_template: "{{ value_json.InFrequency }}" + unit_of_measurement: Hz + device_class: frequency + state_class: measurement + - name: Victron Output Voltage + unique_id: invertergui_mqtt_output_voltage + state_topic: invertergui/updates + value_template: "{{ value_json.OutVoltage }}" + unit_of_measurement: V + device_class: voltage + state_class: measurement + - name: Victron Output Current + unique_id: invertergui_mqtt_output_current + state_topic: invertergui/updates + value_template: "{{ value_json.OutCurrent }}" + unit_of_measurement: A + device_class: current + state_class: measurement + - name: Victron Output Frequency + unique_id: invertergui_mqtt_output_frequency + state_topic: invertergui/updates + value_template: "{{ value_json.OutFrequency }}" + unit_of_measurement: Hz + device_class: frequency + state_class: measurement + - name: Victron Input Power + unique_id: invertergui_mqtt_input_power + state_topic: invertergui/updates + value_template: "{{ ((value_json.InVoltage | float(0)) * (value_json.InCurrent | float(0))) | round(1) }}" + unit_of_measurement: VA + state_class: measurement + - name: Victron Output Power + unique_id: invertergui_mqtt_output_power + state_topic: invertergui/updates + value_template: "{{ ((value_json.OutVoltage | float(0)) * (value_json.OutCurrent | float(0))) | round(1) }}" + unit_of_measurement: VA + state_class: measurement + - name: Victron Last Command Status + unique_id: invertergui_mqtt_last_command_status + state_topic: invertergui/settings/status + value_template: "{{ value_json.status | default('unknown') }}" + icon: mdi:message-alert-outline + - name: Victron Last Command Error + unique_id: invertergui_mqtt_last_command_error + state_topic: invertergui/settings/status + value_template: >- + {% if value_json.status == 'error' %} + {{ value_json.error | default('unknown error') }} + {% else %} + none + {% endif %} + icon: mdi:alert-circle-outline + - name: Victron LED Mains + unique_id: invertergui_mqtt_led_mains + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['0'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:transmission-tower + - name: Victron LED Absorb + unique_id: invertergui_mqtt_led_absorb + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['1'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:water-outline + - name: Victron LED Bulk + unique_id: invertergui_mqtt_led_bulk + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['2'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:battery-plus + - name: Victron LED Float + unique_id: invertergui_mqtt_led_float + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['3'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:battery-heart-variant + - name: Victron LED Inverter + unique_id: invertergui_mqtt_led_inverter + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['4'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:power-plug + - name: Victron LED Overload + unique_id: invertergui_mqtt_led_overload + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['5'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:alert-octagon + - name: Victron LED Low Battery + unique_id: invertergui_mqtt_led_low_battery + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['6'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:battery-alert-variant-outline + - name: Victron LED Over Temp + unique_id: invertergui_mqtt_led_over_temp + state_topic: invertergui/updates + value_template: >- + {% set leds = value_json.LEDs | default({}) %} + {% set v = leds['7'] | default(0) | int(0) %} + {% if v == 1 %}on{% elif v == 2 %}blink{% else %}off{% endif %} + icon: mdi:thermometer-alert + + binary_sensor: + - name: Victron Online + unique_id: invertergui_mqtt_online + state_topic: invertergui/updates + value_template: "ON" + payload_on: "ON" + payload_off: "OFF" + expire_after: 120 + device_class: connectivity + - name: Victron Data Valid + unique_id: invertergui_mqtt_data_valid + state_topic: invertergui/updates + value_template: "{{ value_json.Valid }}" + payload_on: "true" + payload_off: "false" + entity_category: diagnostic + + select: + - name: Victron Remote Panel Mode + unique_id: invertergui_mqtt_remote_panel_mode + state_topic: invertergui/homeassistant/remote_panel_mode/state + command_topic: invertergui/settings/set + command_template: '{"kind":"panel_state","switch":"{{ value }}"}' + options: + - charger_only + - inverter_only + - on + - off + icon: mdi:transmission-tower-export + + number: + - name: Victron Remote Panel Current Limit + unique_id: invertergui_mqtt_remote_panel_current_limit + state_topic: invertergui/homeassistant/remote_panel_current_limit/state + command_topic: invertergui/settings/set + command_template: '{"kind":"panel_state","current_limit":{{ value | float(0) }}}' + unit_of_measurement: A + device_class: current + mode: box + min: 0 + max: 100 + step: 0.1 + icon: mdi:current-ac + + switch: + - name: Victron Remote Panel Standby + unique_id: invertergui_mqtt_remote_panel_standby + state_topic: invertergui/homeassistant/remote_panel_standby/state + command_topic: invertergui/settings/set + payload_on: '{"kind":"standby","standby":true}' + payload_off: '{"kind":"standby","standby":false}' + state_on: "ON" + state_off: "OFF" + icon: mdi:power-sleep + +input_select: + victron_remote_panel_mode_target: + name: Victron Target Mode + options: + - charger_only + - inverter_only + - on + - off + icon: mdi:transmission-tower-export + +input_number: + victron_remote_panel_current_limit_target: + name: Victron Target Current Limit + min: 0 + max: 100 + step: 0.1 + unit_of_measurement: A + mode: box + icon: mdi:current-ac + +input_boolean: + victron_remote_panel_standby_target: + name: Victron Target Standby + icon: mdi:power-sleep + +script: + victron_mqtt_set_remote_panel_state: + alias: Victron MQTT Set Remote Panel State + description: Set panel mode and current limit in one MQTT command. + mode: single + icon: mdi:send + sequence: + - service: mqtt.publish + data: + topic: invertergui/settings/set + qos: 1 + payload: >- + {"kind":"panel_state","switch":"{{ states('input_select.victron_remote_panel_mode_target') }}","current_limit":{{ states('input_number.victron_remote_panel_current_limit_target') | float(0) | round(1) }}} + + victron_mqtt_set_remote_panel_standby: + alias: Victron MQTT Set Remote Panel Standby + description: Set standby state from helper input. + mode: single + icon: mdi:send-circle + sequence: + - service: mqtt.publish + data: + topic: invertergui/settings/set + qos: 1 + payload: >- + {"kind":"standby","standby":{% if is_state('input_boolean.victron_remote_panel_standby_target', 'on') %}true{% else %}false{% endif %}} + +template: + - sensor: + - name: Victron Battery Power + unique_id: invertergui_mqtt_battery_power + unit_of_measurement: W + state_class: measurement + icon: mdi:battery-charging + state: >- + {{ ((states('sensor.victron_battery_voltage') | float(0)) * (states('sensor.victron_battery_current') | float(0))) | round(1) }} + - name: Victron Input Minus Output Power + unique_id: invertergui_mqtt_input_minus_output_power + unit_of_measurement: VA + state_class: measurement + icon: mdi:flash-triangle + state: >- + {{ (states('sensor.victron_input_power') | float(0) - states('sensor.victron_output_power') | float(0)) | round(1) }} + +automation: + - id: victron_mqtt_sync_target_mode + alias: Victron MQTT Sync Mode Target + trigger: + - platform: state + entity_id: select.victron_remote_panel_mode + - platform: homeassistant + event: start + condition: + - condition: template + value_template: >- + {{ states('select.victron_remote_panel_mode') in ['charger_only', 'inverter_only', 'on', 'off'] }} + action: + - service: input_select.select_option + target: + entity_id: input_select.victron_remote_panel_mode_target + data: + option: "{{ states('select.victron_remote_panel_mode') }}" + + - id: victron_mqtt_sync_target_current_limit + alias: Victron MQTT Sync Current Limit Target + trigger: + - platform: state + entity_id: number.victron_remote_panel_current_limit + - platform: homeassistant + event: start + condition: + - condition: template + value_template: >- + {{ states('number.victron_remote_panel_current_limit') not in ['unknown', 'unavailable'] }} + action: + - service: input_number.set_value + target: + entity_id: input_number.victron_remote_panel_current_limit_target + data: + value: "{{ states('number.victron_remote_panel_current_limit') | float(0) }}" + + - id: victron_mqtt_sync_target_standby + alias: Victron MQTT Sync Standby Target + trigger: + - platform: state + entity_id: switch.victron_remote_panel_standby + - platform: homeassistant + event: start + action: + - choose: + - conditions: + - condition: state + entity_id: switch.victron_remote_panel_standby + state: "on" + sequence: + - service: input_boolean.turn_on + target: + entity_id: input_boolean.victron_remote_panel_standby_target + - conditions: + - condition: state + entity_id: switch.victron_remote_panel_standby + state: "off" + sequence: + - service: input_boolean.turn_off + target: + entity_id: input_boolean.victron_remote_panel_standby_target