From 04276d352317fff120d2c98c446e83d85bb24cd0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 21 Dec 2024 20:16:18 +0100 Subject: [PATCH] Add number platform to Peblar Rocksolid EV Chargers integration (#133739) --- homeassistant/components/peblar/__init__.py | 7 +- .../components/peblar/coordinator.py | 27 ++++- .../components/peblar/diagnostics.py | 3 +- homeassistant/components/peblar/icons.json | 5 + homeassistant/components/peblar/number.py | 104 ++++++++++++++++++ homeassistant/components/peblar/sensor.py | 42 ++++--- homeassistant/components/peblar/strings.json | 5 + tests/components/peblar/conftest.py | 4 + .../peblar/fixtures/ev_interface.json | 7 ++ .../peblar/snapshots/test_diagnostics.ambr | 7 ++ .../peblar/snapshots/test_number.ambr | 58 ++++++++++ tests/components/peblar/test_number.py | 35 ++++++ 12 files changed, 273 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/peblar/number.py create mode 100644 tests/components/peblar/fixtures/ev_interface.json create mode 100644 tests/components/peblar/snapshots/test_number.ambr create mode 100644 tests/components/peblar/test_number.py diff --git a/homeassistant/components/peblar/__init__.py b/homeassistant/components/peblar/__init__.py index 79ffd236f32..2ab255037ac 100644 --- a/homeassistant/components/peblar/__init__.py +++ b/homeassistant/components/peblar/__init__.py @@ -22,13 +22,14 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import DOMAIN from .coordinator import ( PeblarConfigEntry, - PeblarMeterDataUpdateCoordinator, + PeblarDataUpdateCoordinator, PeblarRuntimeData, PeblarUserConfigurationDataUpdateCoordinator, PeblarVersionDataUpdateCoordinator, ) PLATFORMS = [ + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.UPDATE, @@ -57,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo ) from err # Setup the data coordinators - meter_coordinator = PeblarMeterDataUpdateCoordinator(hass, entry, api) + meter_coordinator = PeblarDataUpdateCoordinator(hass, entry, api) user_configuration_coordinator = PeblarUserConfigurationDataUpdateCoordinator( hass, entry, peblar ) @@ -70,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo # Store the runtime data entry.runtime_data = PeblarRuntimeData( - meter_coordinator=meter_coordinator, + data_coordinator=meter_coordinator, system_information=system_information, user_configuraton_coordinator=user_configuration_coordinator, version_coordinator=version_coordinator, diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index a01e3d6b41a..33c66266e47 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -9,6 +9,7 @@ from peblar import ( Peblar, PeblarApi, PeblarError, + PeblarEVInterface, PeblarMeter, PeblarUserConfiguration, PeblarVersions, @@ -26,7 +27,7 @@ from .const import LOGGER class PeblarRuntimeData: """Class to hold runtime data.""" - meter_coordinator: PeblarMeterDataUpdateCoordinator + data_coordinator: PeblarDataUpdateCoordinator system_information: PeblarSystemInformation user_configuraton_coordinator: PeblarUserConfigurationDataUpdateCoordinator version_coordinator: PeblarVersionDataUpdateCoordinator @@ -43,6 +44,19 @@ class PeblarVersionInformation: available: PeblarVersions +@dataclass(kw_only=True) +class PeblarData: + """Class to hold active charging related information of Peblar. + + This is data that needs to be polled and updated at a relatively high + frequency in order for this integration to function correctly. + All this data is updated at the same time by a single coordinator. + """ + + ev: PeblarEVInterface + meter: PeblarMeter + + class PeblarVersionDataUpdateCoordinator( DataUpdateCoordinator[PeblarVersionInformation] ): @@ -72,8 +86,8 @@ class PeblarVersionDataUpdateCoordinator( raise UpdateFailed(err) from err -class PeblarMeterDataUpdateCoordinator(DataUpdateCoordinator[PeblarMeter]): - """Class to manage fetching Peblar meter data.""" +class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): + """Class to manage fetching Peblar active data.""" def __init__( self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi @@ -88,10 +102,13 @@ class PeblarMeterDataUpdateCoordinator(DataUpdateCoordinator[PeblarMeter]): update_interval=timedelta(seconds=10), ) - async def _async_update_data(self) -> PeblarMeter: + async def _async_update_data(self) -> PeblarData: """Fetch data from the Peblar device.""" try: - return await self.api.meter() + return PeblarData( + ev=await self.api.ev_interface(), + meter=await self.api.meter(), + ) except PeblarError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/peblar/diagnostics.py b/homeassistant/components/peblar/diagnostics.py index 6c4531c0e09..ab18956ecbb 100644 --- a/homeassistant/components/peblar/diagnostics.py +++ b/homeassistant/components/peblar/diagnostics.py @@ -16,7 +16,8 @@ async def async_get_config_entry_diagnostics( return { "system_information": entry.runtime_data.system_information.to_dict(), "user_configuration": entry.runtime_data.user_configuraton_coordinator.data.to_dict(), - "meter": entry.runtime_data.meter_coordinator.data.to_dict(), + "ev": entry.runtime_data.data_coordinator.data.ev.to_dict(), + "meter": entry.runtime_data.data_coordinator.data.meter.to_dict(), "versions": { "available": entry.runtime_data.version_coordinator.data.available.to_dict(), "current": entry.runtime_data.version_coordinator.data.current.to_dict(), diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index b052eb6de4d..3ead366f4bf 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -1,5 +1,10 @@ { "entity": { + "number": { + "charge_current_limit": { + "default": "mdi:speedometer" + } + }, "select": { "smart_charging": { "default": "mdi:lightning-bolt", diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py new file mode 100644 index 00000000000..72c7b02a6e0 --- /dev/null +++ b/homeassistant/components/peblar/number.py @@ -0,0 +1,104 @@ +"""Support for Peblar numbers.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from peblar import PeblarApi + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ( + PeblarConfigEntry, + PeblarData, + PeblarDataUpdateCoordinator, + PeblarRuntimeData, +) + + +@dataclass(frozen=True, kw_only=True) +class PeblarNumberEntityDescription(NumberEntityDescription): + """Describe a Peblar number.""" + + native_max_value_fn: Callable[[PeblarRuntimeData], int] + set_value_fn: Callable[[PeblarApi, float], Awaitable[Any]] + value_fn: Callable[[PeblarData], int | None] + + +DESCRIPTIONS = [ + PeblarNumberEntityDescription( + key="charge_current_limit", + translation_key="charge_current_limit", + device_class=NumberDeviceClass.CURRENT, + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=6, + native_max_value_fn=lambda x: x.system_information.hardware_max_current, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + set_value_fn=lambda x, v: x.ev_interface(charge_current_limit=int(v) * 1000), + value_fn=lambda x: round(x.ev.charge_current_limit_actual / 1000), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar number based on a config entry.""" + async_add_entities( + PeblarNumberEntity( + entry=entry, + description=description, + ) + for description in DESCRIPTIONS + ) + + +class PeblarNumberEntity(CoordinatorEntity[PeblarDataUpdateCoordinator], NumberEntity): + """Defines a Peblar number.""" + + entity_description: PeblarNumberEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + entry: PeblarConfigEntry, + description: PeblarNumberEntityDescription, + ) -> None: + """Initialize the Peblar entity.""" + super().__init__(entry.runtime_data.data_coordinator) + self.entity_description = description + self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, entry.runtime_data.system_information.product_serial_number) + }, + ) + self._attr_native_max_value = description.native_max_value_fn( + entry.runtime_data + ) + + @property + def native_value(self) -> int | None: + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Change to new number value.""" + await self.entity_description.set_value_fn(self.coordinator.api, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/sensor.py b/homeassistant/components/peblar/sensor.py index bb9fe9d4937..285a8dd5ea0 100644 --- a/homeassistant/components/peblar/sensor.py +++ b/homeassistant/components/peblar/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from peblar import PeblarMeter, PeblarUserConfiguration +from peblar import PeblarUserConfiguration from homeassistant.components.sensor import ( SensorDeviceClass, @@ -26,15 +26,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PeblarConfigEntry, PeblarMeterDataUpdateCoordinator +from .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) class PeblarSensorDescription(SensorEntityDescription): - """Describe an Peblar sensor.""" + """Describe a Peblar sensor.""" has_fn: Callable[[PeblarUserConfiguration], bool] = lambda _: True - value_fn: Callable[[PeblarMeter], int | None] + value_fn: Callable[[PeblarData], int | None] DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( @@ -48,7 +48,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda x: x.current_phase_1, + value_fn=lambda x: x.meter.current_phase_1, ), PeblarSensorDescription( key="current_phase_1", @@ -61,7 +61,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda x: x.current_phase_1, + value_fn=lambda x: x.meter.current_phase_1, ), PeblarSensorDescription( key="current_phase_2", @@ -74,7 +74,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda x: x.current_phase_2, + value_fn=lambda x: x.meter.current_phase_2, ), PeblarSensorDescription( key="current_phase_3", @@ -87,7 +87,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - value_fn=lambda x: x.current_phase_3, + value_fn=lambda x: x.meter.current_phase_3, ), PeblarSensorDescription( key="energy_session", @@ -97,7 +97,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda x: x.energy_session, + value_fn=lambda x: x.meter.energy_session, ), PeblarSensorDescription( key="energy_total", @@ -108,14 +108,14 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( state_class=SensorStateClass.TOTAL_INCREASING, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - value_fn=lambda x: x.energy_total, + value_fn=lambda x: x.meter.energy_total, ), PeblarSensorDescription( key="power_total", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.power_total, + value_fn=lambda x: x.meter.power_total, ), PeblarSensorDescription( key="power_phase_1", @@ -126,7 +126,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( has_fn=lambda x: x.connected_phases >= 2, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.power_phase_1, + value_fn=lambda x: x.meter.power_phase_1, ), PeblarSensorDescription( key="power_phase_2", @@ -137,7 +137,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( has_fn=lambda x: x.connected_phases >= 2, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.power_phase_2, + value_fn=lambda x: x.meter.power_phase_2, ), PeblarSensorDescription( key="power_phase_3", @@ -148,7 +148,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( has_fn=lambda x: x.connected_phases == 3, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.power_phase_3, + value_fn=lambda x: x.meter.power_phase_3, ), PeblarSensorDescription( key="voltage", @@ -158,7 +158,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( has_fn=lambda x: x.connected_phases == 1, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.voltage_phase_1, + value_fn=lambda x: x.meter.voltage_phase_1, ), PeblarSensorDescription( key="voltage_phase_1", @@ -169,7 +169,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( has_fn=lambda x: x.connected_phases >= 2, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.voltage_phase_1, + value_fn=lambda x: x.meter.voltage_phase_1, ), PeblarSensorDescription( key="voltage_phase_2", @@ -180,7 +180,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( has_fn=lambda x: x.connected_phases >= 2, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.voltage_phase_2, + value_fn=lambda x: x.meter.voltage_phase_2, ), PeblarSensorDescription( key="voltage_phase_3", @@ -191,7 +191,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = ( has_fn=lambda x: x.connected_phases == 3, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda x: x.voltage_phase_3, + value_fn=lambda x: x.meter.voltage_phase_3, ), ) @@ -209,9 +209,7 @@ async def async_setup_entry( ) -class PeblarSensorEntity( - CoordinatorEntity[PeblarMeterDataUpdateCoordinator], SensorEntity -): +class PeblarSensorEntity(CoordinatorEntity[PeblarDataUpdateCoordinator], SensorEntity): """Defines a Peblar sensor.""" entity_description: PeblarSensorDescription @@ -224,7 +222,7 @@ class PeblarSensorEntity( description: PeblarSensorDescription, ) -> None: """Initialize the Peblar entity.""" - super().__init__(entry.runtime_data.meter_coordinator) + super().__init__(entry.runtime_data.data_coordinator) self.entity_description = description self._attr_unique_id = f"{entry.unique_id}_{description.key}" self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 02aee0eacc9..e4311df17cd 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -33,6 +33,11 @@ } }, "entity": { + "number": { + "charge_current_limit": { + "name": "Charge limit" + } + }, "select": { "smart_charging": { "name": "Smart charging", diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index 8831697f74e..b8e77da08cd 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -7,6 +7,7 @@ from contextlib import nullcontext from unittest.mock import MagicMock, patch from peblar import ( + PeblarEVInterface, PeblarMeter, PeblarSystemInformation, PeblarUserConfiguration, @@ -64,6 +65,9 @@ def mock_peblar() -> Generator[MagicMock]: ) api = peblar.rest_api.return_value + api.ev_interface.return_value = PeblarEVInterface.from_json( + load_fixture("ev_interface.json", DOMAIN) + ) api.meter.return_value = PeblarMeter.from_json( load_fixture("meter.json", DOMAIN) ) diff --git a/tests/components/peblar/fixtures/ev_interface.json b/tests/components/peblar/fixtures/ev_interface.json new file mode 100644 index 00000000000..901807a7068 --- /dev/null +++ b/tests/components/peblar/fixtures/ev_interface.json @@ -0,0 +1,7 @@ +{ + "ChargeCurrentLimit": 16000, + "ChargeCurrentLimitActual": 6000, + "ChargeCurrentLimitSource": "Current limiter", + "CpState": "State C", + "Force1Phase": false +} diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index 08d4d3ac6c6..625bb196402 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -1,6 +1,13 @@ # serializer version: 1 # name: test_diagnostics dict({ + 'ev': dict({ + 'ChargeCurrentLimit': 16000, + 'ChargeCurrentLimitActual': 6000, + 'ChargeCurrentLimitSource': 'Current limiter', + 'CpState': 'State C', + 'Force1Phase': False, + }), 'meter': dict({ 'CurrentPhase1': 14242, 'CurrentPhase2': 0, diff --git a/tests/components/peblar/snapshots/test_number.ambr b/tests/components/peblar/snapshots/test_number.ambr new file mode 100644 index 00000000000..50b44583d1c --- /dev/null +++ b/tests/components/peblar/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_entities[number][number.peblar_ev_charger_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 16, + 'min': 6, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.peblar_ev_charger_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_current_limit', + 'unique_id': '23-45-A4O-MOF_charge_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number][number.peblar_ev_charger_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Peblar EV Charger Charge limit', + 'max': 16, + 'min': 6, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.peblar_ev_charger_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py new file mode 100644 index 00000000000..4c2ff928210 --- /dev/null +++ b/tests/components/peblar/test_number.py @@ -0,0 +1,35 @@ +"""Tests for the Peblar number platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.peblar.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the number entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure all entities are correctly assigned to the Peblar device + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "23-45-A4O-MOF")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id