Add Besen charger display temperature unit select (#180888)

This commit is contained in:
Yoav Mor
2026-09-14 12:08:48 +02:00
committed by GitHub
parent 59272ae7b2
commit 1d38f3627b
7 changed files with 349 additions and 1 deletions
+1 -1
View File
@@ -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]
@@ -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."""
+103
View File
@@ -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],
)
@@ -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",
+7
View File
@@ -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
@@ -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({
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'celsius',
'fahrenheit',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.garage_temperature_unit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Garage Temperature unit',
<SelectEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'celsius',
'fahrenheit',
]),
}),
'context': <ANY>,
'entity_id': 'select.garage_temperature_unit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'celsius',
})
# ---
+164
View File
@@ -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"