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."""
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:

View File

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

View File

@ -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()

View File

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

View File

@ -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()

View File

@ -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': <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]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -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

View File

@ -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(