mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Add Homee integration to Core (#133738)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
parent
8ad7c522f4
commit
eec5fb2133
@ -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
|
||||
|
85
homeassistant/components/homee/__init__.py
Normal file
85
homeassistant/components/homee/__init__.py
Normal 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)
|
85
homeassistant/components/homee/config_flow.py
Normal file
85
homeassistant/components/homee/config_flow.py
Normal 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,
|
||||
)
|
4
homeassistant/components/homee/const.py
Normal file
4
homeassistant/components/homee/const.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Constants for the homee integration."""
|
||||
|
||||
# General
|
||||
DOMAIN = "homee"
|
261
homeassistant/components/homee/cover.py
Normal file
261
homeassistant/components/homee/cover.py
Normal 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
|
||||
)
|
88
homeassistant/components/homee/entity.py
Normal file
88
homeassistant/components/homee/entity.py
Normal 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()
|
16
homeassistant/components/homee/helpers.py
Normal file
16
homeassistant/components/homee/helpers.py
Normal 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
|
12
homeassistant/components/homee/manifest.json
Normal file
12
homeassistant/components/homee/manifest.json
Normal 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"]
|
||||
}
|
68
homeassistant/components/homee/quality_scale.yaml
Normal file
68
homeassistant/components/homee/quality_scale.yaml
Normal 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
|
28
homeassistant/components/homee/strings.json
Normal file
28
homeassistant/components/homee/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -255,6 +255,7 @@ FLOWS = {
|
||||
"holiday",
|
||||
"home_connect",
|
||||
"homeassistant_sky_connect",
|
||||
"homee",
|
||||
"homekit",
|
||||
"homekit_controller",
|
||||
"homematicip_cloud",
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/homee/__init__.py
Normal file
1
tests/components/homee/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the homee component."""
|
68
tests/components/homee/conftest.py
Normal file
68
tests/components/homee/conftest.py
Normal 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
|
132
tests/components/homee/test_config_flow.py
Normal file
132
tests/components/homee/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user