diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index d3de23e91eb..c1c786f0b77 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.NUMBER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool: diff --git a/homeassistant/components/openevse/number.py b/homeassistant/components/openevse/number.py new file mode 100644 index 00000000000..3eed3da75b9 --- /dev/null +++ b/homeassistant/components/openevse/number.py @@ -0,0 +1,114 @@ +"""Support for OpenEVSE number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from openevsehttp.__main__ import OpenEVSE + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_SERIAL_NUMBER, + EntityCategory, + UnitOfElectricCurrent, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class OpenEVSENumberDescription(NumberEntityDescription): + """Describes an OpenEVSE number entity.""" + + value_fn: Callable[[OpenEVSE], float] + min_value_fn: Callable[[OpenEVSE], float] + max_value_fn: Callable[[OpenEVSE], float] + set_value_fn: Callable[[OpenEVSE, float], Awaitable[Any]] + + +NUMBER_TYPES: tuple[OpenEVSENumberDescription, ...] = ( + OpenEVSENumberDescription( + key="charge_rate", + translation_key="charge_rate", + value_fn=lambda ev: ev.max_current_soft, + min_value_fn=lambda ev: ev.min_amps, + max_value_fn=lambda ev: ev.max_amps, + set_value_fn=lambda ev, value: ev.set_current(value), + native_step=1.0, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenEVSEConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenEVSE sensors based on config entry.""" + coordinator = entry.runtime_data + identifier = entry.unique_id or entry.entry_id + async_add_entities( + OpenEVSENumber(coordinator, description, identifier, entry.unique_id) + for description in NUMBER_TYPES + ) + + +class OpenEVSENumber(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], NumberEntity): + """Implementation of an OpenEVSE sensor.""" + + _attr_has_entity_name = True + entity_description: OpenEVSENumberDescription + + def __init__( + self, + coordinator: OpenEVSEDataUpdateCoordinator, + description: OpenEVSENumberDescription, + identifier: str, + unique_id: str | None, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{identifier}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + manufacturer="OpenEVSE", + ) + if unique_id: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, unique_id) + } + self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id + + @property + def native_value(self) -> float: + """Return the state of the number.""" + return self.entity_description.value_fn(self.coordinator.charger) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return self.entity_description.min_value_fn(self.coordinator.charger) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self.entity_description.max_value_fn(self.coordinator.charger) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.entity_description.set_value_fn(self.coordinator.charger, value) diff --git a/homeassistant/components/openevse/strings.json b/homeassistant/components/openevse/strings.json index 04b5fce6d69..3a76b2bb27f 100644 --- a/homeassistant/components/openevse/strings.json +++ b/homeassistant/components/openevse/strings.json @@ -30,6 +30,11 @@ } }, "entity": { + "number": { + "charge_rate": { + "name": "Charge rate" + } + }, "sensor": { "ambient_temp": { "name": "Ambient temperature" diff --git a/tests/components/openevse/conftest.py b/tests/components/openevse/conftest.py index b338ae396e5..04629a4b5d4 100644 --- a/tests/components/openevse/conftest.py +++ b/tests/components/openevse/conftest.py @@ -57,6 +57,7 @@ def mock_charger() -> Generator[MagicMock]: charger.max_current = 48 charger.min_amps = 6 charger.max_amps = 48 + charger.max_current_soft = 20 # Divert/solar mode sensors charger.available_current = 32.0 charger.smoothed_available_current = 32.0 diff --git a/tests/components/openevse/snapshots/test_number.ambr b/tests/components/openevse/snapshots/test_number.ambr new file mode 100644 index 00000000000..704a878c8a0 --- /dev/null +++ b/tests/components/openevse/snapshots/test_number.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_entities[number.openevse_mock_config_charge_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 48, + 'min': 6, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.openevse_mock_config_charge_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charge rate', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge rate', + 'platform': 'openevse', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'charge_rate', + 'unique_id': 'deadbeeffeed-charge_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[number.openevse_mock_config_charge_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'openevse_mock_config Charge rate', + 'max': 48, + 'min': 6, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.openevse_mock_config_charge_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- diff --git a/tests/components/openevse/test_number.py b/tests/components/openevse/test_number.py new file mode 100644 index 00000000000..cb71e92fe5d --- /dev/null +++ b/tests/components/openevse/test_number.py @@ -0,0 +1,52 @@ +"""Tests for the OpenEVSE number platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.openevse.PLATFORMS", [Platform.NUMBER]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_value( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_charger: MagicMock, +) -> None: + """Test the disabled by default sensor entities.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.openevse_mock_config_charge_rate", ATTR_VALUE: 32.0}, + blocking=True, + ) + mock_charger.set_current.assert_called_once_with(32.0)