From 1d38f3627ba11a1951784d93eb9f1ae019bd547e Mon Sep 17 00:00:00 2001 From: Yoav Mor Date: Mon, 14 Sep 2026 13:08:48 +0300 Subject: [PATCH] Add Besen charger display temperature unit select (#180888) --- homeassistant/components/besen/const.py | 2 +- homeassistant/components/besen/coordinator.py | 5 + homeassistant/components/besen/select.py | 103 +++++++++++ homeassistant/components/besen/strings.json | 9 + tests/components/besen/conftest.py | 7 + .../besen/snapshots/test_select.ambr | 60 +++++++ tests/components/besen/test_select.py | 164 ++++++++++++++++++ 7 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/besen/select.py create mode 100644 tests/components/besen/snapshots/test_select.ambr create mode 100644 tests/components/besen/test_select.py diff --git a/homeassistant/components/besen/const.py b/homeassistant/components/besen/const.py index 8e9b91592066..f1b70b47d83b 100644 --- a/homeassistant/components/besen/const.py +++ b/homeassistant/components/besen/const.py @@ -7,4 +7,4 @@ from homeassistant.const import Platform DOMAIN: Final = "besen" NAME: Final = "Besen" -PLATFORMS: Final = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: Final = [Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/besen/coordinator.py b/homeassistant/components/besen/coordinator.py index 841a884f8134..d9d13e3cd78e 100644 --- a/homeassistant/components/besen/coordinator.py +++ b/homeassistant/components/besen/coordinator.py @@ -95,6 +95,11 @@ class BesenCoordinator(DataUpdateCoordinator[BesenData]): await self._async_run_command(self.client.async_set_charge_amps(amps)) + async def async_set_temperature_unit(self, unit: str) -> None: + """Set the charger display temperature unit.""" + + await self._async_run_command(self.client.async_set_temperature_unit(unit)) + async def _async_run_command(self, command: Awaitable[None]) -> None: """Run a charger command and translate command failures.""" diff --git a/homeassistant/components/besen/select.py b/homeassistant/components/besen/select.py new file mode 100644 index 000000000000..f1ebbec555e3 --- /dev/null +++ b/homeassistant/components/besen/select.py @@ -0,0 +1,103 @@ +"""Select platform for Besen.""" + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import Final, override + +from besen.models import BesenData + +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 . import BesenConfigEntry +from .coordinator import BesenCoordinator +from .entity import BesenEntity + +PARALLEL_UPDATES = 0 + +TEMPERATURE_UNIT_OPTIONS: Final = { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit", +} +TEMPERATURE_UNIT_VALUES: Final = { + value: key for key, value in TEMPERATURE_UNIT_OPTIONS.items() +} + + +def _option_value(value: str | None, options: Mapping[str, str]) -> str | None: + """Return a Home Assistant option for a charger value.""" + + return options.get(value) if value is not None else None + + +@dataclass(frozen=True, kw_only=True) +class BesenSelectEntityDescription(SelectEntityDescription): + """Describe a Besen select entity.""" + + current_option_fn: Callable[[BesenData], str | None] + option_values: dict[str, str] + select_option_fn: Callable[[BesenCoordinator, str], Awaitable[None]] + + +SELECT_DESCRIPTIONS: tuple[BesenSelectEntityDescription, ...] = ( + BesenSelectEntityDescription( + key="temperature_unit", + translation_key="temperature_unit", + entity_category=EntityCategory.CONFIG, + options=list(TEMPERATURE_UNIT_OPTIONS), + current_option_fn=lambda data: _option_value( + data.config.temperature_unit, TEMPERATURE_UNIT_VALUES + ), + option_values=TEMPERATURE_UNIT_OPTIONS, + select_option_fn=lambda coordinator, option: ( + coordinator.async_set_temperature_unit(option) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BesenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Besen select platform.""" + + async_add_entities( + BesenSelect(entry.runtime_data, description) + for description in SELECT_DESCRIPTIONS + ) + + +class BesenSelect(BesenEntity, SelectEntity): + """Representation of a Besen select.""" + + entity_description: BesenSelectEntityDescription + + def __init__( + self, + coordinator: BesenCoordinator, + description: BesenSelectEntityDescription, + ) -> None: + """Initialize a Besen select.""" + + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + @override + def current_option(self) -> str | None: + """Return the current option.""" + + return self.entity_description.current_option_fn(self.coordinator.data) + + @override + async def async_select_option(self, option: str) -> None: + """Set the selected option.""" + + await self.entity_description.select_option_fn( + self.coordinator, + self.entity_description.option_values[option], + ) diff --git a/homeassistant/components/besen/strings.json b/homeassistant/components/besen/strings.json index 547a52e1feed..b7dcb6669242 100644 --- a/homeassistant/components/besen/strings.json +++ b/homeassistant/components/besen/strings.json @@ -41,6 +41,15 @@ "number": { "charging_current": { "name": "Charging current" } }, + "select": { + "temperature_unit": { + "name": "Temperature unit", + "state": { + "celsius": "Celsius", + "fahrenheit": "Fahrenheit" + } + } + }, "sensor": { "charging_message": { "name": "Charging message", diff --git a/tests/components/besen/conftest.py b/tests/components/besen/conftest.py index 8f79e287e6a7..fd2bab82fffe 100644 --- a/tests/components/besen/conftest.py +++ b/tests/components/besen/conftest.py @@ -52,6 +52,7 @@ def charger_state( charger_status: bool | None = True, charge_amps: int | None = 16, output_max_amps: int | None = 32, + temperature_unit: str | None = "Celsius", available: bool = True, authenticated: bool = True, phases: int = 1, @@ -72,6 +73,7 @@ def charger_state( ), config=ChargerConfig( charge_amps=charge_amps, + temperature_unit=temperature_unit, device_name="Garage", rssi=-55, ), @@ -114,6 +116,7 @@ def _configure_client_mock(client: Mock) -> None: client.async_start_charging = AsyncMock() client.async_stop_charging = AsyncMock() client.async_set_charge_amps = AsyncMock() + client.async_set_temperature_unit = AsyncMock() client.add_listener.return_value = Mock() @@ -174,9 +177,13 @@ def mock_besen_client() -> Generator[Mock]: async def async_set_charge_amps(amps: int) -> None: publish_besen_state(client, charger_state(charge_amps=amps)) + async def async_set_temperature_unit(unit: str) -> None: + publish_besen_state(client, charger_state(temperature_unit=unit)) + client.async_start_charging.side_effect = async_start_charging client.async_stop_charging.side_effect = async_stop_charging client.async_set_charge_amps.side_effect = async_set_charge_amps + client.async_set_temperature_unit.side_effect = async_set_temperature_unit yield client diff --git a/tests/components/besen/snapshots/test_select.ambr b/tests/components/besen/snapshots/test_select.ambr new file mode 100644 index 000000000000..28fa61022710 --- /dev/null +++ b/tests/components/besen/snapshots/test_select.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_select_state[select.garage_temperature_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.garage_temperature_unit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature unit', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature unit', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_unit', + 'unique_id': 'AA:BB_temperature_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_state[select.garage_temperature_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Garage Temperature unit', + : list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'context': , + 'entity_id': 'select.garage_temperature_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'celsius', + }) +# --- diff --git a/tests/components/besen/test_select.py b/tests/components/besen/test_select.py new file mode 100644 index 000000000000..2eabd28b44ae --- /dev/null +++ b/tests/components/besen/test_select.py @@ -0,0 +1,164 @@ +"""Tests for the Besen select platform.""" + +from unittest.mock import AsyncMock, Mock + +from besen.exceptions import CommandFailed +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.besen.const import DOMAIN +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import publish_besen_state +from .conftest import charger_state, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +TEMPERATURE_UNIT_ENTITY_ID = "select.garage_temperature_unit" + + +async def test_select_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test select entity states and registry data.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + mock_besen_client.async_start.assert_awaited_once() + + +async def test_select_updates_from_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test select states update from client push data.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + publish_besen_state( + mock_besen_client, + charger_state(temperature_unit="Fahrenheit"), + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == "fahrenheit" + + +@pytest.mark.parametrize( + ("available", "authenticated"), + [ + (False, True), + (True, False), + ], +) +async def test_select_unavailable_from_client_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + available: bool, + authenticated: bool, +) -> None: + """Test select availability follows client state.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + publish_besen_state( + mock_besen_client, + charger_state(available=available, authenticated=authenticated), + ) + await hass.async_block_till_done() + + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("temperature_unit", [None, "Kelvin"]) +async def test_select_unknown_for_unreported_or_unsupported_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + temperature_unit: str | None, +) -> None: + """Test unreported and unsupported values are unknown.""" + + mock_besen_client.state = charger_state(temperature_unit=temperature_unit) + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("option", "wire_value"), + [("celsius", "Celsius"), ("fahrenheit", "Fahrenheit")], +) +async def test_select_option( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + option: str, + wire_value: str, +) -> None: + """Test selecting an option sends its protocol value and updates state.""" + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: TEMPERATURE_UNIT_ENTITY_ID, ATTR_OPTION: option}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_besen_client.async_set_temperature_unit.assert_awaited_once_with(wire_value) + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == option + + +async def test_select_command_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test select command failures are translated.""" + + mock_besen_client.async_set_temperature_unit = AsyncMock( + side_effect=CommandFailed("failed") + ) + + await setup_integration(hass, mock_config_entry, [Platform.SELECT]) + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: TEMPERATURE_UNIT_ENTITY_ID, ATTR_OPTION: "fahrenheit"}, + blocking=True, + ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "command_failed" + assert (state := hass.states.get(TEMPERATURE_UNIT_ENTITY_ID)) is not None + assert state.state == "celsius"