Add Peblar charge switch (#137853)

* Add Peblar charge switch

* Update snapshots
This commit is contained in:
Franck Nijhof 2025-02-08 15:48:31 +01:00 committed by GitHub
parent eab510f440
commit 7bf81037c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 296 additions and 83 deletions

View File

@ -34,6 +34,7 @@ class PeblarRuntimeData:
"""Class to hold runtime data.""" """Class to hold runtime data."""
data_coordinator: PeblarDataUpdateCoordinator data_coordinator: PeblarDataUpdateCoordinator
last_known_charging_limit = 6
system_information: PeblarSystemInformation system_information: PeblarSystemInformation
user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator user_configuration_coordinator: PeblarUserConfigurationDataUpdateCoordinator
version_coordinator: PeblarVersionDataUpdateCoordinator version_coordinator: PeblarVersionDataUpdateCoordinator
@ -137,6 +138,8 @@ class PeblarVersionDataUpdateCoordinator(
class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]): class PeblarDataUpdateCoordinator(DataUpdateCoordinator[PeblarData]):
"""Class to manage fetching Peblar active data.""" """Class to manage fetching Peblar active data."""
config_entry: PeblarConfigEntry
def __init__( def __init__(
self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi self, hass: HomeAssistant, entry: PeblarConfigEntry, api: PeblarApi
) -> None: ) -> None:

View File

@ -36,6 +36,9 @@
} }
}, },
"switch": { "switch": {
"charge": {
"default": "mdi:ev-plug-type2"
},
"force_single_phase": { "force_single_phase": {
"default": "mdi:power-cycle" "default": "mdi:power-cycle"
} }

View File

@ -2,58 +2,27 @@
from __future__ import annotations 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 ( from homeassistant.components.number import (
NumberDeviceClass, NumberDeviceClass,
NumberEntity,
NumberEntityDescription, NumberEntityDescription,
RestoreNumber,
) )
from homeassistant.const import EntityCategory, UnitOfElectricCurrent from homeassistant.const import (
from homeassistant.core import HomeAssistant STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityCategory,
UnitOfElectricCurrent,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import ( from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator
PeblarConfigEntry,
PeblarData,
PeblarDataUpdateCoordinator,
PeblarRuntimeData,
)
from .entity import PeblarEntity from .entity import PeblarEntity
from .helpers import peblar_exception_handler from .helpers import peblar_exception_handler
PARALLEL_UPDATES = 1 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: PeblarConfigEntry, entry: PeblarConfigEntry,
@ -61,42 +30,101 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up Peblar number based on a config entry.""" """Set up Peblar number based on a config entry."""
async_add_entities( async_add_entities(
PeblarNumberEntity( [
PeblarChargeCurrentLimitNumberEntity(
entry=entry, entry=entry,
coordinator=entry.runtime_data.data_coordinator, coordinator=entry.runtime_data.data_coordinator,
description=description,
) )
for description in DESCRIPTIONS ]
) )
class PeblarNumberEntity( class PeblarChargeCurrentLimitNumberEntity(
PeblarEntity[PeblarDataUpdateCoordinator], 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__( def __init__(
self, self,
entry: PeblarConfigEntry, entry: PeblarConfigEntry,
coordinator: PeblarDataUpdateCoordinator, coordinator: PeblarDataUpdateCoordinator,
description: PeblarNumberEntityDescription,
) -> None: ) -> None:
"""Initialize the Peblar entity.""" """Initialize the Peblar charge current limit entity."""
super().__init__(entry=entry, coordinator=coordinator, description=description) super().__init__(
self._attr_native_max_value = description.native_max_value_fn( entry=entry,
entry.runtime_data 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 async def async_added_to_hass(self) -> None:
def native_value(self) -> int | None: """Load the last known state when adding this entity."""
"""Return the number value.""" if (
return self.entity_description.value_fn(self.coordinator.data) (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 @peblar_exception_handler
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Change to new number value.""" """Change the current charging value."""
await self.entity_description.set_value_fn(self.coordinator.api, 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() await self.coordinator.async_request_refresh()

View File

@ -153,6 +153,9 @@
} }
}, },
"switch": { "switch": {
"charge": {
"name": "Charge"
},
"force_single_phase": { "force_single_phase": {
"name": "Force single phase" "name": "Force single phase"
} }

View File

@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from peblar import PeblarApi from peblar import PeblarEVInterface
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@ -31,7 +31,19 @@ class PeblarSwitchEntityDescription(SwitchEntityDescription):
has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True has_fn: Callable[[PeblarRuntimeData], bool] = lambda x: True
is_on_fn: Callable[[PeblarData], bool] 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 = [ DESCRIPTIONS = [
@ -44,7 +56,14 @@ DESCRIPTIONS = [
and x.user_configuration_coordinator.data.connected_phases > 1 and x.user_configuration_coordinator.data.connected_phases > 1
), ),
is_on_fn=lambda x: x.ev.force_single_phase, 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 @peblar_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """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() await self.coordinator.async_request_refresh()
@peblar_exception_handler @peblar_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """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() await self.coordinator.async_request_refresh()

View File

@ -1,4 +1,50 @@
# serializer version: 1 # serializer version: 1
# name: test_entities[switch][switch.peblar_ev_charger_charge-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.peblar_ev_charger_charge',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'switch.peblar_ev_charger_charge',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-entry] # name: test_entities[switch][switch.peblar_ev_charger_force_single_phase-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -14,18 +14,19 @@ from homeassistant.components.number import (
from homeassistant.components.peblar.const import DOMAIN from homeassistant.components.peblar.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, Platform 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.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform from tests.common import (
MockConfigEntry,
pytestmark = [ mock_restore_cache_with_extra_data,
pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True), snapshot_platform,
pytest.mark.usefixtures("init_integration"), )
]
@pytest.mark.parametrize("init_integration", [Platform.NUMBER], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_entities( async def test_entities(
hass: HomeAssistant, hass: HomeAssistant,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
@ -48,7 +49,8 @@ async def test_entities(
assert entity_entry.device_id == device_entry.id 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( async def test_number_set_value(
hass: HomeAssistant, hass: HomeAssistant,
mock_peblar: MagicMock, 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}) 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( @pytest.mark.parametrize(
("error", "error_match", "translation_key", "translation_placeholders"), ("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( async def test_number_set_value_communication_error(
hass: HomeAssistant, hass: HomeAssistant,
mock_peblar: MagicMock, mock_peblar: MagicMock,
@ -128,6 +168,8 @@ async def test_number_set_value_communication_error(
assert excinfo.value.translation_placeholders == translation_placeholders 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( async def test_number_set_value_authentication_error(
hass: HomeAssistant, hass: HomeAssistant,
mock_peblar: MagicMock, mock_peblar: MagicMock,
@ -175,3 +217,51 @@ async def test_number_set_value_authentication_error(
assert "context" in flow assert "context" in flow
assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("source") == SOURCE_REAUTH
assert flow["context"].get("entry_id") == mock_config_entry.entry_id 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

View File

@ -49,10 +49,32 @@ async def test_entities(
@pytest.mark.parametrize( @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") @pytest.mark.usefixtures("entity_registry_enabled_by_default")
@ -60,10 +82,11 @@ async def test_switch(
hass: HomeAssistant, hass: HomeAssistant,
mock_peblar: MagicMock, mock_peblar: MagicMock,
service: str, service: str,
force_single_phase: bool, entity_id: str,
parameter: str,
parameter_value: bool | int,
) -> None: ) -> None:
"""Test the Peblar EV charger switches.""" """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 = mock_peblar.rest_api.return_value.ev_interface
mocked_method.reset_mock() mocked_method.reset_mock()
@ -76,9 +99,7 @@ async def test_switch(
) )
assert len(mocked_method.mock_calls) == 2 assert len(mocked_method.mock_calls) == 2
mocked_method.mock_calls[0].assert_called_with( mocked_method.mock_calls[0].assert_called_with({parameter: parameter_value})
{"force_single_phase": force_single_phase}
)
@pytest.mark.parametrize( @pytest.mark.parametrize(