diff --git a/homeassistant/components/hdfury/__init__.py b/homeassistant/components/hdfury/__init__.py index fcf40cbbac0..9e8f1cc092c 100644 --- a/homeassistant/components/hdfury/__init__.py +++ b/homeassistant/components/hdfury/__init__.py @@ -7,6 +7,7 @@ from .coordinator import HDFuryConfigEntry, HDFuryCoordinator PLATFORMS = [ Platform.BUTTON, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/hdfury/icons.json b/homeassistant/components/hdfury/icons.json index 91d1c3c6784..60123cec657 100644 --- a/homeassistant/components/hdfury/icons.json +++ b/homeassistant/components/hdfury/icons.json @@ -5,6 +5,14 @@ "default": "mdi:connection" } }, + "number": { + "oled_fade": { + "default": "mdi:cellphone-information" + }, + "reboot_timer": { + "default": "mdi:timer-refresh" + } + }, "select": { "opmode": { "default": "mdi:cogs" diff --git a/homeassistant/components/hdfury/number.py b/homeassistant/components/hdfury/number.py new file mode 100644 index 00000000000..3693c5171ba --- /dev/null +++ b/homeassistant/components/hdfury/number.py @@ -0,0 +1,101 @@ +"""Number platform for HDFury Integration.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from hdfury import HDFuryAPI, HDFuryError + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import HDFuryConfigEntry +from .entity import HDFuryEntity + +PARALLEL_UPDATES = 1 + + +@dataclass(kw_only=True, frozen=True) +class HDFuryNumberEntityDescription(NumberEntityDescription): + """Description for HDFury number entities.""" + + set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]] + + +NUMBERS: tuple[HDFuryNumberEntityDescription, ...] = ( + HDFuryNumberEntityDescription( + key="oledfade", + translation_key="oled_fade", + mode=NumberMode.BOX, + native_min_value=1, + native_max_value=100, + native_step=1, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda client, value: client.set_oled_fade(value), + ), + HDFuryNumberEntityDescription( + key="reboottimer", + translation_key="reboot_timer", + mode=NumberMode.BOX, + native_min_value=0, + native_max_value=100, + native_step=1, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda client, value: client.set_reboot_timer(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HDFuryConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up numbers using the platform schema.""" + + coordinator = entry.runtime_data + + async_add_entities( + HDFuryNumber(coordinator, description) + for description in NUMBERS + if description.key in coordinator.data.config + ) + + +class HDFuryNumber(HDFuryEntity, NumberEntity): + """Base HDFury Number Class.""" + + entity_description: HDFuryNumberEntityDescription + + @property + def native_value(self) -> float: + """Return the current number value.""" + + return float(self.coordinator.data.config[self.entity_description.key]) + + async def async_set_native_value(self, value: float) -> None: + """Set Number Value Event.""" + + try: + await self.entity_description.set_value_fn( + self.coordinator.client, str(int(value)) + ) + except HDFuryError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/hdfury/strings.json b/homeassistant/components/hdfury/strings.json index b5addd5668a..54a09bd9485 100644 --- a/homeassistant/components/hdfury/strings.json +++ b/homeassistant/components/hdfury/strings.json @@ -40,6 +40,14 @@ "name": "Issue hotplug" } }, + "number": { + "oled_fade": { + "name": "OLED fade timer" + }, + "reboot_timer": { + "name": "Restart timer" + } + }, "select": { "opmode": { "name": "Operation mode", diff --git a/tests/components/hdfury/conftest.py b/tests/components/hdfury/conftest.py index cf8c1b5308b..b296ed902b8 100644 --- a/tests/components/hdfury/conftest.py +++ b/tests/components/hdfury/conftest.py @@ -103,7 +103,9 @@ def mock_hdfury_client() -> Generator[AsyncMock]: "mutetx1": "1", "relay": "0", "macaddr": "c7:1c:df:9d:f6:40", + "reboottimer": "0", "oled": "1", + "oledfade": "30", } ) diff --git a/tests/components/hdfury/snapshots/test_diagnostics.ambr b/tests/components/hdfury/snapshots/test_diagnostics.ambr index d77ab9eccb5..6d4043fb3b1 100644 --- a/tests/components/hdfury/snapshots/test_diagnostics.ambr +++ b/tests/components/hdfury/snapshots/test_diagnostics.ambr @@ -24,6 +24,8 @@ 'mutetx0': '1', 'mutetx1': '1', 'oled': '1', + 'oledfade': '30', + 'reboottimer': '0', 'relay': '0', 'tx0plus5': '1', 'tx1plus5': '1', diff --git a/tests/components/hdfury/snapshots/test_number.ambr b/tests/components/hdfury/snapshots/test_number.ambr new file mode 100644 index 00000000000..20cde1949d6 --- /dev/null +++ b/tests/components/hdfury/snapshots/test_number.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_number_entities[number.hdfury_vrroom_02_oled_fade_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 1, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.hdfury_vrroom_02_oled_fade_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'OLED fade timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'OLED fade timer', + 'platform': 'hdfury', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'oled_fade', + 'unique_id': '000123456789_oledfade', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[number.hdfury_vrroom_02_oled_fade_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'HDFury VRROOM-02 OLED fade timer', + 'max': 100, + 'min': 1, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.hdfury_vrroom_02_oled_fade_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_number_entities[number.hdfury_vrroom_02_restart_timer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.hdfury_vrroom_02_restart_timer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart timer', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart timer', + 'platform': 'hdfury', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reboot_timer', + 'unique_id': '000123456789_reboottimer', + 'unit_of_measurement': , + }) +# --- +# name: test_number_entities[number.hdfury_vrroom_02_restart_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'HDFury VRROOM-02 Restart timer', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.hdfury_vrroom_02_restart_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/hdfury/test_number.py b/tests/components/hdfury/test_number.py new file mode 100644 index 00000000000..b39a73d8467 --- /dev/null +++ b/tests/components/hdfury/test_number.py @@ -0,0 +1,122 @@ +"""Tests for the HDFury number platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from hdfury import HDFuryError +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, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_number_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test HDFury number entities.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("number.hdfury_vrroom_02_oled_fade_timer", "set_oled_fade"), + ("number.hdfury_vrroom_02_restart_timer", "set_reboot_timer"), + ], +) +async def test_number_set_value( + hass: HomeAssistant, + mock_hdfury_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, +) -> None: + """Test setting a device number value.""" + + 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: 50}, + blocking=True, + ) + + getattr(mock_hdfury_client, method).assert_awaited_once_with("50") + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("number.hdfury_vrroom_02_oled_fade_timer", "set_oled_fade"), + ("number.hdfury_vrroom_02_restart_timer", "set_reboot_timer"), + ], +) +async def test_number_error( + hass: HomeAssistant, + mock_hdfury_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_id: str, + method: str, +) -> None: + """Test set number value raises HomeAssistantError on API failure.""" + + getattr(mock_hdfury_client, method).side_effect = HDFuryError() + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + with pytest.raises( + HomeAssistantError, + match="An error occurred while communicating with HDFury device", + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 50}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("number.hdfury_vrroom_02_oled_fade_timer"), + ("number.hdfury_vrroom_02_restart_timer"), + ], +) +async def test_number_entities_unavailable_on_error( + hass: HomeAssistant, + mock_hdfury_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + entity_id: str, +) -> None: + """Test API error causes entities to become unavailable.""" + + await setup_integration(hass, mock_config_entry, [Platform.NUMBER]) + + mock_hdfury_client.get_info.side_effect = HDFuryError() + + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE