mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 04:05:20 +01:00
Add HDFury integration (#159996)
This commit is contained in:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -661,6 +661,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
|
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
|
||||||
/homeassistant/components/hassio/ @home-assistant/supervisor
|
/homeassistant/components/hassio/ @home-assistant/supervisor
|
||||||
/tests/components/hassio/ @home-assistant/supervisor
|
/tests/components/hassio/ @home-assistant/supervisor
|
||||||
|
/homeassistant/components/hdfury/ @glenndehaan
|
||||||
|
/tests/components/hdfury/ @glenndehaan
|
||||||
/homeassistant/components/hdmi_cec/ @inytar
|
/homeassistant/components/hdmi_cec/ @inytar
|
||||||
/tests/components/hdmi_cec/ @inytar
|
/tests/components/hdmi_cec/ @inytar
|
||||||
/homeassistant/components/heatmiser/ @andylockran
|
/homeassistant/components/heatmiser/ @andylockran
|
||||||
|
|||||||
29
homeassistant/components/hdfury/__init__.py
Normal file
29
homeassistant/components/hdfury/__init__.py
Normal file
@@ -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)
|
||||||
54
homeassistant/components/hdfury/config_flow.py
Normal file
54
homeassistant/components/hdfury/config_flow.py
Normal file
@@ -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"]
|
||||||
3
homeassistant/components/hdfury/const.py
Normal file
3
homeassistant/components/hdfury/const.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Constants for HDFury Integration."""
|
||||||
|
|
||||||
|
DOMAIN = "hdfury"
|
||||||
67
homeassistant/components/hdfury/coordinator.py
Normal file
67
homeassistant/components/hdfury/coordinator.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
39
homeassistant/components/hdfury/entity.py
Normal file
39
homeassistant/components/hdfury/entity.py
Normal file
@@ -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"])
|
||||||
|
},
|
||||||
|
)
|
||||||
15
homeassistant/components/hdfury/icons.json
Normal file
15
homeassistant/components/hdfury/icons.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"select": {
|
||||||
|
"opmode": {
|
||||||
|
"default": "mdi:cogs"
|
||||||
|
},
|
||||||
|
"portseltx0": {
|
||||||
|
"default": "mdi:hdmi-port"
|
||||||
|
},
|
||||||
|
"portseltx1": {
|
||||||
|
"default": "mdi:hdmi-port"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
homeassistant/components/hdfury/manifest.json
Normal file
11
homeassistant/components/hdfury/manifest.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
74
homeassistant/components/hdfury/quality_scale.yaml
Normal file
74
homeassistant/components/hdfury/quality_scale.yaml
Normal file
@@ -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
|
||||||
122
homeassistant/components/hdfury/select.py
Normal file
122
homeassistant/components/hdfury/select.py
Normal file
@@ -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()
|
||||||
64
homeassistant/components/hdfury/strings.json
Normal file
64
homeassistant/components/hdfury/strings.json
Normal file
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -278,6 +278,7 @@ FLOWS = {
|
|||||||
"habitica",
|
"habitica",
|
||||||
"hanna",
|
"hanna",
|
||||||
"harmony",
|
"harmony",
|
||||||
|
"hdfury",
|
||||||
"heos",
|
"heos",
|
||||||
"here_travel_time",
|
"here_travel_time",
|
||||||
"hikvision",
|
"hikvision",
|
||||||
|
|||||||
@@ -2678,6 +2678,12 @@
|
|||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
},
|
},
|
||||||
|
"hdfury": {
|
||||||
|
"name": "HDFury",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling"
|
||||||
|
},
|
||||||
"hdmi_cec": {
|
"hdmi_cec": {
|
||||||
"name": "HDMI-CEC",
|
"name": "HDMI-CEC",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|||||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -1184,6 +1184,9 @@ hassil==3.5.0
|
|||||||
# homeassistant.components.jewish_calendar
|
# homeassistant.components.jewish_calendar
|
||||||
hdate[astral]==1.1.2
|
hdate[astral]==1.1.2
|
||||||
|
|
||||||
|
# homeassistant.components.hdfury
|
||||||
|
hdfury==1.3.1
|
||||||
|
|
||||||
# homeassistant.components.heatmiser
|
# homeassistant.components.heatmiser
|
||||||
heatmiserV3==2.0.4
|
heatmiserV3==2.0.4
|
||||||
|
|
||||||
|
|||||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -1051,6 +1051,9 @@ hassil==3.5.0
|
|||||||
# homeassistant.components.jewish_calendar
|
# homeassistant.components.jewish_calendar
|
||||||
hdate[astral]==1.1.2
|
hdate[astral]==1.1.2
|
||||||
|
|
||||||
|
# homeassistant.components.hdfury
|
||||||
|
hdfury==1.3.1
|
||||||
|
|
||||||
# homeassistant.components.here_travel_time
|
# homeassistant.components.here_travel_time
|
||||||
here-routing==1.2.0
|
here-routing==1.2.0
|
||||||
|
|
||||||
|
|||||||
13
tests/components/hdfury/__init__.py
Normal file
13
tests/components/hdfury/__init__.py
Normal file
@@ -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()
|
||||||
79
tests/components/hdfury/conftest.py
Normal file
79
tests/components/hdfury/conftest.py
Normal file
@@ -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
|
||||||
192
tests/components/hdfury/snapshots/test_select.ambr
Normal file
192
tests/components/hdfury/snapshots/test_select.ambr
Normal file
@@ -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': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'entity_id': 'select.hdfury_vrroom_02_operation_mode',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'entity_id': 'select.hdfury_vrroom_02_port_select_tx0',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'config_subentry_id': <ANY>,
|
||||||
|
'device_class': None,
|
||||||
|
'device_id': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'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': <ANY>,
|
||||||
|
'entity_id': 'select.hdfury_vrroom_02_port_select_tx1',
|
||||||
|
'last_changed': <ANY>,
|
||||||
|
'last_reported': <ANY>,
|
||||||
|
'last_updated': <ANY>,
|
||||||
|
'state': '4',
|
||||||
|
})
|
||||||
|
# ---
|
||||||
96
tests/components/hdfury/test_config_flow.py
Normal file
96
tests/components/hdfury/test_config_flow.py
Normal file
@@ -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"
|
||||||
30
tests/components/hdfury/test_select.py
Normal file
30
tests/components/hdfury/test_select.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user