Make sure we take all Zinvolt battery units in account (#167294)

This commit is contained in:
Joost Lekkerkerker
2026-04-03 15:13:17 +02:00
committed by Franck Nijhof
parent 02f1a9c3a9
commit ac53cfa85a
9 changed files with 579 additions and 37 deletions

View File

@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ZinvoltConfigEntry, ZinvoltData, ZinvoltDeviceCoordinator
from .entity import ZinvoltEntity
from .entity import ZinvoltEntity, ZinvoltUnitEntity
POINT_ENTITIES = {
"communication": BinarySensorDeviceClass.PROBLEM,
@@ -57,9 +57,10 @@ async def async_setup_entry(
for coordinator in entry.runtime_data.values()
]
entities.extend(
ZinvoltPointBinarySensor(coordinator, point)
ZinvoltPointBinarySensor(coordinator, battery.serial_number, point)
for coordinator in entry.runtime_data.values()
for point in coordinator.data.points
for battery in coordinator.battery_units.values()
for point in coordinator.data.batteries[battery.serial_number].points
if point in POINT_ENTITIES
)
async_add_entities(entities)
@@ -88,25 +89,27 @@ class ZinvoltBatteryStateBinarySensor(ZinvoltEntity, BinarySensorEntity):
return self.entity_description.is_on_fn(self.coordinator.data)
class ZinvoltPointBinarySensor(ZinvoltEntity, BinarySensorEntity):
class ZinvoltPointBinarySensor(ZinvoltUnitEntity, BinarySensorEntity):
"""Zinvolt battery state binary sensor."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(self, coordinator: ZinvoltDeviceCoordinator, point: str) -> None:
def __init__(
self, coordinator: ZinvoltDeviceCoordinator, unit_serial_number: str, point: str
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
super().__init__(coordinator, unit_serial_number)
self.point = point
self._attr_translation_key = point
self._attr_device_class = POINT_ENTITIES[point]
self._attr_unique_id = f"{coordinator.data.battery.serial_number}.{point}"
self._attr_unique_id = f"{self.serial_number}.{point}"
@property
def available(self) -> bool:
"""Return the availability of the binary sensor."""
return super().available and self.point in self.coordinator.data.points
return super().available and self.point in self.battery.points
@property
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
return not self.coordinator.data.points[self.point]
return not self.battery.points[self.point]

View File

@@ -6,7 +6,7 @@ import logging
from zinvolt import ZinvoltClient
from zinvolt.exceptions import ZinvoltError
from zinvolt.models import Battery, BatteryState
from zinvolt.models import Battery, BatteryState, Unit, UnitType
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -24,6 +24,13 @@ class ZinvoltData:
"""Data for the Zinvolt integration."""
battery: BatteryState
batteries: dict[str, BatteryData]
@dataclass
class BatteryData:
"""Data per battery unit."""
sw_version: str
model: str
points: dict[str, bool]
@@ -32,6 +39,8 @@ class ZinvoltData:
class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]):
"""Class for Zinvolt devices."""
battery_units: dict[str, Unit]
def __init__(
self,
hass: HomeAssistant,
@@ -50,15 +59,30 @@ class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]):
self.battery = battery
self.client = client
async def _async_setup(self) -> None:
"""Set up the Zinvolt integration."""
try:
units = await self.client.get_units(self.battery.identifier)
except ZinvoltError as err:
raise UpdateFailed(
translation_key="update_failed", translation_domain=DOMAIN
) from err
self.battery_units = {
unit.serial_number: unit for unit in units if unit.type is UnitType.BATTERY
}
async def _async_update_data(self) -> ZinvoltData:
"""Update data from Zinvolt."""
try:
battery_state = await self.client.get_battery_status(
self.battery.identifier
)
battery_unit = await self.client.get_battery_unit(
self.battery.identifier, self.battery.serial_number
)
battery_units = {
unit_serial_number: await self.client.get_battery_unit(
self.battery.identifier, unit_serial_number
)
for unit_serial_number in self.battery_units
}
except ZinvoltError as err:
raise UpdateFailed(
translation_key="update_failed",
@@ -66,7 +90,15 @@ class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]):
) from err
return ZinvoltData(
battery_state,
battery_unit.version.current_version,
battery_unit.battery_model,
{point.point.lower(): point.normal for point in battery_unit.points},
{
serial_number: BatteryData(
battery_unit.version.current_version,
battery_unit.battery_model,
{
point.point.lower(): point.normal
for point in battery_unit.points
},
)
for serial_number, battery_unit in battery_units.items()
},
)

View File

@@ -1,10 +1,13 @@
"""Base entity for Zinvolt integration."""
from zinvolt.models import Unit
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ZinvoltDeviceCoordinator
from .coordinator import BatteryData, ZinvoltDeviceCoordinator
class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]):
@@ -20,6 +23,55 @@ class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]):
manufacturer="Zinvolt",
name=coordinator.battery.name,
serial_number=coordinator.data.battery.serial_number,
model_id=coordinator.data.model,
sw_version=coordinator.data.sw_version,
)
class ZinvoltUnitEntity(ZinvoltEntity):
"""Base entity for Zinvolt units."""
def __init__(
self, coordinator: ZinvoltDeviceCoordinator, unit_serial_number: str
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.unit_serial_number = unit_serial_number
is_main_device = (
list(coordinator.battery_units).index(self.unit_serial_number) == 0
)
self.serial_number = (
coordinator.data.battery.serial_number
if is_main_device
else self.battery_unit.serial_number
)
name = coordinator.battery.name if is_main_device else self.battery_unit.name
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.serial_number)},
manufacturer="Zinvolt",
name=name,
serial_number=self.serial_number,
sw_version=self.battery_unit.version.current_version,
model_id=self.battery.model,
)
if not is_main_device:
self._attr_device_info[ATTR_VIA_DEVICE] = (
DOMAIN,
coordinator.data.battery.serial_number,
)
@property
def battery(self) -> BatteryData:
"""Return the battery data."""
return self.coordinator.data.batteries[self.unit_serial_number]
@property
def battery_unit(self) -> Unit:
"""Return the battery unit."""
return self.coordinator.battery_units[self.unit_serial_number]
@property
def available(self) -> bool:
"""Return if the entity is available."""
return (
super().available
and self.unit_serial_number in self.coordinator.data.batteries
)

View File

@@ -4,7 +4,7 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from zinvolt.models import BatteryListResponse, BatteryState, BatteryUnit
from zinvolt.models import BatteryListResponse, BatteryState, BatteryUnit, UnitsResponse
from homeassistant.components.zinvolt.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
@@ -57,4 +57,7 @@ def mock_zinvolt_client() -> Generator[AsyncMock]:
client.get_battery_unit.return_value = BatteryUnit.from_json(
load_fixture("battery_unit.json", DOMAIN)
)
client.get_units.return_value = UnitsResponse.from_json(
load_fixture("units.json", DOMAIN)
).units
yield client

View File

@@ -0,0 +1,48 @@
{
"units": [
{
"usn": "INV001",
"name": "Inverter",
"type": "INVERTER",
"abnormalAmount": 0,
"resettable": false,
"version": {
"currentVersion": "V1.032",
"status": "NO_UPDATE"
}
},
{
"usn": "ems",
"name": "EMS",
"type": "EMS",
"abnormalAmount": 0,
"resettable": false,
"version": {
"currentVersion": "V1.01.45E",
"status": "NO_UPDATE"
}
},
{
"usn": "BAT001",
"name": "Battery - 1",
"type": "BATTERY",
"abnormalAmount": 0,
"resettable": false,
"version": {
"currentVersion": "V1.20",
"status": "NO_UPDATE"
}
},
{
"usn": "BAT002",
"name": "Battery - 2",
"type": "BATTERY",
"abnormalAmount": 0,
"resettable": false,
"version": {
"currentVersion": "V1.20",
"status": "NO_UPDATE"
}
}
]
}

View File

@@ -1,4 +1,361 @@
# serializer version: 1
# name: test_all_entities[binary_sensor.battery_2_charge-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.battery_2_charge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Charge',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Charge',
'platform': 'zinvolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'charge',
'unique_id': 'BAT002.charge',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.battery_2_charge-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Battery - 2 Charge',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.battery_2_charge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.battery_2_communication-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.battery_2_communication',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Communication',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Communication',
'platform': 'zinvolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'communication',
'unique_id': 'BAT002.communication',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.battery_2_communication-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Battery - 2 Communication',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.battery_2_communication',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.battery_2_current-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.battery_2_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Current',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Current',
'platform': 'zinvolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'current',
'unique_id': 'BAT002.current',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.battery_2_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Battery - 2 Current',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.battery_2_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.battery_2_discharge-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.battery_2_discharge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Discharge',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Discharge',
'platform': 'zinvolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'discharge',
'unique_id': 'BAT002.discharge',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.battery_2_discharge-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Battery - 2 Discharge',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.battery_2_discharge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.battery_2_heat-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.battery_2_heat',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Heat',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.HEAT: 'heat'>,
'original_icon': None,
'original_name': 'Heat',
'platform': 'zinvolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'temperature',
'unique_id': 'BAT002.temperature',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.battery_2_heat-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'heat',
'friendly_name': 'Battery - 2 Heat',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.battery_2_heat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.battery_2_other_problems-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.battery_2_other_problems',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Other problems',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Other problems',
'platform': 'zinvolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'other',
'unique_id': 'BAT002.other',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.battery_2_other_problems-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Battery - 2 Other problems',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.battery_2_other_problems',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.battery_2_voltage-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.battery_2_voltage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Voltage',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Voltage',
'platform': 'zinvolt',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'voltage',
'unique_id': 'BAT002.voltage',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.battery_2_voltage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Battery - 2 Voltage',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.battery_2_voltage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.zinvolt_batterij_charge-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -4,6 +4,34 @@
'coordinators': list([
dict({
'a125ef17-6bdf-45ad-b106-ce54e95e4634': dict({
'batteries': dict({
'BAT001': dict({
'model': 'ZVS4000',
'points': dict({
'charge': True,
'communication': True,
'current': True,
'discharge': True,
'other': True,
'temperature': True,
'voltage': True,
}),
'sw_version': 'V1.02-V0.00.000',
}),
'BAT002': dict({
'model': 'ZVS4000',
'points': dict({
'charge': True,
'communication': True,
'current': True,
'discharge': True,
'other': True,
'temperature': True,
'voltage': True,
}),
'sw_version': 'V1.02-V0.00.000',
}),
}),
'battery': dict({
'current_power': dict({
'is_dormant': False,
@@ -29,17 +57,6 @@
'serial_number': 'ZVG011025120088',
'smart_mode': 'CHARGED',
}),
'model': 'ZVS4000',
'points': dict({
'charge': True,
'communication': True,
'current': True,
'discharge': True,
'other': True,
'temperature': True,
'voltage': True,
}),
'sw_version': 'V1.02-V0.00.000',
}),
}),
]),

View File

@@ -1,5 +1,36 @@
# serializer version: 1
# name: test_device
# name: test_device[BAT002]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'zinvolt',
'BAT002',
),
}),
'labels': set({
}),
'manufacturer': 'Zinvolt',
'model': None,
'model_id': 'ZVS4000',
'name': 'Battery - 2',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'BAT002',
'sw_version': 'V1.20',
'via_device_id': <ANY>,
})
# ---
# name: test_device[ZVG011025120088]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
@@ -26,7 +57,7 @@
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'ZVG011025120088',
'sw_version': 'V1.02-V0.00.000',
'sw_version': 'V1.20',
'via_device_id': None,
})
# ---

View File

@@ -4,7 +4,6 @@ from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.zinvolt.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@@ -22,6 +21,6 @@ async def test_device(
) -> None:
"""Test the Zinvolt device."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device({(DOMAIN, "ZVG011025120088")})
assert device
assert device == snapshot
devices = device_registry.devices
for device in devices.values():
assert device == snapshot(name=list(device.identifiers)[0][1])