From 06206cfa02c1488a3685f620f68b5456a7da6941 Mon Sep 17 00:00:00 2001 From: Yoav Mor Date: Thu, 10 Sep 2026 15:53:39 +0300 Subject: [PATCH] Add Besen charging current control (#180617) --- homeassistant/components/besen/const.py | 2 +- homeassistant/components/besen/coordinator.py | 5 + homeassistant/components/besen/number.py | 62 ++++++ homeassistant/components/besen/strings.json | 3 + tests/components/besen/conftest.py | 14 +- .../besen/snapshots/test_number.ambr | 62 ++++++ tests/components/besen/test_number.py | 207 ++++++++++++++++++ 7 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/besen/number.py create mode 100644 tests/components/besen/snapshots/test_number.ambr create mode 100644 tests/components/besen/test_number.py diff --git a/homeassistant/components/besen/const.py b/homeassistant/components/besen/const.py index 052084ab68f1..8e9b91592066 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.SENSOR, Platform.SWITCH] +PLATFORMS: Final = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/besen/coordinator.py b/homeassistant/components/besen/coordinator.py index b2002dde2002..841a884f8134 100644 --- a/homeassistant/components/besen/coordinator.py +++ b/homeassistant/components/besen/coordinator.py @@ -90,6 +90,11 @@ class BesenCoordinator(DataUpdateCoordinator[BesenData]): await self._async_run_command(self.client.async_stop_charging()) + async def async_set_charge_amps(self, amps: int) -> None: + """Set the charging current.""" + + await self._async_run_command(self.client.async_set_charge_amps(amps)) + async def _async_run_command(self, command: Awaitable[None]) -> None: """Run a charger command and translate command failures.""" diff --git a/homeassistant/components/besen/number.py b/homeassistant/components/besen/number.py new file mode 100644 index 000000000000..94c8024e23ac --- /dev/null +++ b/homeassistant/components/besen/number.py @@ -0,0 +1,62 @@ +"""Number platform for Besen.""" + +from typing import override + +from besen.const import FALLBACK_MAX_CHARGE_AMPS, MIN_CHARGE_AMPS + +from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode +from homeassistant.const import EntityCategory, UnitOfElectricCurrent +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 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BesenConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Besen number platform.""" + + async_add_entities([BesenChargingCurrentNumber(entry.runtime_data)]) + + +class BesenChargingCurrentNumber(BesenEntity, NumberEntity): + """Charging current control.""" + + _attr_device_class = NumberDeviceClass.CURRENT + _attr_entity_category = EntityCategory.CONFIG + _attr_mode = NumberMode.BOX + _attr_native_min_value = MIN_CHARGE_AMPS + _attr_native_step = 1 + _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE + + def __init__(self, coordinator: BesenCoordinator) -> None: + """Initialize the charging current control.""" + + super().__init__(coordinator, "charging_current") + + @property + @override + def native_max_value(self) -> float: + """Return the maximum charging current.""" + + return self.coordinator.data.info.output_max_amps or FALLBACK_MAX_CHARGE_AMPS + + @property + @override + def native_value(self) -> float | None: + """Return the configured charging current.""" + + return self.coordinator.data.config.charge_amps + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the charging current.""" + + await self.coordinator.async_set_charge_amps(int(value)) diff --git a/homeassistant/components/besen/strings.json b/homeassistant/components/besen/strings.json index b7345780d098..a9508a9df882 100644 --- a/homeassistant/components/besen/strings.json +++ b/homeassistant/components/besen/strings.json @@ -38,6 +38,9 @@ } }, "entity": { + "number": { + "charging_current": { "name": "Charging current" } + }, "sensor": { "charging_power": { "name": "Charging power" }, "external_temperature": { "name": "External temperature" }, diff --git a/tests/components/besen/conftest.py b/tests/components/besen/conftest.py index 9cc0a1ffa34d..9de1e42b0e07 100644 --- a/tests/components/besen/conftest.py +++ b/tests/components/besen/conftest.py @@ -50,6 +50,8 @@ FAKE_SERVICE_INFO = BluetoothServiceInfoBleak( def charger_state( *, charger_status: bool | None = True, + charge_amps: int | None = 16, + output_max_amps: int | None = 32, available: bool = True, authenticated: bool = True, phases: int = 1, @@ -66,8 +68,13 @@ def charger_state( model="BS20", hardware_version="HW1", software_version="SW1", + output_max_amps=output_max_amps, + ), + config=ChargerConfig( + charge_amps=charge_amps, + device_name="Garage", + rssi=-55, ), - config=ChargerConfig(device_name="Garage", rssi=-55), charge=( charge if charge is not None @@ -100,6 +107,7 @@ def _configure_client_mock(client: Mock) -> None: client.async_stop = AsyncMock() client.async_start_charging = AsyncMock() client.async_stop_charging = AsyncMock() + client.async_set_charge_amps = AsyncMock() client.add_listener.return_value = Mock() @@ -157,8 +165,12 @@ def mock_besen_client() -> Generator[Mock]: async def async_stop_charging() -> None: publish_besen_state(client, charger_state(charger_status=False)) + async def async_set_charge_amps(amps: int) -> None: + publish_besen_state(client, charger_state(charge_amps=amps)) + 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 yield client diff --git a/tests/components/besen/snapshots/test_number.ambr b/tests/components/besen/snapshots/test_number.ambr new file mode 100644 index 000000000000..243e60b9c3ca --- /dev/null +++ b/tests/components/besen/snapshots/test_number.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_number_state[number.garage_charging_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 32, + : 6, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.garage_charging_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging current', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging current', + 'platform': 'besen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charging_current', + 'unique_id': 'AA:BB_charging_current', + 'unit_of_measurement': , + }) +# --- +# name: test_number_state[number.garage_charging_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'current', + : 'Garage Charging current', + : 32, + : 6, + : , + : 1, + : , + }), + 'context': , + 'entity_id': 'number.garage_charging_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- diff --git a/tests/components/besen/test_number.py b/tests/components/besen/test_number.py new file mode 100644 index 000000000000..5cea69121b09 --- /dev/null +++ b/tests/components/besen/test_number.py @@ -0,0 +1,207 @@ +"""Tests for the Besen number platform.""" + +from unittest.mock import AsyncMock, Mock + +from besen.const import FALLBACK_MAX_CHARGE_AMPS +from besen.exceptions import CommandFailed +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.besen.const import DOMAIN +from homeassistant.components.number import ( + ATTR_MAX, + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity + +from . import publish_besen_state +from .conftest import charger_state, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "number.garage_charging_current" + + +async def test_number_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test number entity state and registry data.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + mock_besen_client.async_start.assert_awaited_once() + + +async def test_number_updates_from_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test number state updates from client push data.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + publish_besen_state(mock_besen_client, charger_state(charge_amps=20)) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "20" + + +async def test_number_updates_on_refresh( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test number state updates when the coordinator refreshes.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + mock_besen_client.state = charger_state(charge_amps=20) + await async_update_entity(hass, ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "20" + + +@pytest.mark.parametrize( + ("available", "authenticated"), + [ + (False, True), + (True, False), + ], +) +async def test_number_unavailable_from_client_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + available: bool, + authenticated: bool, +) -> None: + """Test number availability follows client availability and authentication.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + publish_besen_state( + mock_besen_client, + charger_state(available=available, authenticated=authenticated), + ) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_number_unknown_without_reported_current( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test the number is unknown before the charger reports its current.""" + + mock_besen_client.state = charger_state(charge_amps=None) + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("output_max_amps", "expected_max"), + [ + (16, 16), + (None, FALLBACK_MAX_CHARGE_AMPS), + ], +) +async def test_number_maximum( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, + output_max_amps: int | None, + expected_max: int, +) -> None: + """Test the maximum uses charger information with a safe fallback.""" + + mock_besen_client.state = charger_state( + charge_amps=16, + output_max_amps=output_max_amps, + ) + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_MAX] == expected_max + + +async def test_number_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test setting the charging current calls the client and updates state.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_VALUE: 20}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_besen_client.async_set_charge_amps.assert_awaited_once_with(20) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "20" + + +async def test_number_command_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_besen_client: Mock, +) -> None: + """Test command failures are translated to Home Assistant errors.""" + + mock_besen_client.async_set_charge_amps = AsyncMock( + side_effect=CommandFailed("failed") + ) + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_VALUE: 20}, + blocking=True, + ) + + assert err.value.translation_domain == DOMAIN + assert err.value.translation_key == "command_failed" + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == "16"