diff --git a/homeassistant/components/homevolt/__init__.py b/homeassistant/components/homevolt/__init__.py index 1826be7a0fc4..503c821ede4a 100644 --- a/homeassistant/components/homevolt/__init__.py +++ b/homeassistant/components/homevolt/__init__.py @@ -8,7 +8,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool: diff --git a/homeassistant/components/homevolt/number.py b/homeassistant/components/homevolt/number.py new file mode 100644 index 000000000000..4e854e97d3c6 --- /dev/null +++ b/homeassistant/components/homevolt/number.py @@ -0,0 +1,110 @@ +"""Support for Homevolt number entities.""" + +from typing import override + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import EntityCategory, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator +from .entity import HomevoltEntity, homevolt_exception_handler + +PARALLEL_UPDATES = 0 # Coordinator-based updates + +MAX_CONTROL_POWER = 11000 + +NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = ( + NumberEntityDescription( + key="setpoint", + translation_key="setpoint", + native_min_value=0, + native_max_value=MAX_CONTROL_POWER, + native_step=100, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key="grid_import_limit", + translation_key="grid_import_limit", + native_min_value=0, + native_max_value=MAX_CONTROL_POWER, + native_step=100, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key="grid_export_limit", + translation_key="grid_export_limit", + native_min_value=0, + native_max_value=MAX_CONTROL_POWER, + native_step=100, + native_unit_of_measurement=UnitOfPower.WATT, + device_class=NumberDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomevoltConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Homevolt number entities.""" + coordinator = entry.runtime_data + async_add_entities( + [ + HomevoltNumberEntity(coordinator, description) + for description in NUMBER_DESCRIPTIONS + ] + ) + + +class HomevoltNumberEntity(HomevoltEntity, NumberEntity): + """Representation of a Homevolt number entity.""" + + entity_description: NumberEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: HomevoltDataUpdateCoordinator, + description: NumberEntityDescription, + ) -> None: + """Initialize the number entity.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}" + device_id = coordinator.data.unique_id + super().__init__(coordinator, f"ems_{device_id}") + + @property + @override + def available(self) -> bool: + """Return whether the current manual schedule supports parameter writes.""" + return ( + super().available + and self.entity_description.key + in self.coordinator.client.writable_battery_parameters + ) + + @property + @override + def native_value(self) -> float | None: + """Return the current value.""" + value = self.coordinator.client.schedule.get(self.entity_description.key) + return float(value) if value is not None else None + + @homevolt_exception_handler + @override + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + key = self.entity_description.key + await self.coordinator.client.set_battery_parameters(**{key: value}) + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/homevolt/strings.json b/homeassistant/components/homevolt/strings.json index 2c5c054fc012..ef3a7cff559f 100644 --- a/homeassistant/components/homevolt/strings.json +++ b/homeassistant/components/homevolt/strings.json @@ -52,6 +52,17 @@ } }, "entity": { + "number": { + "grid_export_limit": { + "name": "Grid export limit" + }, + "grid_import_limit": { + "name": "Grid import limit" + }, + "setpoint": { + "name": "Power setpoint" + } + }, "select": { "battery_mode": { "name": "Battery mode", diff --git a/tests/components/homevolt/conftest.py b/tests/components/homevolt/conftest.py index 11ea6bca232b..cffe85889847 100644 --- a/tests/components/homevolt/conftest.py +++ b/tests/components/homevolt/conftest.py @@ -81,12 +81,25 @@ def mock_homevolt_client() -> Generator[MagicMock]: # Load schedule data from fixture client.current_schedule = load_json_object_fixture("schedule.json", DOMAIN) - client.schedule = {"mode": client.current_schedule["schedule"][0]["type"]} + schedule_entry = client.current_schedule["schedule"][0] + schedule_params = schedule_entry["params"] + client.schedule = { + "mode": schedule_entry["type"], + "setpoint": schedule_params["setpoint"], + "max_charge": schedule_params["max_charge"], + "max_discharge": schedule_params["max_discharge"], + "min_soc": schedule_entry["min"], + "max_soc": schedule_entry["max"], + "grid_import_limit": schedule_params["import_limit"], + "grid_export_limit": schedule_params["export_limit"], + } # Switch (local mode) support - client.local_mode_enabled = False + client.local_mode_enabled = client.current_schedule["local_mode"] + client.writable_battery_parameters = frozenset() client.enable_local_mode = AsyncMock() client.disable_local_mode = AsyncMock() + client.set_battery_parameters = AsyncMock() yield client diff --git a/tests/components/homevolt/fixtures/schedule.json b/tests/components/homevolt/fixtures/schedule.json index 7c4c6d46c926..593dc42cfd3a 100644 --- a/tests/components/homevolt/fixtures/schedule.json +++ b/tests/components/homevolt/fixtures/schedule.json @@ -1,14 +1,17 @@ { - "local_mode": true, + "local_mode": false, + "schedule_id": "Manual Schedule", "schedule": [ { "type": 1, + "min": 10, + "max": 95, "params": { "setpoint": 0, "max_charge": 6028, "max_discharge": 6028, - "min_soc": 10, - "max_soc": 95 + "import_limit": 10000, + "export_limit": 9000 } } ] diff --git a/tests/components/homevolt/snapshots/test_number.ambr b/tests/components/homevolt/snapshots/test_number.ambr new file mode 100644 index 000000000000..74af043e9be4 --- /dev/null +++ b/tests/components/homevolt/snapshots/test_number.ambr @@ -0,0 +1,184 @@ +# serializer version: 1 +# name: test_number_entities[number.homevolt_ems_grid_export_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 11000, + : 0, + : , + : 100, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.homevolt_ems_grid_export_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid export limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid export limit', + 'platform': 'homevolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_export_limit', + 'unique_id': '40580137858664_grid_export_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[number.homevolt_ems_grid_export_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Homevolt EMS Grid export limit', + : 11000, + : 0, + : , + : 100, + : , + }), + 'context': , + 'entity_id': 'number.homevolt_ems_grid_export_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_entities[number.homevolt_ems_grid_import_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 11000, + : 0, + : , + : 100, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.homevolt_ems_grid_import_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Grid import limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid import limit', + 'platform': 'homevolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'grid_import_limit', + 'unique_id': '40580137858664_grid_import_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[number.homevolt_ems_grid_import_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Homevolt EMS Grid import limit', + : 11000, + : 0, + : , + : 100, + : , + }), + 'context': , + 'entity_id': 'number.homevolt_ems_grid_import_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_number_entities[number.homevolt_ems_power_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 11000, + : 0, + : , + : 100, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.homevolt_ems_power_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power setpoint', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power setpoint', + 'platform': 'homevolt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'setpoint', + 'unique_id': '40580137858664_setpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[number.homevolt_ems_power_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'power', + : 'Homevolt EMS Power setpoint', + : 11000, + : 0, + : , + : 100, + : , + }), + 'context': , + 'entity_id': 'number.homevolt_ems_power_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/homevolt/test_number.py b/tests/components/homevolt/test_number.py new file mode 100644 index 000000000000..a24b1920128b --- /dev/null +++ b/tests/components/homevolt/test_number.py @@ -0,0 +1,279 @@ +"""Tests for the Homevolt number platform.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from homevolt import ( + HomevoltAuthenticationError, + HomevoltCommandOutcomeUnknownError, + HomevoltCommandRejectedError, + HomevoltCommandVerificationError, + HomevoltConnectionError, + HomevoltError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homevolt.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.number import ( + 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 ( + ConfigEntryAuthFailed, + HomeAssistantError, + ServiceValidationError, +) +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +SETPOINT_ENTITY_ID = "number.homevolt_ems_power_setpoint" +IMPORT_LIMIT_ENTITY_ID = "number.homevolt_ems_grid_import_limit" +EXPORT_LIMIT_ENTITY_ID = "number.homevolt_ems_grid_export_limit" + + +@pytest.fixture +def platforms(mock_homevolt_client: MagicMock) -> list[Platform]: + """Load the number platform with a writable manual schedule.""" + mock_homevolt_client.local_mode_enabled = True + mock_homevolt_client.writable_battery_parameters = frozenset({"setpoint"}) + return [Platform.NUMBER] + + +async def test_number_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test battery control number states and registry entries.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.parametrize( + ("key", "entity_id", "value", "mode"), + [ + pytest.param("setpoint", SETPOINT_ENTITY_ID, 1000, 1, id="setpoint"), + pytest.param( + "grid_import_limit", IMPORT_LIMIT_ENTITY_ID, 4000, 6, id="import-limit" + ), + pytest.param( + "grid_export_limit", EXPORT_LIMIT_ENTITY_ID, 5000, 6, id="export-limit" + ), + ], +) +async def test_set_number_value( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_homevolt_client: MagicMock, + key: str, + entity_id: str, + value: int, + mode: int, +) -> None: + """Test each supported parameter publishes its verified value immediately.""" + mock_homevolt_client.schedule["mode"] = mode + mock_homevolt_client.writable_battery_parameters = frozenset({key}) + init_integration.runtime_data.async_update_listeners() + + async def set_battery_parameters(**parameters: int) -> None: + mock_homevolt_client.schedule.update(parameters) + + mock_homevolt_client.set_battery_parameters.side_effect = set_battery_parameters + mock_homevolt_client.update_info.reset_mock() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + mock_homevolt_client.set_battery_parameters.assert_awaited_once_with(**{key: value}) + mock_homevolt_client.update_info.assert_not_awaited() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == str(float(value)) + + +async def test_number_unknown_value( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_homevolt_client: MagicMock, +) -> None: + """Test an absent manual parameter has an unknown state.""" + mock_homevolt_client.schedule["setpoint"] = None + await init_integration.runtime_data.async_request_refresh() + + state = hass.states.get(SETPOINT_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_numbers_unavailable_without_writable_manual_schedule( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_homevolt_client: MagicMock, +) -> None: + """Test parameter writes are unavailable for non-manual schedules.""" + mock_homevolt_client.writable_battery_parameters = frozenset() + await init_integration.runtime_data.async_request_refresh() + + state = hass.states.get(SETPOINT_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "value", + [pytest.param(-1, id="below-minimum"), pytest.param(11001, id="above-maximum")], +) +async def test_invalid_number_value( + hass: HomeAssistant, + mock_homevolt_client: MagicMock, + value: int, +) -> None: + """Test out-of-range values never reach the client.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: SETPOINT_ENTITY_ID, ATTR_VALUE: value}, + blocking=True, + ) + + mock_homevolt_client.set_battery_parameters.assert_not_awaited() + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ( + "error", + "expected_exception", + "translation_key", + "translation_placeholders", + "refresh_count", + ), + [ + pytest.param( + HomevoltAuthenticationError("authentication failed"), + ConfigEntryAuthFailed, + "auth_failed", + None, + 0, + id="authentication", + ), + pytest.param( + HomevoltCommandRejectedError("command rejected"), + HomeAssistantError, + "command_rejected", + None, + 0, + id="command-rejected", + ), + pytest.param( + HomevoltCommandVerificationError("command verification failed"), + HomeAssistantError, + "command_verification_failed", + None, + 1, + id="command-verification", + ), + pytest.param( + HomevoltCommandOutcomeUnknownError("command outcome unknown"), + HomeAssistantError, + "command_outcome_unknown", + None, + 1, + id="command-outcome-unknown", + ), + pytest.param( + HomevoltConnectionError("connection failed"), + HomeAssistantError, + "communication_error", + {"error": "connection failed"}, + 0, + id="connection", + ), + pytest.param( + HomevoltError("unknown error"), + HomeAssistantError, + "unknown_error", + {"error": "unknown error"}, + 0, + id="unknown", + ), + ], +) +async def test_set_number_value_error( + hass: HomeAssistant, + mock_homevolt_client: MagicMock, + error: HomevoltError, + expected_exception: type[HomeAssistantError], + translation_key: str, + translation_placeholders: dict[str, str] | None, + refresh_count: int, +) -> None: + """Test number actions use the shared translated error handler.""" + mock_homevolt_client.set_battery_parameters.side_effect = error + mock_homevolt_client.update_info.reset_mock() + + with pytest.raises(expected_exception) as exc_info: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: SETPOINT_ENTITY_ID, ATTR_VALUE: 1000}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == translation_placeholders + assert exc_info.value.__cause__ is error + assert mock_homevolt_client.update_info.await_count == refresh_count + + +async def test_commands_preserve_telemetry_polling( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_homevolt_client: MagicMock, + platforms: list[Platform], +) -> None: + """Test repeated schedule writes do not postpone the regular telemetry poll.""" + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.homevolt.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_homevolt_client.update_info.assert_awaited_once() + mock_homevolt_client.update_info.reset_mock() + command_interval = SCAN_INTERVAL / 3 + + for _ in range(2): + freezer.tick(command_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: SETPOINT_ENTITY_ID, ATTR_VALUE: 1000}, + blocking=True, + ) + mock_homevolt_client.update_info.assert_not_awaited() + + freezer.tick(command_interval.total_seconds() + 1) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_homevolt_client.update_info.assert_awaited_once()