From 7bf81037c1ed1098b5401e3702ac4091c0811d74 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 8 Feb 2025 15:48:31 +0100 Subject: [PATCH] Add Peblar charge switch (#137853) * Add Peblar charge switch * Update snapshots --- .../components/peblar/coordinator.py | 3 + homeassistant/components/peblar/icons.json | 3 + homeassistant/components/peblar/number.py | 150 +++++++++++------- homeassistant/components/peblar/strings.json | 3 + homeassistant/components/peblar/switch.py | 29 +++- .../peblar/snapshots/test_switch.ambr | 46 ++++++ tests/components/peblar/test_number.py | 108 +++++++++++-- tests/components/peblar/test_switch.py | 37 ++++- 8 files changed, 296 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/peblar/coordinator.py b/homeassistant/components/peblar/coordinator.py index 058f2aefb3b..36708b207c5 100644 --- a/homeassistant/components/peblar/coordinator.py +++ b/homeassistant/components/peblar/coordinator.py @@ -34,6 +34,7 @@ class PeblarRuntimeData: """Class to hold runtime data.""" data_coordinator: PeblarDataUpdateCoordinator + last_known_charging_limit = 6 system_information: PeblarSystemInformation user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator version_coordinator: PeblarVersionDataUpdateCoordinator @@ -137,6 +138,8 @@ class PeblarVersionDataUpdateCoordinator( class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): """Class to manage fetching Peblar active data.""" + config_entry: PeblarConfigEntry + def __init__( self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi ) -> None: diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index 6244945077b..a954d112c4a 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -36,6 +36,9 @@ } }, "switch": { + "charge": { + "default": "mdi:ev-plug-type2" + }, "force_single_phase": { "default": "mdi:power-cycle" } diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py index 1a7cec43295..0e929a63523 100644 --- a/homeassistant/components/peblar/number.py +++ b/homeassistant/components/peblar/number.py @@ -2,58 +2,27 @@ 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, + RestoreNumber, ) -from homeassistant.const import EntityCategory, UnitOfElectricCurrent -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + EntityCategory, + UnitOfElectricCurrent, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .coordinator import ( - PeblarConfigEntry, - PeblarData, - PeblarDataUpdateCoordinator, - PeblarRuntimeData, -) +from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator from .entity import PeblarEntity from .helpers import peblar_exception_handler PARALLEL_UPDATES = 1 -@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.user_configuration_coordinator.data.user_defined_charge_limit_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 / 1000), - ), -] - - async def async_setup_entry( hass: HomeAssistant, entry: PeblarConfigEntry, @@ -61,42 +30,101 @@ async def async_setup_entry( ) -> None: """Set up Peblar number based on a config entry.""" async_add_entities( - PeblarNumberEntity( - entry=entry, - coordinator=entry.runtime_data.data_coordinator, - description=description, - ) - for description in DESCRIPTIONS + [ + PeblarChargeCurrentLimitNumberEntity( + entry=entry, + coordinator=entry.runtime_data.data_coordinator, + ) + ] ) -class PeblarNumberEntity( +class PeblarChargeCurrentLimitNumberEntity( PeblarEntity[PeblarDataUpdateCoordinator], - NumberEntity, + RestoreNumber, ): - """Defines a Peblar number.""" + """Defines a Peblar charge current limit number. - entity_description: PeblarNumberEntityDescription + This entity is a little bit different from the other entities, any value + below 6 amps is ignored. It means the Peblar is not charging. + Peblar has assigned a dual functionality to the charge current limit + number, it is used to set the current charging value and to start/stop/pauze + the charging process. + """ + + _attr_device_class = NumberDeviceClass.CURRENT + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value = 6 + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + _attr_translation_key = "charge_current_limit" def __init__( self, entry: PeblarConfigEntry, coordinator: PeblarDataUpdateCoordinator, - description: PeblarNumberEntityDescription, ) -> None: - """Initialize the Peblar entity.""" - super().__init__(entry=entry, coordinator=coordinator, description=description) - self._attr_native_max_value = description.native_max_value_fn( - entry.runtime_data + """Initialize the Peblar charge current limit entity.""" + super().__init__( + entry=entry, + coordinator=coordinator, + description=NumberEntityDescription(key="charge_current_limit"), ) + configuration = entry.runtime_data.user_configuration_coordinator.data + self._attr_native_max_value = configuration.user_defined_charge_limit_current - @property - def native_value(self) -> int | None: - """Return the number value.""" - return self.entity_description.value_fn(self.coordinator.data) + async def async_added_to_hass(self) -> None: + """Load the last known state when adding this entity.""" + if ( + (last_state := await self.async_get_last_state()) + and (last_number_data := await self.async_get_last_number_data()) + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and last_number_data.native_value + ): + self._attr_native_value = last_number_data.native_value + # Set the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + last_number_data.native_value + ) + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update. + + Ignore any update that provides a ampere value that is below the + minimum value (6 amps). It means the Peblar is currently not charging. + """ + if ( + current_charge_limit := round( + self.coordinator.data.ev.charge_current_limit / 1000 + ) + ) < 6: + return + self._attr_native_value = current_charge_limit + # Update the last known charging limit in the runtime data the + # start/stop/pauze functionality needs it in order to restore + # the last known charging limits when charging is resumed. + self.coordinator.config_entry.runtime_data.last_known_charging_limit = ( + current_charge_limit + ) + super()._handle_coordinator_update() @peblar_exception_handler 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) + """Change the current charging value.""" + # If charging is currently disabled (below 6 amps), just set the value + # as the native value and the last known charging limit in the runtime + # data. So we can pick it up once charging gets enabled again. + if self.coordinator.data.ev.charge_current_limit < 6000: + self._attr_native_value = int(value) + self.coordinator.config_entry.runtime_data.last_known_charging_limit = int( + value + ) + self.async_write_ha_state() + return + await self.coordinator.api.ev_interface(charge_current_limit=int(value) * 1000) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index a33667fa533..4a1500e54c5 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -153,6 +153,9 @@ } }, "switch": { + "charge": { + "name": "Charge" + }, "force_single_phase": { "name": "Force single phase" } diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py index e56c2fcdaec..74a42ddc47d 100644 --- a/homeassistant/components/peblar/switch.py +++ b/homeassistant/components/peblar/switch.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from peblar import PeblarApi +from peblar import PeblarEVInterface from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -31,7 +31,19 @@ class PeblarSwitchEntityDescription(SwitchEntityDescription): has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True is_on_fn: Callable[[PeblarData], bool] - set_fn: Callable[[PeblarApi, bool], Awaitable[Any]] + set_fn: Callable[[PeblarDataUpdateCoordinator, bool], Awaitable[Any]] + + +def _async_peblar_charge( + coordinator: PeblarDataUpdateCoordinator, on: bool +) -> Awaitable[PeblarEVInterface]: + """Set the charge state.""" + charge_current_limit = 0 + if on: + charge_current_limit = ( + coordinator.config_entry.runtime_data.last_known_charging_limit * 1000 + ) + return coordinator.api.ev_interface(charge_current_limit=charge_current_limit) DESCRIPTIONS = [ @@ -44,7 +56,14 @@ DESCRIPTIONS = [ and x.user_configuration_coordinator.data.connected_phases > 1 ), is_on_fn=lambda x: x.ev.force_single_phase, - set_fn=lambda x, on: x.ev_interface(force_single_phase=on), + set_fn=lambda x, on: x.api.ev_interface(force_single_phase=on), + ), + PeblarSwitchEntityDescription( + key="charge", + translation_key="charge", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda x: (x.ev.charge_current_limit >= 6000), + set_fn=_async_peblar_charge, ), ] @@ -82,11 +101,11 @@ class PeblarSwitchEntity( @peblar_exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_fn(self.coordinator.api, True) + await self.entity_description.set_fn(self.coordinator, True) await self.coordinator.async_request_refresh() @peblar_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.entity_description.set_fn(self.coordinator.api, False) + await self.entity_description.set_fn(self.coordinator, False) await self.coordinator.async_request_refresh() diff --git a/tests/components/peblar/snapshots/test_switch.ambr b/tests/components/peblar/snapshots/test_switch.ambr index 53829278593..426b48b6838 100644 --- a/tests/components/peblar/snapshots/test_switch.ambr +++ b/tests/components/peblar/snapshots/test_switch.ambr @@ -1,4 +1,50 @@ # serializer version: 1 +# name: test_entities[switch][switch.peblar_ev_charger_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.peblar_ev_charger_charge', + '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': 'Charge', + 'platform': 'peblar', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge', + 'unique_id': '23-45-A4O-MOF_charge', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[switch][switch.peblar_ev_charger_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Peblar EV Charger Charge', + }), + 'context': , + 'entity_id': 'switch.peblar_ev_charger_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/peblar/test_number.py b/tests/components/peblar/test_number.py index 57469fecbc6..fa49b6ab116 100644 --- a/tests/components/peblar/test_number.py +++ b/tests/components/peblar/test_number.py @@ -14,18 +14,19 @@ from homeassistant.components.number import ( from homeassistant.components.peblar.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform - -pytestmark = [ - pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True), - pytest.mark.usefixtures("init_integration"), -] +from tests.common import ( + MockConfigEntry, + mock_restore_cache_with_extra_data, + snapshot_platform, +) +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -48,7 +49,8 @@ async def test_entities( assert entity_entry.device_id == device_entry.id -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value( hass: HomeAssistant, mock_peblar: MagicMock, @@ -73,6 +75,43 @@ async def test_number_set_value( mocked_method.mock_calls[0].assert_called_with({"charge_current_limit": 10}) +async def test_number_set_value_when_charging_is_suspended( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test handling of setting the charging limit while charging is suspended.""" + entity_id = "number.peblar_ev_charger_charge_limit" + + # Suspend charging + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mocked_method = mock_peblar.rest_api.return_value.ev_interface + mocked_method.reset_mock() + + # Test normal happy path number value change + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert len(mocked_method.mock_calls) == 0 + + # Check the state is reflected + assert (state := hass.states.get(entity_id)) + assert state.state == "10" + + @pytest.mark.parametrize( ("error", "error_match", "translation_key", "translation_placeholders"), [ @@ -96,7 +135,8 @@ async def test_number_set_value( ), ], ) -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration", "entity_registry_enabled_by_default") async def test_number_set_value_communication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -128,6 +168,8 @@ async def test_number_set_value_communication_error( assert excinfo.value.translation_placeholders == translation_placeholders +@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True) +@pytest.mark.usefixtures("init_integration") async def test_number_set_value_authentication_error( hass: HomeAssistant, mock_peblar: MagicMock, @@ -175,3 +217,51 @@ async def test_number_set_value_authentication_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("restore_state", "restore_native_value", "expected_state"), + [ + ("10", 10, "10"), + ("unknown", 10, "unknown"), + ("unavailable", 10, "unknown"), + ("10", None, "unknown"), + ], +) +async def test_restore_state( + hass: HomeAssistant, + mock_peblar: MagicMock, + mock_config_entry: MockConfigEntry, + restore_state: str, + restore_native_value: int, + expected_state: str, +) -> None: + """Test restoring the number state.""" + EXTRA_STORED_DATA = { + "native_max_value": 16, + "native_min_value": 6, + "native_step": 1, + "native_unit_of_measurement": "A", + "native_value": restore_native_value, + } + mock_restore_cache_with_extra_data( + hass, + ( + ( + State("number.peblar_ev_charger_charge_limit", restore_state), + EXTRA_STORED_DATA, + ), + ), + ) + + # Adjust Peblar client to have an ignored value for the charging limit + mock_peblar.rest_api.return_value.ev_interface.return_value.charge_current_limit = 0 + + # Setup the config entry + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Check if state is restored and value is set correctly + assert (state := hass.states.get("number.peblar_ev_charger_charge_limit")) + assert state.state == expected_state diff --git a/tests/components/peblar/test_switch.py b/tests/components/peblar/test_switch.py index 75deeb2d5d3..a7dab51eb3a 100644 --- a/tests/components/peblar/test_switch.py +++ b/tests/components/peblar/test_switch.py @@ -49,10 +49,32 @@ async def test_entities( @pytest.mark.parametrize( - ("service", "force_single_phase"), + ("service", "entity_id", "parameter", "parameter_value"), [ - (SERVICE_TURN_ON, True), - (SERVICE_TURN_OFF, False), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + True, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_force_single_phase", + "force_single_phase", + False, + ), + ( + SERVICE_TURN_ON, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 16, + ), + ( + SERVICE_TURN_OFF, + "switch.peblar_ev_charger_charge", + "charge_current_limit", + 0, + ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -60,10 +82,11 @@ async def test_switch( hass: HomeAssistant, mock_peblar: MagicMock, service: str, - force_single_phase: bool, + entity_id: str, + parameter: str, + parameter_value: bool | int, ) -> None: """Test the Peblar EV charger switches.""" - entity_id = "switch.peblar_ev_charger_force_single_phase" mocked_method = mock_peblar.rest_api.return_value.ev_interface mocked_method.reset_mock() @@ -76,9 +99,7 @@ async def test_switch( ) assert len(mocked_method.mock_calls) == 2 - mocked_method.mock_calls[0].assert_called_with( - {"force_single_phase": force_single_phase} - ) + mocked_method.mock_calls[0].assert_called_with({parameter: parameter_value}) @pytest.mark.parametrize(