From 341c441e618f386ecbf98085b4be8b2ac5786288 Mon Sep 17 00:00:00 2001 From: Glenn de Haan Date: Thu, 8 Jan 2026 12:21:04 +0100 Subject: [PATCH] Add HDFury integration (#159996) --- CODEOWNERS | 2 + homeassistant/components/hdfury/__init__.py | 29 +++ .../components/hdfury/config_flow.py | 54 +++++ homeassistant/components/hdfury/const.py | 3 + .../components/hdfury/coordinator.py | 67 ++++++ homeassistant/components/hdfury/entity.py | 39 ++++ homeassistant/components/hdfury/icons.json | 15 ++ homeassistant/components/hdfury/manifest.json | 11 + .../components/hdfury/quality_scale.yaml | 74 +++++++ homeassistant/components/hdfury/select.py | 122 +++++++++++ homeassistant/components/hdfury/strings.json | 64 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/hdfury/__init__.py | 13 ++ tests/components/hdfury/conftest.py | 79 +++++++ .../hdfury/snapshots/test_select.ambr | 192 ++++++++++++++++++ tests/components/hdfury/test_config_flow.py | 96 +++++++++ tests/components/hdfury/test_select.py | 30 +++ 20 files changed, 903 insertions(+) create mode 100644 homeassistant/components/hdfury/__init__.py create mode 100644 homeassistant/components/hdfury/config_flow.py create mode 100644 homeassistant/components/hdfury/const.py create mode 100644 homeassistant/components/hdfury/coordinator.py create mode 100644 homeassistant/components/hdfury/entity.py create mode 100644 homeassistant/components/hdfury/icons.json create mode 100644 homeassistant/components/hdfury/manifest.json create mode 100644 homeassistant/components/hdfury/quality_scale.yaml create mode 100644 homeassistant/components/hdfury/select.py create mode 100644 homeassistant/components/hdfury/strings.json create mode 100644 tests/components/hdfury/__init__.py create mode 100644 tests/components/hdfury/conftest.py create mode 100644 tests/components/hdfury/snapshots/test_select.ambr create mode 100644 tests/components/hdfury/test_config_flow.py create mode 100644 tests/components/hdfury/test_select.py diff --git a/CODEOWNERS b/CODEOWNERS index 58082d4a87e..b6459c82ac8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -661,6 +661,8 @@ build.json @home-assistant/supervisor /tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /homeassistant/components/hassio/ @home-assistant/supervisor /tests/components/hassio/ @home-assistant/supervisor +/homeassistant/components/hdfury/ @glenndehaan +/tests/components/hdfury/ @glenndehaan /homeassistant/components/hdmi_cec/ @inytar /tests/components/hdmi_cec/ @inytar /homeassistant/components/heatmiser/ @andylockran diff --git a/homeassistant/components/hdfury/__init__.py b/homeassistant/components/hdfury/__init__.py new file mode 100644 index 00000000000..55136382dfa --- /dev/null +++ b/homeassistant/components/hdfury/__init__.py @@ -0,0 +1,29 @@ +"""The HDFury Integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import HDFuryConfigEntry, HDFuryCoordinator + +PLATFORMS = [ + Platform.SELECT, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: HDFuryConfigEntry) -> bool: + """Set up HDFury as config entry.""" + + coordinator = HDFuryCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: HDFuryConfigEntry) -> bool: + """Unload a HDFury config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hdfury/config_flow.py b/homeassistant/components/hdfury/config_flow.py new file mode 100644 index 00000000000..3b8e7d1cfc3 --- /dev/null +++ b/homeassistant/components/hdfury/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for HDFury Integration.""" + +from typing import Any + +from hdfury import HDFuryAPI, HDFuryError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class HDFuryConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle Config Flow for HDFury.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle Initial Setup.""" + + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + + serial = await self._validate_connection(host) + if serial is not None: + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"HDFury ({host})", data=user_input + ) + + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def _validate_connection(self, host: str) -> str | None: + """Try to fetch serial number to confirm it's a valid HDFury device.""" + + client = HDFuryAPI(host, async_get_clientsession(self.hass)) + + try: + data = await client.get_board() + except HDFuryError: + return None + + return data["serial"] diff --git a/homeassistant/components/hdfury/const.py b/homeassistant/components/hdfury/const.py new file mode 100644 index 00000000000..9856277b3cb --- /dev/null +++ b/homeassistant/components/hdfury/const.py @@ -0,0 +1,3 @@ +"""Constants for HDFury Integration.""" + +DOMAIN = "hdfury" diff --git a/homeassistant/components/hdfury/coordinator.py b/homeassistant/components/hdfury/coordinator.py new file mode 100644 index 00000000000..2a5af9e8310 --- /dev/null +++ b/homeassistant/components/hdfury/coordinator.py @@ -0,0 +1,67 @@ +"""DataUpdateCoordinator for HDFury Integration.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Final + +from hdfury import HDFuryAPI, HDFuryError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL: Final = timedelta(seconds=60) + +type HDFuryConfigEntry = ConfigEntry[HDFuryCoordinator] + + +@dataclass(kw_only=True, frozen=True) +class HDFuryData: + """HDFury Data Class.""" + + board: dict[str, str] + info: dict[str, str] + config: dict[str, str] + + +class HDFuryCoordinator(DataUpdateCoordinator[HDFuryData]): + """HDFury Device Coordinator Class.""" + + def __init__(self, hass: HomeAssistant, entry: HDFuryConfigEntry) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="HDFury", + update_interval=SCAN_INTERVAL, + ) + self.host: str = entry.data[CONF_HOST] + self.client = HDFuryAPI(self.host, async_get_clientsession(hass)) + + async def _async_update_data(self) -> HDFuryData: + """Fetch the latest device data.""" + + try: + board = await self.client.get_board() + info = await self.client.get_info() + config = await self.client.get_config() + except HDFuryError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error + + return HDFuryData( + board=board, + info=info, + config=config, + ) diff --git a/homeassistant/components/hdfury/entity.py b/homeassistant/components/hdfury/entity.py new file mode 100644 index 00000000000..ca083c3d48c --- /dev/null +++ b/homeassistant/components/hdfury/entity.py @@ -0,0 +1,39 @@ +"""Base class for HDFury entities.""" + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import HDFuryCoordinator + + +class HDFuryEntity(CoordinatorEntity[HDFuryCoordinator]): + """Common elements for all entities.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: HDFuryCoordinator, entity_description: EntityDescription + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + + self.entity_description = entity_description + + self._attr_unique_id = ( + f"{coordinator.data.board['serial']}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + name=f"HDFury {coordinator.data.board['hostname']}", + manufacturer="HDFury", + model=coordinator.data.board["hostname"].split("-")[0], + serial_number=coordinator.data.board["serial"], + sw_version=coordinator.data.board["version"].removeprefix("FW: "), + hw_version=coordinator.data.board.get("pcbv"), + configuration_url=f"http://{coordinator.host}", + connections={ + (dr.CONNECTION_NETWORK_MAC, coordinator.data.config["macaddr"]) + }, + ) diff --git a/homeassistant/components/hdfury/icons.json b/homeassistant/components/hdfury/icons.json new file mode 100644 index 00000000000..3c28322b3e6 --- /dev/null +++ b/homeassistant/components/hdfury/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "select": { + "opmode": { + "default": "mdi:cogs" + }, + "portseltx0": { + "default": "mdi:hdmi-port" + }, + "portseltx1": { + "default": "mdi:hdmi-port" + } + } + } +} diff --git a/homeassistant/components/hdfury/manifest.json b/homeassistant/components/hdfury/manifest.json new file mode 100644 index 00000000000..93c09362f30 --- /dev/null +++ b/homeassistant/components/hdfury/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hdfury", + "name": "HDFury", + "codeowners": ["@glenndehaan"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hdfury", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["hdfury==1.3.1"] +} diff --git a/homeassistant/components/hdfury/quality_scale.yaml b/homeassistant/components/hdfury/quality_scale.yaml new file mode 100644 index 00000000000..614a3344f10 --- /dev/null +++ b/homeassistant/components/hdfury/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: Integration has no authentication flow. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Device type integration. + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: Device type integration. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/hdfury/select.py b/homeassistant/components/hdfury/select.py new file mode 100644 index 00000000000..c0849dc5ca9 --- /dev/null +++ b/homeassistant/components/hdfury/select.py @@ -0,0 +1,122 @@ +"""Select platform for HDFury Integration.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from hdfury import ( + OPERATION_MODES, + TX0_INPUT_PORTS, + TX1_INPUT_PORTS, + HDFuryAPI, + HDFuryError, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +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, HDFuryCoordinator +from .entity import HDFuryEntity + + +@dataclass(kw_only=True, frozen=True) +class HDFurySelectEntityDescription(SelectEntityDescription): + """Description for HDFury select entities.""" + + set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]] + + +SELECT_PORTS: tuple[HDFurySelectEntityDescription, ...] = ( + HDFurySelectEntityDescription( + key="portseltx0", + translation_key="portseltx0", + options=list(TX0_INPUT_PORTS.keys()), + set_value_fn=lambda coordinator, value: _set_ports(coordinator), + ), + HDFurySelectEntityDescription( + key="portseltx1", + translation_key="portseltx1", + options=list(TX1_INPUT_PORTS.keys()), + set_value_fn=lambda coordinator, value: _set_ports(coordinator), + ), +) + + +SELECT_OPERATION_MODE: HDFurySelectEntityDescription = HDFurySelectEntityDescription( + key="opmode", + translation_key="opmode", + options=list(OPERATION_MODES.keys()), + set_value_fn=lambda coordinator, value: coordinator.client.set_operation_mode( + value + ), +) + + +async def _set_ports(coordinator: HDFuryCoordinator) -> None: + tx0 = coordinator.data.info.get("portseltx0") + tx1 = coordinator.data.info.get("portseltx1") + + if tx0 is None or tx1 is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="tx_state_error", + translation_placeholders={"details": f"tx0={tx0}, tx1={tx1}"}, + ) + + await coordinator.client.set_port_selection(tx0, tx1) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HDFuryConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up selects using the platform schema.""" + + coordinator = entry.runtime_data + + entities: list[HDFuryEntity] = [] + + for description in SELECT_PORTS: + if description.key not in coordinator.data.info: + continue + + entities.append(HDFurySelect(coordinator, description)) + + # Add OPMODE select if present + if "opmode" in coordinator.data.info: + entities.append(HDFurySelect(coordinator, SELECT_OPERATION_MODE)) + + async_add_entities(entities) + + +class HDFurySelect(HDFuryEntity, SelectEntity): + """HDFury Select Class.""" + + entity_description: HDFurySelectEntityDescription + + @property + def current_option(self) -> str: + """Return the current option.""" + + return self.coordinator.data.info[self.entity_description.key] + + async def async_select_option(self, option: str) -> None: + """Update the current option.""" + + # Update local data first + self.coordinator.data.info[self.entity_description.key] = option + + # Send command to device + try: + await self.entity_description.set_value_fn(self.coordinator, option) + except HDFuryError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="communication_error", + ) from error + + # Trigger HA coordinator refresh + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/hdfury/strings.json b/homeassistant/components/hdfury/strings.json new file mode 100644 index 00000000000..5e728b157c7 --- /dev/null +++ b/homeassistant/components/hdfury/strings.json @@ -0,0 +1,64 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your HDFury device." + }, + "description": "Set up your HDFury to integrate with Home Assistant." + } + } + }, + "entity": { + "select": { + "opmode": { + "name": "Operation mode", + "state": { + "0": "Mode 0 - Splitter TX0/TX1 FRL5 VRR", + "1": "Mode 1 - Splitter TX0/TX1 UPSCALE FRL5", + "2": "Mode 2 - Matrix TMDS", + "3": "Mode 3 - Matrix FRL->TMDS", + "4": "Mode 4 - Matrix DOWNSCALE", + "5": "Mode 5 - Matrix RX0:FRL5 + RX1-3:TMDS" + } + }, + "portseltx0": { + "name": "Port select TX0", + "state": { + "0": "Input 0", + "1": "Input 1", + "2": "Input 2", + "3": "Input 3", + "4": "Copy TX1" + } + }, + "portseltx1": { + "name": "Port select TX1", + "state": { + "0": "Input 0", + "1": "Input 1", + "2": "Input 2", + "3": "Input 3", + "4": "Copy TX0" + } + } + } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with HDFury device" + }, + "tx_state_error": { + "message": "An error occurred while validating TX states: {details}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ca9c32bd276..2f0829b0756 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -278,6 +278,7 @@ FLOWS = { "habitica", "hanna", "harmony", + "hdfury", "heos", "here_travel_time", "hikvision", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7adcacb09c0..971e472ac71 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2678,6 +2678,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "hdfury": { + "name": "HDFury", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "hdmi_cec": { "name": "HDMI-CEC", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index fb3b5f37a7c..ddbb3cf0bf8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1184,6 +1184,9 @@ hassil==3.5.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 +# homeassistant.components.hdfury +hdfury==1.3.1 + # homeassistant.components.heatmiser heatmiserV3==2.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f42cc16eec..06e09c5a35a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1051,6 +1051,9 @@ hassil==3.5.0 # homeassistant.components.jewish_calendar hdate[astral]==1.1.2 +# homeassistant.components.hdfury +hdfury==1.3.1 + # homeassistant.components.here_travel_time here-routing==1.2.0 diff --git a/tests/components/hdfury/__init__.py b/tests/components/hdfury/__init__.py new file mode 100644 index 00000000000..116a8aab197 --- /dev/null +++ b/tests/components/hdfury/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the HDFury integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/hdfury/conftest.py b/tests/components/hdfury/conftest.py new file mode 100644 index 00000000000..8478f7abe82 --- /dev/null +++ b/tests/components/hdfury/conftest.py @@ -0,0 +1,79 @@ +"""Common fixtures for the HDFury tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.hdfury.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + +TEST_HOST = "192.168.1.123" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.hdfury.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="000123456789", + data={ + CONF_HOST: TEST_HOST, + }, + ) + + +@pytest.fixture(autouse=True) +def mock_hdfury_client() -> Generator[AsyncMock]: + """Mock a HDFury client.""" + with ( + patch( + "homeassistant.components.hdfury.config_flow.HDFuryAPI", + autospec=True, + ) as mock_cf_client, + patch( + "homeassistant.components.hdfury.coordinator.HDFuryAPI", + autospec=True, + ) as mock_coord_client, + ): + # Config flow client + cf_client = mock_cf_client.return_value + cf_client.get_board = AsyncMock( + return_value={ + "hostname": "VRROOM-02", + "ipaddress": "192.168.1.123", + "serial": "000123456789", + "pcbv": "3", + "version": "FW: 0.61", + } + ) + + # Coordinator client + coord_client = mock_coord_client.return_value + coord_client.get_board = cf_client.get_board + coord_client.get_info = AsyncMock( + return_value={ + "portseltx0": "0", + "portseltx1": "4", + "opmode": "0", + } + ) + coord_client.get_config = AsyncMock( + return_value={ + "macaddr": "c7:1c:df:9d:f6:40", + } + ) + + yield coord_client diff --git a/tests/components/hdfury/snapshots/test_select.ambr b/tests/components/hdfury/snapshots/test_select.ambr new file mode 100644 index 00000000000..581cc895a0d --- /dev/null +++ b/tests/components/hdfury/snapshots/test_select.ambr @@ -0,0 +1,192 @@ +# serializer version: 1 +# name: test_select_entities[select.hdfury_vrroom_02_operation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hdfury_vrroom_02_operation_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Operation mode', + 'platform': 'hdfury', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'opmode', + 'unique_id': '000123456789_opmode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.hdfury_vrroom_02_operation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HDFury VRROOM-02 Operation mode', + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + ]), + }), + 'context': , + 'entity_id': 'select.hdfury_vrroom_02_operation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx0-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + '3', + '4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hdfury_vrroom_02_port_select_tx0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Port select TX0', + 'platform': 'hdfury', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'portseltx0', + 'unique_id': '000123456789_portseltx0', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HDFury VRROOM-02 Port select TX0', + 'options': list([ + '0', + '1', + '2', + '3', + '4', + ]), + }), + 'context': , + 'entity_id': 'select.hdfury_vrroom_02_port_select_tx0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + '3', + '4', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.hdfury_vrroom_02_port_select_tx1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Port select TX1', + 'platform': 'hdfury', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'portseltx1', + 'unique_id': '000123456789_portseltx1', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities[select.hdfury_vrroom_02_port_select_tx1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'HDFury VRROOM-02 Port select TX1', + 'options': list([ + '0', + '1', + '2', + '3', + '4', + ]), + }), + 'context': , + 'entity_id': 'select.hdfury_vrroom_02_port_select_tx1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/hdfury/test_config_flow.py b/tests/components/hdfury/test_config_flow.py new file mode 100644 index 00000000000..aeed0504600 --- /dev/null +++ b/tests/components/hdfury/test_config_flow.py @@ -0,0 +1,96 @@ +"""Test the HDFury config flow.""" + +from unittest.mock import AsyncMock + +from hdfury import HDFuryError + +from homeassistant.components.hdfury.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_async_step_user_gets_form_and_creates_entry( + hass: HomeAssistant, + mock_hdfury_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the we can view the form and that the config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + } + assert result["result"].unique_id == "000123456789" + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test that we abort if we attempt to submit the same entry twice.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.123"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_successful_recovery_after_connection_error( + hass: HomeAssistant, + mock_hdfury_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test error shown when connection fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Simulate a connection error by raising a HDFuryError + mock_hdfury_client.get_board.side_effect = HDFuryError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.123"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Simulate successful connection on retry + mock_hdfury_client.get_board.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.123"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + } + assert result["result"].unique_id == "000123456789" diff --git a/tests/components/hdfury/test_select.py b/tests/components/hdfury/test_select.py new file mode 100644 index 00000000000..e3491f2687a --- /dev/null +++ b/tests/components/hdfury/test_select.py @@ -0,0 +1,30 @@ +"""Tests for the HDFury select platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SELECT] + + +async def test_select_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test HDFury select entities.""" + + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)