[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

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