[ci skip] home assistant integration

This commit is contained in:
2026-02-19 14:34:58 +11:00
parent 7d0ce52c27
commit d72e88ab7b
15 changed files with 1397 additions and 1 deletions

6
.gitignore vendored
View File

@@ -23,4 +23,8 @@ _testmain.go
*.test
*.prof
vendor/
vendor/
# Python cache files (for Home Assistant custom component)
__pycache__/
*.pyc

114
README.md
View File

@@ -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
<home-assistant-config>/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
<home-assistant-config>/packages/invertergui_mqtt.yaml
```
3. Copy dashboard file to your HA config:
```text
<home-assistant-config>/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.

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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"
}

View File

@@ -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)}
)

View File

@@ -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})

View File

@@ -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)

View File

@@ -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

View File

@@ -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})

View File

@@ -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

View File

@@ -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