Add Homee integration to Core (#133738)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Markus Adrario 2025-01-03 12:44:06 +00:00 committed by GitHub
parent 8ad7c522f4
commit eec5fb2133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 863 additions and 0 deletions

View File

@ -637,6 +637,8 @@ build.json @home-assistant/supervisor
/tests/components/homeassistant_sky_connect/ @home-assistant/core
/homeassistant/components/homeassistant_yellow/ @home-assistant/core
/tests/components/homeassistant_yellow/ @home-assistant/core
/homeassistant/components/homee/ @Taraman17
/tests/components/homee/ @Taraman17
/homeassistant/components/homekit/ @bdraco
/tests/components/homekit/ @bdraco
/homeassistant/components/homekit_controller/ @Jc2k @bdraco

View File

@ -0,0 +1,85 @@
"""The Homee integration."""
import logging
from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.COVER]
type HomeeConfigEntry = ConfigEntry[Homee]
async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
"""Set up homee from a config entry."""
# Create the Homee api object using host, user,
# password & pyHomee instance from the config
homee = Homee(
host=entry.data[CONF_HOST],
user=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
device="HA_" + hass.config.location_name,
reconnect_interval=10,
max_retries=100,
)
# Start the homee websocket connection as a new task
# and wait until we are connected
try:
await homee.get_access_token()
except HomeeConnectionFailedException as exc:
raise ConfigEntryNotReady(
f"Connection to Homee failed: {exc.__cause__}"
) from exc
except HomeeAuthFailedException as exc:
raise ConfigEntryNotReady(
f"Authentication to Homee failed: {exc.__cause__}"
) from exc
hass.loop.create_task(homee.run())
await homee.wait_until_connected()
entry.runtime_data = homee
entry.async_on_unload(homee.disconnect)
async def _connection_update_callback(connected: bool) -> None:
"""Call when the device is notified of changes."""
if connected:
_LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST])
else:
_LOGGER.warning("Disconnected from Homee at %s", entry.data[CONF_HOST])
await homee.add_connection_listener(_connection_update_callback)
# create device register entry
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={
(dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address))
},
identifiers={(DOMAIN, homee.settings.uid)},
manufacturer="homee",
name=homee.settings.homee_name,
model="homee",
sw_version=homee.settings.version,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> bool:
"""Unload a homee config entry."""
# Unload platforms
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,85 @@
"""Config flow for homee integration."""
import logging
from typing import Any
from pyHomee import (
Homee,
HomeeAuthFailedException as HomeeAuthenticationFailedException,
HomeeConnectionFailedException,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
AUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for homee."""
VERSION = 1
homee: Homee
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial user step."""
errors = {}
if user_input is not None:
self.homee = Homee(
user_input[CONF_HOST],
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = "cannot_connect"
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
_LOGGER.info("Got access token for homee")
self.hass.loop.create_task(self.homee.run())
_LOGGER.debug("Homee task created")
await self.homee.wait_until_connected()
_LOGGER.info("Homee connected")
self.homee.disconnect()
_LOGGER.debug("Homee disconnecting")
await self.homee.wait_until_disconnected()
_LOGGER.info("Homee config successfully tested")
await self.async_set_unique_id(self.homee.settings.uid)
self._abort_if_unique_id_configured()
_LOGGER.info(
"Created new homee entry with ID %s", self.homee.settings.uid
)
return self.async_create_entry(
title=f"{self.homee.settings.homee_name} ({self.homee.host})",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=AUTH_SCHEMA,
errors=errors,
)

View File

@ -0,0 +1,4 @@
"""Constants for the homee integration."""
# General
DOMAIN = "homee"

View File

@ -0,0 +1,261 @@
"""The homee cover platform."""
import logging
from typing import Any, cast
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.cover import (
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeNodeEntity
_LOGGER = logging.getLogger(__name__)
OPEN_CLOSE_ATTRIBUTES = [
AttributeType.OPEN_CLOSE,
AttributeType.SLAT_ROTATION_IMPULSE,
AttributeType.UP_DOWN,
]
POSITION_ATTRIBUTES = [AttributeType.POSITION, AttributeType.SHUTTER_SLAT_POSITION]
def get_open_close_attribute(node: HomeeNode) -> HomeeAttribute:
"""Return the attribute used for opening/closing the cover."""
# We assume, that no device has UP_DOWN and OPEN_CLOSE, but only one of them.
if (open_close := node.get_attribute_by_type(AttributeType.UP_DOWN)) is None:
open_close = node.get_attribute_by_type(AttributeType.OPEN_CLOSE)
return open_close
def get_cover_features(
node: HomeeNode, open_close_attribute: HomeeAttribute
) -> CoverEntityFeature:
"""Determine the supported cover features of a homee node based on the available attributes."""
features = CoverEntityFeature(0)
if open_close_attribute.editable:
features |= (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
)
# Check for up/down position settable.
attribute = node.get_attribute_by_type(AttributeType.POSITION)
if attribute is not None:
if attribute.editable:
features |= CoverEntityFeature.SET_POSITION
if node.get_attribute_by_type(AttributeType.SLAT_ROTATION_IMPULSE) is not None:
features |= CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
if node.get_attribute_by_type(AttributeType.SHUTTER_SLAT_POSITION) is not None:
features |= CoverEntityFeature.SET_TILT_POSITION
return features
def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
"""Determine the device class a homee node based on the node profile."""
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
return COVER_DEVICE_PROFILES.get(node.profile)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddEntitiesCallback,
) -> None:
"""Add the homee platform for the cover integration."""
async_add_devices(
HomeeCover(node, config_entry)
for node in config_entry.runtime_data.nodes
if is_cover_node(node)
)
def is_cover_node(node: HomeeNode) -> bool:
"""Determine if a node is controllable as a homee cover based on its profile and attributes."""
return node.profile in [
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
NodeProfile.GARAGE_DOOR_OPERATOR,
NodeProfile.SHUTTER_POSITION_SWITCH,
]
class HomeeCover(HomeeNodeEntity, CoverEntity):
"""Representation of a homee cover device."""
_attr_name = None
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
"""Initialize a homee cover entity."""
super().__init__(node, entry)
self._open_close_attribute = get_open_close_attribute(node)
self._attr_supported_features = get_cover_features(
node, self._open_close_attribute
)
self._attr_device_class = get_device_class(node)
self._attr_unique_id = f"{self._attr_unique_id}-{self._open_close_attribute.id}"
@property
def current_cover_position(self) -> int | None:
"""Return the cover's position."""
# Translate the homee position values to HA's 0-100 scale
if self.has_attribute(AttributeType.POSITION):
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = attribute.current_value
position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
return 100 - position
return None
@property
def current_cover_tilt_position(self) -> int | None:
"""Return the cover's tilt position."""
# Translate the homee position values to HA's 0-100 scale
if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
attribute = self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = attribute.current_value
position = ((homee_position - homee_min) / (homee_max - homee_min)) * 100
return 100 - position
return None
@property
def is_opening(self) -> bool | None:
"""Return the opening status of the cover."""
if self._open_close_attribute is not None:
return (
self._open_close_attribute.get_value() == 3
if not self._open_close_attribute.is_reversed
else self._open_close_attribute.get_value() == 4
)
return None
@property
def is_closing(self) -> bool | None:
"""Return the closing status of the cover."""
if self._open_close_attribute is not None:
return (
self._open_close_attribute.get_value() == 4
if not self._open_close_attribute.is_reversed
else self._open_close_attribute.get_value() == 3
)
return None
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
if self.has_attribute(AttributeType.POSITION):
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
return attribute.get_value() == attribute.maximum
if self._open_close_attribute is not None:
if not self._open_close_attribute.is_reversed:
return self._open_close_attribute.get_value() == 1
return self._open_close_attribute.get_value() == 0
# If none of the above is present, it might be a slat only cover.
if self.has_attribute(AttributeType.SHUTTER_SLAT_POSITION):
attribute = self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
return attribute.get_value() == attribute.minimum
return None
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
if not self._open_close_attribute.is_reversed:
await self.async_set_value(self._open_close_attribute, 0)
else:
await self.async_set_value(self._open_close_attribute, 1)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
if not self._open_close_attribute.is_reversed:
await self.async_set_value(self._open_close_attribute, 1)
else:
await self.async_set_value(self._open_close_attribute, 0)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
if CoverEntityFeature.SET_POSITION in self.supported_features:
position = 100 - cast(int, kwargs[ATTR_POSITION])
# Convert position to range of our entity.
attribute = self._node.get_attribute_by_type(AttributeType.POSITION)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
await self.async_set_value(AttributeType.POSITION, homee_position)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.async_set_value(self._open_close_attribute, 2)
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
slat_attribute = self._node.get_attribute_by_type(
AttributeType.SLAT_ROTATION_IMPULSE
)
if not slat_attribute.is_reversed:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2)
else:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
slat_attribute = self._node.get_attribute_by_type(
AttributeType.SLAT_ROTATION_IMPULSE
)
if not slat_attribute.is_reversed:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 1)
else:
await self.async_set_value(AttributeType.SLAT_ROTATION_IMPULSE, 2)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
if CoverEntityFeature.SET_TILT_POSITION in self.supported_features:
position = 100 - cast(int, kwargs[ATTR_TILT_POSITION])
# Convert position to range of our entity.
attribute = self._node.get_attribute_by_type(
AttributeType.SHUTTER_SLAT_POSITION
)
homee_min = attribute.minimum
homee_max = attribute.maximum
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
await self.async_set_value(
AttributeType.SHUTTER_SLAT_POSITION, homee_position
)

View File

@ -0,0 +1,88 @@
"""Base Entities for Homee integration."""
from pyHomee.const import AttributeType, NodeProfile, NodeState
from pyHomee.model import HomeeNode
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import HomeeConfigEntry
from .const import DOMAIN
from .helpers import get_name_for_enum
class HomeeNodeEntity(Entity):
"""Representation of an Entity that uses more than one HomeeAttribute."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
"""Initialize the wrapper using a HomeeNode and target entity."""
self._node = node
self._attr_unique_id = f"{entry.runtime_data.settings.uid}-{node.id}"
self._entry = entry
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(node.id))},
name=node.name,
model=get_name_for_enum(NodeProfile, node.profile),
sw_version=self._get_software_version(),
via_device=(DOMAIN, entry.runtime_data.settings.uid),
)
self._host_connected = entry.runtime_data.connected
async def async_added_to_hass(self) -> None:
"""Add the homee binary sensor device to home assistant."""
self.async_on_remove(self._node.add_on_changed_listener(self._on_node_updated))
self.async_on_remove(
await self._entry.runtime_data.add_connection_listener(
self._on_connection_changed
)
)
@property
def available(self) -> bool:
"""Return the availability of the underlying node."""
return self._node.state == NodeState.AVAILABLE and self._host_connected
async def async_update(self) -> None:
"""Fetch new state data for this node."""
# Base class requests the whole node, if only a single attribute is needed
# the platform will overwrite this method.
homee = self._entry.runtime_data
await homee.update_node(self._node.id)
def _get_software_version(self) -> str | None:
"""Return the software version of the node."""
if self.has_attribute(AttributeType.FIRMWARE_REVISION):
return self._node.get_attribute_by_type(
AttributeType.FIRMWARE_REVISION
).get_value()
if self.has_attribute(AttributeType.SOFTWARE_REVISION):
return self._node.get_attribute_by_type(
AttributeType.SOFTWARE_REVISION
).get_value()
return None
def has_attribute(self, attribute_type: AttributeType) -> bool:
"""Check if an attribute of the given type exists."""
return attribute_type in self._node.attribute_map
async def async_set_value(self, attribute_type: int, value: float) -> None:
"""Set an attribute value on the homee node."""
await self.async_set_value_by_id(
self._node.get_attribute_by_type(attribute_type).id, value
)
async def async_set_value_by_id(self, attribute_id: int, value: float) -> None:
"""Set an attribute value on the homee node."""
homee = self._entry.runtime_data
await homee.set_value(self._node.id, attribute_id, value)
def _on_node_updated(self, node: HomeeNode) -> None:
self.schedule_update_ha_state()
async def _on_connection_changed(self, connected: bool) -> None:
self._host_connected = connected
self.schedule_update_ha_state()

View File

@ -0,0 +1,16 @@
"""Helper functions for the homee custom component."""
import logging
_LOGGER = logging.getLogger(__name__)
def get_name_for_enum(att_class, att_id) -> str:
"""Return the enum item name for a given integer."""
try:
attribute_name = att_class(att_id).name
except ValueError:
_LOGGER.warning("Value %s does not exist in %s", att_id, att_class.__name__)
return "Unknown"
return attribute_name

View File

@ -0,0 +1,12 @@
{
"domain": "homee",
"name": "Homee",
"codeowners": ["@Taraman17"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/homee",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "bronze",
"requirements": ["pyHomee==1.2.0"]
}

View File

@ -0,0 +1,68 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any additional actions.
appropriate-polling:
status: exempt
comment: Integration is push based.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
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: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
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: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@ -0,0 +1,28 @@
{
"config": {
"flow_title": "Homee {name} ({host})",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"title": "Configure homee",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The IP address of your Homee.",
"username": "The username for your Homee.",
"password": "The password for your Homee."
}
}
}
}
}

View File

@ -255,6 +255,7 @@ FLOWS = {
"holiday",
"home_connect",
"homeassistant_sky_connect",
"homee",
"homekit",
"homekit_controller",
"homematicip_cloud",

View File

@ -2600,6 +2600,12 @@
"integration_type": "virtual",
"supported_by": "netatmo"
},
"homee": {
"name": "Homee",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
"homematic": {
"name": "Homematic",
"integrations": {

View File

@ -1759,6 +1759,9 @@ pyEmby==1.10
# homeassistant.components.hikvision
pyHik==0.3.2
# homeassistant.components.homee
pyHomee==1.2.0
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1

View File

@ -1448,6 +1448,9 @@ pyDuotecno==2024.10.1
# homeassistant.components.electrasmart
pyElectra==1.2.4
# homeassistant.components.homee
pyHomee==1.2.0
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1

View File

@ -0,0 +1 @@
"""Tests for the homee component."""

View File

@ -0,0 +1,68 @@
"""Fixtures for Homee integration tests."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from typing_extensions import Generator
from homeassistant.components.homee.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry
HOMEE_ID = "00055511EECC"
HOMEE_IP = "192.168.1.11"
HOMEE_NAME = "TestHomee"
TESTUSER = "testuser"
TESTPASS = "testpass"
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title=f"{HOMEE_NAME} ({HOMEE_IP})",
domain=DOMAIN,
data={
CONF_HOST: HOMEE_IP,
CONF_USERNAME: TESTUSER,
CONF_PASSWORD: TESTPASS,
},
unique_id=HOMEE_ID,
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.homee.async_setup_entry", return_value=True
) as mock_setup:
yield mock_setup
@pytest.fixture
def mock_homee() -> Generator[AsyncMock]:
"""Return a mock Homee instance."""
with (
patch(
"homeassistant.components.homee.config_flow.Homee", autospec=True
) as mocked_homee,
patch(
"homeassistant.components.homee.Homee",
autospec=True,
),
):
homee = mocked_homee.return_value
homee.host = HOMEE_IP
homee.user = TESTUSER
homee.password = TESTPASS
homee.settings = MagicMock()
homee.settings.uid = HOMEE_ID
homee.settings.homee_name = HOMEE_NAME
homee.reconnect_interval = 10
homee.get_access_token.return_value = "test_token"
yield homee

View File

@ -0,0 +1,132 @@
"""Test the Homee config flow."""
from unittest.mock import AsyncMock
from pyHomee import HomeeAuthFailedException, HomeeConnectionFailedException
import pytest
from homeassistant.components.homee.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import HOMEE_ID, HOMEE_IP, HOMEE_NAME, TESTPASS, TESTUSER
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_homee", "mock_config_entry", "mock_setup_entry")
async def test_config_flow(
hass: HomeAssistant,
) -> None:
"""Test the complete config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["step_id"] == "user"
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: HOMEE_IP,
CONF_USERNAME: TESTUSER,
CONF_PASSWORD: TESTPASS,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
"host": HOMEE_IP,
"username": TESTUSER,
"password": TESTPASS,
}
assert result["title"] == f"{HOMEE_NAME} ({HOMEE_IP})"
assert result["result"].unique_id == HOMEE_ID
@pytest.mark.parametrize(
("side_eff", "error"),
[
(
HomeeConnectionFailedException("connection timed out"),
{"base": "cannot_connect"},
),
(
HomeeAuthFailedException("wrong username or password"),
{"base": "invalid_auth"},
),
(
Exception,
{"base": "unknown"},
),
],
)
async def test_config_flow_errors(
hass: HomeAssistant,
mock_homee: AsyncMock,
side_eff: Exception,
error: dict[str, str],
) -> None:
"""Test the config flow fails as expected."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
flow_id = result["flow_id"]
mock_homee.get_access_token.side_effect = side_eff
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={
CONF_HOST: HOMEE_IP,
CONF_USERNAME: TESTUSER,
CONF_PASSWORD: TESTPASS,
},
)
assert result["type"] == FlowResultType.FORM
assert result["errors"] == error
mock_homee.get_access_token.side_effect = None
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input={
CONF_HOST: HOMEE_IP,
CONF_USERNAME: TESTUSER,
CONF_PASSWORD: TESTPASS,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_homee")
async def test_flow_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test config flow aborts when already configured."""
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
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: HOMEE_IP,
CONF_USERNAME: TESTUSER,
CONF_PASSWORD: TESTPASS,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"