From a3febc4449375868c8968350262b8dd84170e76b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 21 Dec 2024 14:23:16 +0100 Subject: [PATCH] Add select platform to Peblar Rocksolid EV Chargers integration (#133720) --- homeassistant/components/peblar/__init__.py | 9 +- .../components/peblar/coordinator.py | 38 +++++++- .../components/peblar/diagnostics.py | 1 + homeassistant/components/peblar/icons.json | 11 +++ homeassistant/components/peblar/select.py | 95 +++++++++++++++++++ homeassistant/components/peblar/strings.json | 12 +++ tests/components/peblar/conftest.py | 10 +- .../peblar/fixtures/user_configuration.json | 59 ++++++++++++ .../peblar/snapshots/test_diagnostics.ambr | 61 ++++++++++++ .../peblar/snapshots/test_select.ambr | 62 ++++++++++++ tests/components/peblar/test_select.py | 35 +++++++ 11 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/peblar/select.py create mode 100644 tests/components/peblar/fixtures/user_configuration.json create mode 100644 tests/components/peblar/snapshots/test_select.ambr create mode 100644 tests/components/peblar/test_select.py diff --git a/homeassistant/components/peblar/__init__.py b/homeassistant/components/peblar/__init__.py index d1da6ce83b7..79ffd236f32 100644 --- a/homeassistant/components/peblar/__init__.py +++ b/homeassistant/components/peblar/__init__.py @@ -24,10 +24,12 @@ from .coordinator import ( PeblarConfigEntry, PeblarMeterDataUpdateCoordinator, PeblarRuntimeData, + PeblarUserConfigurationDataUpdateCoordinator, PeblarVersionDataUpdateCoordinator, ) PLATFORMS = [ + Platform.SELECT, Platform.SENSOR, Platform.UPDATE, ] @@ -56,16 +58,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bo # Setup the data coordinators meter_coordinator = PeblarMeterDataUpdateCoordinator(hass, entry, api) + user_configuration_coordinator = PeblarUserConfigurationDataUpdateCoordinator( + hass, entry, peblar + ) version_coordinator = PeblarVersionDataUpdateCoordinator(hass, entry, peblar) await asyncio.gather( meter_coordinator.async_config_entry_first_refresh(), + user_configuration_coordinator.async_config_entry_first_refresh(), version_coordinator.async_config_entry_first_refresh(), ) # Store the runtime data entry.runtime_data = PeblarRuntimeData( - system_information=system_information, meter_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 f83ed8f4dda..a01e3d6b41a 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -5,7 +5,14 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from peblar import Peblar, PeblarApi, PeblarError, PeblarMeter, PeblarVersions +from peblar import ( + Peblar, + PeblarApi, + PeblarError, + PeblarMeter, + PeblarUserConfiguration, + PeblarVersions, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -19,8 +26,9 @@ from .const import LOGGER class PeblarRuntimeData: """Class to hold runtime data.""" - system_information: PeblarSystemInformation meter_coordinator: PeblarMeterDataUpdateCoordinator + system_information: PeblarSystemInformation + user_configuraton_coordinator: PeblarUserConfigurationDataUpdateCoordinator version_coordinator: PeblarVersionDataUpdateCoordinator @@ -86,3 +94,29 @@ class PeblarMeterDataUpdateCoordinator(DataUpdateCoordinator[PeblarMeter]): return await self.api.meter() except PeblarError as err: raise UpdateFailed(err) from err + + +class PeblarUserConfigurationDataUpdateCoordinator( + DataUpdateCoordinator[PeblarUserConfiguration] +): + """Class to manage fetching Peblar user configuration data.""" + + def __init__( + self, hass: HomeAssistant, entry: PeblarConfigEntry, peblar: Peblar + ) -> None: + """Initialize the coordinator.""" + self.peblar = peblar + super().__init__( + hass, + LOGGER, + config_entry=entry, + name=f"Peblar {entry.title} user configuration", + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self) -> PeblarUserConfiguration: + """Fetch data from the Peblar device.""" + try: + return await self.peblar.user_configuration() + except PeblarError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/peblar/diagnostics.py b/homeassistant/components/peblar/diagnostics.py index 91cdb5dc811..6c4531c0e09 100644 --- a/homeassistant/components/peblar/diagnostics.py +++ b/homeassistant/components/peblar/diagnostics.py @@ -15,6 +15,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" 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(), "versions": { "available": entry.runtime_data.version_coordinator.data.available.to_dict(), diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index 073cd08a2c7..b052eb6de4d 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -1,5 +1,16 @@ { "entity": { + "select": { + "smart_charging": { + "default": "mdi:lightning-bolt", + "state": { + "fast_solar": "mdi:solar-power", + "pure_solar": "mdi:solar-power-variant", + "scheduled": "mdi:calendar-clock", + "smart_solar": "mdi:solar-power" + } + } + }, "update": { "customization": { "default": "mdi:palette" diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py new file mode 100644 index 00000000000..95a87248804 --- /dev/null +++ b/homeassistant/components/peblar/select.py @@ -0,0 +1,95 @@ +"""Support for Peblar selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from peblar import Peblar, PeblarUserConfiguration, SmartChargingMode + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +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, PeblarUserConfigurationDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class PeblarSelectEntityDescription(SelectEntityDescription): + """Class describing Peblar select entities.""" + + current_fn: Callable[[PeblarUserConfiguration], str | None] + select_fn: Callable[[Peblar, str], Awaitable[Any]] + + +DESCRIPTIONS = [ + PeblarSelectEntityDescription( + key="smart_charging", + translation_key="smart_charging", + entity_category=EntityCategory.CONFIG, + options=[ + "default", + "fast_solar", + "pure_solar", + "scheduled", + "smart_solar", + ], + current_fn=lambda x: x.smart_charging.value if x.smart_charging else None, + select_fn=lambda x, mode: x.smart_charging(SmartChargingMode(mode)), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PeblarConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Peblar select based on a config entry.""" + async_add_entities( + PeblarSelectEntity( + entry=entry, + description=description, + ) + for description in DESCRIPTIONS + ) + + +class PeblarSelectEntity( + CoordinatorEntity[PeblarUserConfigurationDataUpdateCoordinator], SelectEntity +): + """Defines a peblar select entity.""" + + entity_description: PeblarSelectEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + entry: PeblarConfigEntry, + description: PeblarSelectEntityDescription, + ) -> None: + """Initialize the select entity.""" + super().__init__(entry.runtime_data.user_configuraton_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) + }, + ) + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.data) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.peblar, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 2e23fcfcdcd..a36cd14fe48 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -33,6 +33,18 @@ } }, "entity": { + "select": { + "smart_charging": { + "name": "Smart charging", + "state": { + "default": "Default", + "fast_solar": "Fast solar", + "pure_solar": "Pure solar", + "scheduled": "Scheduled", + "smart_solar": "Smart solar" + } + } + }, "update": { "customization": { "name": "Customization" diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index 2db28d3a7e6..8831697f74e 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -6,7 +6,12 @@ from collections.abc import Generator from contextlib import nullcontext from unittest.mock import MagicMock, patch -from peblar import PeblarMeter, PeblarSystemInformation, PeblarVersions +from peblar import ( + PeblarMeter, + PeblarSystemInformation, + PeblarUserConfiguration, + PeblarVersions, +) import pytest from homeassistant.components.peblar.const import DOMAIN @@ -51,6 +56,9 @@ def mock_peblar() -> Generator[MagicMock]: peblar.current_versions.return_value = PeblarVersions.from_json( load_fixture("current_versions.json", DOMAIN) ) + peblar.user_configuration.return_value = PeblarUserConfiguration.from_json( + load_fixture("user_configuration.json", DOMAIN) + ) peblar.system_information.return_value = PeblarSystemInformation.from_json( load_fixture("system_information.json", DOMAIN) ) diff --git a/tests/components/peblar/fixtures/user_configuration.json b/tests/components/peblar/fixtures/user_configuration.json new file mode 100644 index 00000000000..b778ad35f18 --- /dev/null +++ b/tests/components/peblar/fixtures/user_configuration.json @@ -0,0 +1,59 @@ +{ + "BopFallbackCurrent": 6000, + "BopHomeWizardAddress": "p1meter-093586", + "BopSource": "homewizard", + "BopSourceParameters": "{}", + "ConnectedPhases": 1, + "CurrentCtrlBopCtType": "CTK05-14", + "CurrentCtrlBopEnable": true, + "CurrentCtrlBopFuseRating": 35, + "CurrentCtrlFixedChargeCurrentLimit": 16, + "GroundMonitoring": true, + "GroupLoadBalancingEnable": false, + "GroupLoadBalancingFallbackCurrent": 6, + "GroupLoadBalancingGroupId": 1, + "GroupLoadBalancingInterface": "RS485", + "GroupLoadBalancingMaxCurrent": 0, + "GroupLoadBalancingRole": "", + "HmiBuzzerVolume": 1, + "HmiLedIntensityManual": 0, + "HmiLedIntensityMax": 100, + "HmiLedIntensityMin": 1, + "HmiLedIntensityMode": "Fixed", + "LocalRestApiAccessMode": "ReadWrite", + "LocalRestApiAllowed": true, + "LocalRestApiEnable": true, + "LocalSmartChargingAllowed": true, + "ModbusServerAccessMode": "ReadOnly", + "ModbusServerAllowed": true, + "ModbusServerEnable": true, + "PhaseRotation": "RST", + "PowerLimitInputDi1Inverse": false, + "PowerLimitInputDi1Limit": 6, + "PowerLimitInputDi2Inverse": false, + "PowerLimitInputDi2Limit": 0, + "PowerLimitInputEnable": false, + "PredefinedCpoName": "", + "ScheduledChargingAllowed": true, + "ScheduledChargingEnable": false, + "SeccOcppActive": false, + "SeccOcppUri": "", + "SessionManagerChargeWithoutAuth": false, + "SolarChargingAllowed": true, + "SolarChargingEnable": true, + "SolarChargingMode": "PureSolar", + "SolarChargingSource": "homewizard", + "SolarChargingSourceParameters": "{\"address\":\"p1meter-093586\"}", + "TimeZone": "Europe/Amsterdam", + "UserDefinedChargeLimitCurrent": 16, + "UserDefinedChargeLimitCurrentAllowed": true, + "UserDefinedHouseholdPowerLimit": 20000, + "UserDefinedHouseholdPowerLimitAllowed": true, + "UserDefinedHouseholdPowerLimitEnable": false, + "UserDefinedHouseholdPowerLimitSource": "homewizard", + "UserDefinedHouseholdPowerLimitSourceParameters": "{\"address\":\"p1meter-093586\"}", + "UserKeepSocketLocked": false, + "VDEPhaseImbalanceEnable": false, + "VDEPhaseImbalanceLimit": 20, + "WebIfUpdateHelper": true +} diff --git a/tests/components/peblar/snapshots/test_diagnostics.ambr b/tests/components/peblar/snapshots/test_diagnostics.ambr index 7701c1eb159..fa6eb857e09 100644 --- a/tests/components/peblar/snapshots/test_diagnostics.ambr +++ b/tests/components/peblar/snapshots/test_diagnostics.ambr @@ -75,6 +75,67 @@ 'WlanApMacAddr': '00:0F:11:58:86:98', 'WlanStaMacAddr': '00:0F:11:58:86:99', }), + 'user_configuration': dict({ + 'BopFallbackCurrent': 6000, + 'BopHomeWizardAddress': 'p1meter-093586', + 'BopSource': 'homewizard', + 'BopSourceParameters': '{}', + 'ConnectedPhases': 1, + 'CurrentCtrlBopCtType': 'CTK05-14', + 'CurrentCtrlBopEnable': True, + 'CurrentCtrlBopFuseRating': 35, + 'CurrentCtrlFixedChargeCurrentLimit': 16, + 'GroundMonitoring': True, + 'GroupLoadBalancingEnable': False, + 'GroupLoadBalancingFallbackCurrent': 6, + 'GroupLoadBalancingGroupId': 1, + 'GroupLoadBalancingInterface': 'RS485', + 'GroupLoadBalancingMaxCurrent': 0, + 'GroupLoadBalancingRole': '', + 'HmiBuzzerVolume': 1, + 'HmiLedIntensityManual': 0, + 'HmiLedIntensityMax': 100, + 'HmiLedIntensityMin': 1, + 'HmiLedIntensityMode': 'Fixed', + 'LocalRestApiAccessMode': 'ReadWrite', + 'LocalRestApiAllowed': True, + 'LocalRestApiEnable': True, + 'LocalSmartChargingAllowed': True, + 'ModbusServerAccessMode': 'ReadOnly', + 'ModbusServerAllowed': True, + 'ModbusServerEnable': True, + 'PhaseRotation': 'RST', + 'PowerLimitInputDi1Inverse': False, + 'PowerLimitInputDi1Limit': 6, + 'PowerLimitInputDi2Inverse': False, + 'PowerLimitInputDi2Limit': 0, + 'PowerLimitInputEnable': False, + 'PredefinedCpoName': '', + 'ScheduledChargingAllowed': True, + 'ScheduledChargingEnable': False, + 'SeccOcppActive': False, + 'SeccOcppUri': '', + 'SessionManagerChargeWithoutAuth': False, + 'SolarChargingAllowed': True, + 'SolarChargingEnable': True, + 'SolarChargingMode': 'PureSolar', + 'SolarChargingSource': 'homewizard', + 'SolarChargingSourceParameters': dict({ + 'address': 'p1meter-093586', + }), + 'TimeZone': 'Europe/Amsterdam', + 'UserDefinedChargeLimitCurrent': 16, + 'UserDefinedChargeLimitCurrentAllowed': True, + 'UserDefinedHouseholdPowerLimit': 20000, + 'UserDefinedHouseholdPowerLimitAllowed': True, + 'UserDefinedHouseholdPowerLimitEnable': False, + 'UserDefinedHouseholdPowerLimitSource': 'homewizard', + 'UserKeepSocketLocked': False, + 'VDEPhaseImbalanceEnable': False, + 'VDEPhaseImbalanceLimit': 20, + 'WebIfUpdateHelper': True, + 'smart_charging': 'pure_solar', + }), 'versions': dict({ 'available': dict({ 'Customization': 'Peblar-1.9', diff --git a/tests/components/peblar/snapshots/test_select.ambr b/tests/components/peblar/snapshots/test_select.ambr new file mode 100644 index 00000000000..9f0852d7cf4 --- /dev/null +++ b/tests/components/peblar/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_entities[select][select.peblar_ev_charger_smart_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'default', + 'fast_solar', + 'pure_solar', + 'scheduled', + 'smart_solar', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.peblar_ev_charger_smart_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Smart charging', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'smart_charging', + 'unique_id': '23-45-A4O-MOF-smart_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select][select.peblar_ev_charger_smart_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Peblar EV Charger Smart charging', + 'options': list([ + 'default', + 'fast_solar', + 'pure_solar', + 'scheduled', + 'smart_solar', + ]), + }), + 'context': , + 'entity_id': 'select.peblar_ev_charger_smart_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pure_solar', + }) +# --- diff --git a/tests/components/peblar/test_select.py b/tests/components/peblar/test_select.py new file mode 100644 index 00000000000..e20d84da755 --- /dev/null +++ b/tests/components/peblar/test_select.py @@ -0,0 +1,35 @@ +"""Tests for the Peblar select 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.SELECT], 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 select 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