diff --git a/homeassistant/components/peblar/icons.json b/homeassistant/components/peblar/icons.json index 90b77343f557..573fc87dc68d 100644 --- a/homeassistant/components/peblar/icons.json +++ b/homeassistant/components/peblar/icons.json @@ -14,6 +14,26 @@ } }, "select": { + "buzzer_volume": { + "default": "mdi:volume-high", + "state": { + "high": "mdi:volume-high", + "low": "mdi:volume-low", + "low_medium": "mdi:volume-medium", + "medium": "mdi:volume-medium", + "off": "mdi:volume-off" + } + }, + "led_brightness": { + "default": "mdi:brightness-6", + "state": { + "automatic": "mdi:brightness-auto", + "bright": "mdi:brightness-7", + "dim": "mdi:brightness-4", + "medium": "mdi:brightness-6", + "off": "mdi:led-off" + } + }, "smart_charging": { "default": "mdi:lightning-bolt", "state": { diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py index 6d73e858353c..5f2720826254 100644 --- a/homeassistant/components/peblar/select.py +++ b/homeassistant/components/peblar/select.py @@ -4,14 +4,24 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, override -from peblar import Peblar, PeblarUserConfiguration, SmartChargingMode +from peblar import ( + LedBrightness, + Peblar, + PeblarUserConfiguration, + SmartChargingMode, + SoundVolume, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator +from .coordinator import ( + PeblarConfigEntry, + PeblarRuntimeData, + PeblarUserConfigurationDataUpdateCoordinator, +) from .entity import PeblarEntity from .helpers import peblar_exception_handler @@ -22,6 +32,7 @@ PARALLEL_UPDATES = 1 class PeblarSelectEntityDescription(SelectEntityDescription): """Class describing Peblar select entities.""" + has_fn: Callable[[PeblarRuntimeData], bool] = lambda _: True current_fn: Callable[[PeblarUserConfiguration], str | None] select_fn: Callable[[Peblar, str], Awaitable[Any]] @@ -41,6 +52,44 @@ DESCRIPTIONS = [ current_fn=lambda x: x.smart_charging.value if x.smart_charging else None, select_fn=lambda x, mode: x.smart_charging(SmartChargingMode(mode)), ), + PeblarSelectEntityDescription( + key="buzzer_volume", + translation_key="buzzer_volume", + entity_category=EntityCategory.CONFIG, + has_fn=lambda x: x.system_information.hardware_has_buzzer, + options=[ + "off", + "low", + "low_medium", + "medium", + "high", + ], + current_fn=lambda x: x.buzzer_volume.name.lower(), + select_fn=lambda x, option: x.set_buzzer_volume( + volume=SoundVolume[option.upper()] + ), + ), + PeblarSelectEntityDescription( + key="led_brightness", + translation_key="led_brightness", + entity_category=EntityCategory.CONFIG, + has_fn=lambda x: x.system_information.hardware_has_led, + options=[ + "automatic", + "off", + "dim", + "medium", + "bright", + ], + # None when the charger reports a manual intensity that the UI has + # no name for, which someone can set straight through the API. + current_fn=lambda x: ( + x.led_brightness.name.lower() if x.led_brightness is not None else None + ), + select_fn=lambda x, option: x.set_led_brightness( + brightness=LedBrightness[option.upper()] + ), + ), ] @@ -57,6 +106,7 @@ async def async_setup_entry( description=description, ) for description in DESCRIPTIONS + if description.has_fn(entry.runtime_data) ) diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json index 8acc869a3024..2c96c31130b8 100644 --- a/homeassistant/components/peblar/strings.json +++ b/homeassistant/components/peblar/strings.json @@ -70,6 +70,26 @@ } }, "select": { + "buzzer_volume": { + "name": "Buzzer volume", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "low_medium": "Low medium", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, + "led_brightness": { + "name": "LED brightness", + "state": { + "automatic": "[%key:common::state::auto%]", + "bright": "Bright", + "dim": "Dim", + "medium": "[%key:common::state::medium%]", + "off": "[%key:common::state::off%]" + } + }, "smart_charging": { "name": "Smart charging", "state": { diff --git a/tests/components/peblar/conftest.py b/tests/components/peblar/conftest.py index 93380afae09a..bba7d46ab0ea 100644 --- a/tests/components/peblar/conftest.py +++ b/tests/components/peblar/conftest.py @@ -2,6 +2,7 @@ from collections.abc import Generator from contextlib import nullcontext +import json from unittest.mock import MagicMock, patch from peblar import ( @@ -43,8 +44,17 @@ def mock_setup_entry() -> Generator[None]: @pytest.fixture -def mock_peblar() -> Generator[MagicMock]: - """Return a mocked Peblar client.""" +def mock_peblar(request: pytest.FixtureRequest) -> Generator[MagicMock]: + """Return a mocked Peblar client. + + Parametrize indirectly with a dict to override single system + information fields, so a test that cares about one hardware flag does + not need a full copy of the fixture. + """ + system_information = { + **json.loads(load_fixture("system_information.json", DOMAIN)), + **getattr(request, "param", {}), + } with ( patch("homeassistant.components.peblar.Peblar", autospec=True) as peblar_mock, patch("homeassistant.components.peblar.config_flow.Peblar", new=peblar_mock), @@ -59,8 +69,8 @@ def mock_peblar() -> Generator[MagicMock]: 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) + peblar.system_information.return_value = PeblarSystemInformation.from_dict( + system_information ) api = peblar.rest_api.return_value diff --git a/tests/components/peblar/snapshots/test_select.ambr b/tests/components/peblar/snapshots/test_select.ambr index fff965edb0a7..ad6634630f22 100644 --- a/tests/components/peblar/snapshots/test_select.ambr +++ b/tests/components/peblar/snapshots/test_select.ambr @@ -1,4 +1,134 @@ # serializer version: 1 +# name: test_entities[select][select.peblar_ev_charger_buzzer_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'off', + 'low', + 'low_medium', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.peblar_ev_charger_buzzer_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Buzzer volume', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Buzzer volume', + 'platform': 'peblar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'buzzer_volume', + 'unique_id': '23-45-A4O-MOF_buzzer_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select][select.peblar_ev_charger_buzzer_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Peblar EV Charger Buzzer volume', + : list([ + 'off', + 'low', + 'low_medium', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.peblar_ev_charger_buzzer_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_entities[select][select.peblar_ev_charger_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'automatic', + 'off', + 'dim', + 'medium', + 'bright', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.peblar_ev_charger_led_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LED brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED brightness', + 'platform': 'peblar', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_brightness', + 'unique_id': '23-45-A4O-MOF_led_brightness', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[select][select.peblar_ev_charger_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Peblar EV Charger LED brightness', + : list([ + 'automatic', + 'off', + 'dim', + 'medium', + 'bright', + ]), + }), + 'context': , + 'entity_id': 'select.peblar_ev_charger_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_entities[select][select.peblar_ev_charger_smart_charging-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/peblar/test_select.py b/tests/components/peblar/test_select.py index 56b5e21e5c42..6c7ef209347a 100644 --- a/tests/components/peblar/test_select.py +++ b/tests/components/peblar/test_select.py @@ -1,12 +1,15 @@ """Tests for the Peblar select platform.""" +from typing import Any from unittest.mock import MagicMock from peblar import ( + LedBrightness, PeblarAuthenticationError, PeblarConnectionError, PeblarError, SmartChargingMode, + SoundVolume, ) import pytest from syrupy.assertion import SnapshotAssertion @@ -182,3 +185,68 @@ async def test_select_option_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.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("entity_id", "method_name", "option", "expected_kwargs"), + [ + ( + "select.peblar_ev_charger_buzzer_volume", + "set_buzzer_volume", + "medium", + {"volume": SoundVolume.MEDIUM}, + ), + ( + "select.peblar_ev_charger_led_brightness", + "set_led_brightness", + "bright", + {"brightness": LedBrightness.BRIGHT}, + ), + ], +) +async def test_select_hardware_entity( + hass: HomeAssistant, + mock_peblar: MagicMock, + entity_id: str, + method_name: str, + option: str, + expected_kwargs: dict[str, Any], +) -> None: + """Test the Peblar EV charger hardware select entities.""" + mocked_method = getattr(mock_peblar, method_name) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option}, + blocking=True, + ) + + mocked_method.assert_called_once_with(**expected_kwargs) + + +@pytest.mark.parametrize( + ("mock_peblar", "entity_key"), + [ + ({"HwHasBuzzer": False}, "buzzer_volume"), + ({"HwHasLed": False}, "led_brightness"), + ], + indirect=["mock_peblar"], +) +async def test_hw_entity_absent_when_hw_flag_false( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + entity_key: str, +) -> None: + """Test hardware select entity is absent when the hardware flag is false.""" + assert entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, f"{mock_config_entry.unique_id}_smart_charging" + ) + assert ( + entity_registry.async_get_entity_id( + Platform.SELECT, DOMAIN, f"{mock_config_entry.unique_id}_{entity_key}" + ) + is None + )