From b28f528a7a6484fc0b20729f7958bb123ab7b8a8 Mon Sep 17 00:00:00 2001 From: Christophe Gagnier Date: Fri, 5 Jul 2024 03:39:58 -0400 Subject: [PATCH] Add max current number entity for TechnoVE (#121148) Co-authored-by: Robert Resch --- homeassistant/components/technove/__init__.py | 2 +- homeassistant/components/technove/number.py | 106 +++++++++ .../components/technove/strings.json | 10 + .../technove/fixtures/station_charging.json | 2 +- .../snapshots/test_binary_sensor.ambr | 2 +- .../technove/snapshots/test_number.ambr | 57 +++++ tests/components/technove/test_number.py | 201 ++++++++++++++++++ 7 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/technove/number.py create mode 100644 tests/components/technove/snapshots/test_number.ambr create mode 100644 tests/components/technove/test_number.py diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py index d2d5b4255ba..7315f6f785c 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/technove/number.py b/homeassistant/components/technove/number.py new file mode 100644 index 00000000000..9f2af47c24f --- /dev/null +++ b/homeassistant/components/technove/number.py @@ -0,0 +1,106 @@ +"""Support for TechnoVE number entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from technove import MIN_CURRENT, TechnoVE + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity +from .helpers import technove_exception_handler + + +@dataclass(frozen=True, kw_only=True) +class TechnoVENumberDescription(NumberEntityDescription): + """Describes TechnoVE number entity.""" + + native_max_value_fn: Callable[[TechnoVE], float] + native_value_fn: Callable[[TechnoVE], float] + set_value_fn: Callable[ + [TechnoVEDataUpdateCoordinator, float], Coroutine[Any, Any, None] + ] + + +async def _set_max_current( + coordinator: TechnoVEDataUpdateCoordinator, value: float +) -> None: + if coordinator.data.info.in_sharing_mode: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="max_current_in_sharing_mode" + ) + await coordinator.technove.set_max_current(value) + + +NUMBERS = [ + TechnoVENumberDescription( + key="max_current", + translation_key="max_current", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.CURRENT, + mode=NumberMode.BOX, + native_step=1, + native_min_value=MIN_CURRENT, + native_max_value_fn=lambda station: station.info.max_station_current, + native_value_fn=lambda station: station.info.max_current, + set_value_fn=_set_max_current, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up TechnoVE number entity based on a config entry.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TechnoVENumberEntity(coordinator, description) for description in NUMBERS + ) + + +class TechnoVENumberEntity(TechnoVEEntity, NumberEntity): + """Defines a TechnoVE number entity.""" + + entity_description: TechnoVENumberDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVENumberDescription, + ) -> None: + """Initialize a TechnoVE switch entity.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @property + def native_max_value(self) -> float: + """Return the max value of the TechnoVE number entity.""" + return self.entity_description.native_max_value_fn(self.coordinator.data) + + @property + def native_value(self) -> float: + """Return the native value of the TechnoVE number entity.""" + return self.entity_description.native_value_fn(self.coordinator.data) + + @technove_exception_handler + async def async_set_native_value(self, value: float) -> None: + """Set the value for the TechnoVE number entity.""" + await self.entity_description.set_value_fn(self.coordinator, value) diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 1e7550c8842..8799909d95c 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -39,6 +39,11 @@ "name": "Static IP" } }, + "number": { + "max_current": { + "name": "Maximum current" + } + }, "sensor": { "voltage_in": { "name": "Input voltage" @@ -74,5 +79,10 @@ "name": "Auto charge" } } + }, + "exceptions": { + "max_current_in_sharing_mode": { + "message": "Cannot set the max current when power sharing mode is enabled." + } } } diff --git a/tests/components/technove/fixtures/station_charging.json b/tests/components/technove/fixtures/station_charging.json index ea98dc0b071..63e68d0db0e 100644 --- a/tests/components/technove/fixtures/station_charging.json +++ b/tests/components/technove/fixtures/station_charging.json @@ -11,7 +11,7 @@ "normalPeriodActive": false, "maxChargePourcentage": 0.9, "isBatteryProtected": false, - "inSharingMode": true, + "inSharingMode": false, "energySession": 12.34, "energyTotal": 1234, "version": "1.82", diff --git a/tests/components/technove/snapshots/test_binary_sensor.ambr b/tests/components/technove/snapshots/test_binary_sensor.ambr index 140526b9391..cc2dcf4a04a 100644 --- a/tests/components/technove/snapshots/test_binary_sensor.ambr +++ b/tests/components/technove/snapshots/test_binary_sensor.ambr @@ -181,7 +181,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'off', }) # --- # name: test_sensors[binary_sensor.technove_station_static_ip-entry] diff --git a/tests/components/technove/snapshots/test_number.ambr b/tests/components/technove/snapshots/test_number.ambr new file mode 100644 index 00000000000..622c04d542a --- /dev/null +++ b/tests/components/technove/snapshots/test_number.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_numbers[number.technove_station_maximum_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 32, + 'min': 8, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.technove_station_maximum_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum current', + 'platform': 'technove', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_current', + 'unique_id': 'AA:AA:AA:AA:AA:BB_max_current', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[number.technove_station_maximum_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'TechnoVE Station Maximum current', + 'max': 32, + 'min': 8, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.technove_station_maximum_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- diff --git a/tests/components/technove/test_number.py b/tests/components/technove/test_number.py new file mode 100644 index 00000000000..c9f39cd9200 --- /dev/null +++ b/tests/components/technove/test_number.py @@ -0,0 +1,201 @@ +"""Tests for the TechnoVE number platform.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion +from technove import TechnoVEConnectionError, TechnoVEError + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_technove") +async def test_numbers( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the creation and values of the TechnoVE numbers.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.NUMBER]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "method", "called_with_value"), + [ + ( + "number.technove_station_maximum_current", + "set_max_current", + {"max_current": 10}, + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_number_expected_value( + hass: HomeAssistant, + mock_technove: MagicMock, + entity_id: str, + method: str, + called_with_value: dict[str, bool | int], +) -> None: + """Test set value services with valid values.""" + state = hass.states.get(entity_id) + method_mock = getattr(mock_technove, method) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: called_with_value["max_current"]}, + blocking=True, + ) + + assert method_mock.call_count == 1 + method_mock.assert_called_with(**called_with_value) + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ( + "number.technove_station_maximum_current", + 1, + ), + ( + "number.technove_station_maximum_current", + 1000, + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_number_out_of_bound( + hass: HomeAssistant, + entity_id: str, + value: float, +) -> None: + """Test set value services with out of bound values.""" + state = hass.states.get(entity_id) + + with pytest.raises(ServiceValidationError, match="is outside valid range"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_set_max_current_sharing_mode( + hass: HomeAssistant, + mock_technove: MagicMock, +) -> None: + """Test failure to set the max current when the station is in sharing mode.""" + entity_id = "number.technove_station_maximum_current" + state = hass.states.get(entity_id) + + # Enable power sharing mode + device = mock_technove.update.return_value + device.info.in_sharing_mode = True + + with pytest.raises( + ServiceValidationError, + match="power sharing mode is enabled", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ( + "number.technove_station_maximum_current", + "set_max_current", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_invalid_response( + hass: HomeAssistant, + mock_technove: MagicMock, + entity_id: str, + method: str, +) -> None: + """Test invalid response, not becoming unavailable.""" + state = hass.states.get(entity_id) + method_mock = getattr(mock_technove, method) + + method_mock.side_effect = TechnoVEError + with pytest.raises(HomeAssistantError, match="Invalid response from TechnoVE API"): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: 10}, + blocking=True, + ) + + assert method_mock.call_count == 1 + assert (state := hass.states.get(state.entity_id)) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ( + "number.technove_station_maximum_current", + "set_max_current", + ), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_connection_error( + hass: HomeAssistant, + mock_technove: MagicMock, + entity_id: str, + method: str, +) -> None: + """Test connection error, leading to becoming unavailable.""" + state = hass.states.get(entity_id) + method_mock = getattr(mock_technove, method) + + method_mock.side_effect = TechnoVEConnectionError + with pytest.raises( + HomeAssistantError, match="Error communicating with TechnoVE API" + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: 10}, + blocking=True, + ) + + assert method_mock.call_count == 1 + assert (state := hass.states.get(state.entity_id)) + assert state.state == STATE_UNAVAILABLE