mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add flow it integration (#175076)
Co-authored-by: Erwin Douna <e.douna@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Erwin Douna
Copilot Autofix powered by AI
parent
cba75939ed
commit
fbb769fc16
@@ -216,6 +216,7 @@ homeassistant.components.filter.*
|
|||||||
homeassistant.components.firefly_iii.*
|
homeassistant.components.firefly_iii.*
|
||||||
homeassistant.components.fitbit.*
|
homeassistant.components.fitbit.*
|
||||||
homeassistant.components.flexit_bacnet.*
|
homeassistant.components.flexit_bacnet.*
|
||||||
|
homeassistant.components.flow_it.*
|
||||||
homeassistant.components.flux_led.*
|
homeassistant.components.flux_led.*
|
||||||
homeassistant.components.folder_watcher.*
|
homeassistant.components.folder_watcher.*
|
||||||
homeassistant.components.forecast_solar.*
|
homeassistant.components.forecast_solar.*
|
||||||
|
|||||||
Generated
+2
@@ -578,6 +578,8 @@ CLAUDE.md @home-assistant/core
|
|||||||
/tests/components/flipr/ @cnico
|
/tests/components/flipr/ @cnico
|
||||||
/homeassistant/components/flo/ @dmulcahey
|
/homeassistant/components/flo/ @dmulcahey
|
||||||
/tests/components/flo/ @dmulcahey
|
/tests/components/flo/ @dmulcahey
|
||||||
|
/homeassistant/components/flow_it/ @albertogeniola
|
||||||
|
/tests/components/flow_it/ @albertogeniola
|
||||||
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
|
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||||
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
|
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
|
||||||
/homeassistant/components/fluss/ @fluss @Marcello17
|
/homeassistant/components/fluss/ @fluss @Marcello17
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""The Flow-it integration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from flow_it_api.client import FlowItVMCMachine
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.httpx_client import get_async_client
|
||||||
|
|
||||||
|
from .coordinator import FlowItConfigEntry, FlowItCoordinator, FlowItData
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.FAN,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: FlowItConfigEntry) -> bool:
|
||||||
|
"""Set up Flow-it from a config entry."""
|
||||||
|
|
||||||
|
vmc = FlowItVMCMachine(
|
||||||
|
entry.data[CONF_HOST],
|
||||||
|
entry.data[CONF_PASSWORD],
|
||||||
|
entry.data[CONF_USERNAME],
|
||||||
|
session=get_async_client(hass),
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator = FlowItCoordinator(hass, entry, vmc)
|
||||||
|
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
entry.runtime_data = FlowItData(
|
||||||
|
vmc=vmc,
|
||||||
|
coordinator=coordinator,
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: FlowItConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
|
vmc = entry.runtime_data.vmc
|
||||||
|
await vmc.close()
|
||||||
|
|
||||||
|
return unload_ok
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Config flow for Flow-it integration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, override
|
||||||
|
|
||||||
|
from flow_it_api.client import FlowItVMCMachine
|
||||||
|
from flow_it_api.exceptions import FlowItAuthError, FlowItConnectionError
|
||||||
|
import voluptuous as vol
|
||||||
|
from yarl import URL
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.httpx_client import get_async_client
|
||||||
|
from homeassistant.helpers.selector import (
|
||||||
|
TextSelector,
|
||||||
|
TextSelectorConfig,
|
||||||
|
TextSelectorType,
|
||||||
|
)
|
||||||
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
|
from .const import DEFAULT_USERNAME, DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate the user input allows us to connect."""
|
||||||
|
vmc = FlowItVMCMachine(
|
||||||
|
data[CONF_HOST],
|
||||||
|
data[CONF_PASSWORD],
|
||||||
|
data[CONF_USERNAME],
|
||||||
|
session=get_async_client(hass),
|
||||||
|
)
|
||||||
|
info = await vmc.get_info()
|
||||||
|
await vmc.refresh_state()
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert vmc.state is not None
|
||||||
|
return {
|
||||||
|
"title": info.hostname,
|
||||||
|
"mac_address": vmc.state.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FlowItConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for Flow-it."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the config flow."""
|
||||||
|
self._discovery_info: dict[str, Any] = {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial step."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
host = user_input[CONF_HOST]
|
||||||
|
|
||||||
|
if not URL(host).scheme:
|
||||||
|
host = str(URL.build(scheme="http", host=host))
|
||||||
|
user_input[CONF_HOST] = host
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, user_input)
|
||||||
|
except FlowItAuthError:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except FlowItConnectionError:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(info["mac_address"])
|
||||||
|
self._abort_if_unique_id_configured(updates=user_input)
|
||||||
|
return self.async_create_entry(title=info["title"], data=user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_HOST): str,
|
||||||
|
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): TextSelector(
|
||||||
|
TextSelectorConfig(
|
||||||
|
type=TextSelectorType.TEXT, autocomplete="username"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Required(CONF_PASSWORD): TextSelector(
|
||||||
|
TextSelectorConfig(
|
||||||
|
type=TextSelectorType.PASSWORD,
|
||||||
|
autocomplete="current-password",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_zeroconf_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle a flow initiated by zeroconf."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
if user_input is not None:
|
||||||
|
host = self._discovery_info[CONF_HOST]
|
||||||
|
if not URL(host).scheme:
|
||||||
|
host = str(URL.build(scheme="http", host=host))
|
||||||
|
|
||||||
|
data = {
|
||||||
|
CONF_HOST: host,
|
||||||
|
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||||
|
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = await validate_input(self.hass, data)
|
||||||
|
except FlowItAuthError:
|
||||||
|
errors["base"] = "invalid_auth"
|
||||||
|
except FlowItConnectionError:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Unexpected exception")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
else:
|
||||||
|
await self.async_set_unique_id(info["mac_address"])
|
||||||
|
self._abort_if_unique_id_configured(updates=data)
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=info["title"],
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
data_schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): TextSelector(
|
||||||
|
TextSelectorConfig(
|
||||||
|
type=TextSelectorType.TEXT, autocomplete="username"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
vol.Required(CONF_PASSWORD): TextSelector(
|
||||||
|
TextSelectorConfig(
|
||||||
|
type=TextSelectorType.PASSWORD, autocomplete="current-password"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="zeroconf_confirm",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
"name": self._discovery_info.get(
|
||||||
|
"friendly_name",
|
||||||
|
self._discovery_info[CONF_HOST].removesuffix(".local"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle zeroconf discovery."""
|
||||||
|
host = discovery_info.host
|
||||||
|
hostname = discovery_info.hostname.rstrip(".")
|
||||||
|
friendly_name = discovery_info.name.removesuffix("._tbk_vmc._tcp.local.")
|
||||||
|
|
||||||
|
self._discovery_info = {
|
||||||
|
CONF_HOST: hostname,
|
||||||
|
"friendly_name": friendly_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._async_abort_entries_match({CONF_HOST: host})
|
||||||
|
self._async_abort_entries_match({CONF_HOST: hostname})
|
||||||
|
self._async_abort_entries_match({CONF_HOST: f"http://{host}"})
|
||||||
|
self._async_abort_entries_match({CONF_HOST: f"http://{hostname}"})
|
||||||
|
|
||||||
|
self.context.update({"title_placeholders": {"name": friendly_name}})
|
||||||
|
|
||||||
|
return await self.async_step_zeroconf_confirm()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Constants for the Flow-it integration."""
|
||||||
|
|
||||||
|
DOMAIN = "flow_it"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_USERNAME = "api"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Data update coordinator for the Flow-it integration."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, override
|
||||||
|
|
||||||
|
from flow_it_api.client import FlowItVMCMachine
|
||||||
|
from flow_it_api.exceptions import (
|
||||||
|
FlowItAuthError,
|
||||||
|
FlowItConnectionError,
|
||||||
|
FlowItResponseError,
|
||||||
|
)
|
||||||
|
from flow_it_api.models import MachineData, MachineStatusResponse
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
type FlowItConfigEntry = ConfigEntry[FlowItData]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True, frozen=True)
|
||||||
|
class FlowItCoordinatorData:
|
||||||
|
"""Data fetched from the Flow-it VMC."""
|
||||||
|
|
||||||
|
state: MachineStatusResponse
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(kw_only=True, frozen=True)
|
||||||
|
class FlowItData:
|
||||||
|
"""Data for the Flow-it integration."""
|
||||||
|
|
||||||
|
vmc: FlowItVMCMachine
|
||||||
|
coordinator: FlowItCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class FlowItCoordinator(DataUpdateCoordinator[FlowItCoordinatorData]):
|
||||||
|
"""Class to manage fetching Flow-it data."""
|
||||||
|
|
||||||
|
config_entry: FlowItConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: FlowItConfigEntry,
|
||||||
|
vmc: FlowItVMCMachine,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the coordinator."""
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=timedelta(seconds=60),
|
||||||
|
config_entry=config_entry,
|
||||||
|
)
|
||||||
|
self.vmc = vmc
|
||||||
|
|
||||||
|
async def _on_ws_data(data: MachineData) -> None:
|
||||||
|
"""Handle data from WebSocket."""
|
||||||
|
_LOGGER.debug("Received WebSocket update")
|
||||||
|
if self.data:
|
||||||
|
self.data.state.data = data
|
||||||
|
self.async_set_updated_data(self.data)
|
||||||
|
|
||||||
|
self.vmc.register_websocket_callback(_on_ws_data)
|
||||||
|
self.vmc.websocket.start()
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def _async_update_data(self) -> FlowItCoordinatorData:
|
||||||
|
"""Fetch data from API endpoint."""
|
||||||
|
try:
|
||||||
|
await self.vmc.refresh_state()
|
||||||
|
except FlowItAuthError as err:
|
||||||
|
raise ConfigEntryAuthFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="auth_failed",
|
||||||
|
) from err
|
||||||
|
except (FlowItConnectionError, FlowItResponseError) as err:
|
||||||
|
raise UpdateFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="update_failed",
|
||||||
|
) from err
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert self.vmc.state is not None
|
||||||
|
return FlowItCoordinatorData(state=self.vmc.state)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Base entity for Flow-it."""
|
||||||
|
|
||||||
|
from flow_it_api.client import FlowItVMCMachine
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import FlowItCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class FlowItVmcEntity(CoordinatorEntity[FlowItCoordinator]):
|
||||||
|
"""Base entity for Flow-it."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: FlowItCoordinator,
|
||||||
|
vmc: FlowItVMCMachine,
|
||||||
|
entity_description: EntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = entity_description
|
||||||
|
self.vmc = vmc
|
||||||
|
self._attr_unique_id = f"{coordinator.data.state.name}_{entity_description.key}"
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, coordinator.data.state.name)},
|
||||||
|
name=coordinator.data.state.name,
|
||||||
|
manufacturer="FLOW-IT",
|
||||||
|
model="VMC",
|
||||||
|
sw_version=coordinator.data.state.data.alert.version,
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Fan platform for Flow-it."""
|
||||||
|
|
||||||
|
from typing import Any, override
|
||||||
|
|
||||||
|
from flow_it_api.client import FlowItVMCMachine
|
||||||
|
from flow_it_api.const import Speed
|
||||||
|
from flow_it_api.exceptions import FlowItAuthError, FlowItCommandError, FlowItError
|
||||||
|
|
||||||
|
from homeassistant.components.fan import (
|
||||||
|
FanEntity,
|
||||||
|
FanEntityDescription,
|
||||||
|
FanEntityFeature,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.util.percentage import (
|
||||||
|
ordered_list_item_to_percentage,
|
||||||
|
percentage_to_ordered_list_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import FlowItConfigEntry, FlowItCoordinator
|
||||||
|
from .entity import FlowItVmcEntity
|
||||||
|
|
||||||
|
ORDERED_NAMED_FAN_SPEEDS = [
|
||||||
|
Speed.LEVEL_1,
|
||||||
|
Speed.LEVEL_2,
|
||||||
|
Speed.LEVEL_3,
|
||||||
|
Speed.LEVEL_4,
|
||||||
|
Speed.LEVEL_5,
|
||||||
|
]
|
||||||
|
|
||||||
|
PRESET_MODES = [Speed.AUTO, Speed.BOOST]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: FlowItConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Flow-it fan."""
|
||||||
|
data = config_entry.runtime_data
|
||||||
|
async_add_entities([FlowItVmcFan(data.coordinator, data.vmc)])
|
||||||
|
|
||||||
|
|
||||||
|
class FlowItVmcFan(FlowItVmcEntity, FanEntity):
|
||||||
|
"""Flow-it fan entity."""
|
||||||
|
|
||||||
|
_attr_supported_features = (
|
||||||
|
FanEntityFeature.SET_SPEED
|
||||||
|
| FanEntityFeature.PRESET_MODE
|
||||||
|
| FanEntityFeature.TURN_ON
|
||||||
|
| FanEntityFeature.TURN_OFF
|
||||||
|
)
|
||||||
|
|
||||||
|
_attr_preset_modes = list(PRESET_MODES)
|
||||||
|
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
|
||||||
|
|
||||||
|
def __init__(self, coordinator: FlowItCoordinator, vmc: FlowItVMCMachine) -> None:
|
||||||
|
"""Initialize the fan."""
|
||||||
|
super().__init__(
|
||||||
|
coordinator,
|
||||||
|
vmc,
|
||||||
|
FanEntityDescription(
|
||||||
|
key="fan",
|
||||||
|
name=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return true if fan is on."""
|
||||||
|
return self.coordinator.data.state.data.mode.speed != Speed.OFF
|
||||||
|
|
||||||
|
@override
|
||||||
|
@property
|
||||||
|
def percentage(self) -> int | None:
|
||||||
|
"""Return the current speed percentage."""
|
||||||
|
speed = self.coordinator.data.state.data.mode.speed
|
||||||
|
if speed in ORDERED_NAMED_FAN_SPEEDS:
|
||||||
|
return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, speed)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@override
|
||||||
|
@property
|
||||||
|
def preset_mode(self) -> str | None:
|
||||||
|
"""Return the current preset mode."""
|
||||||
|
speed = self.coordinator.data.state.data.mode.speed
|
||||||
|
if speed in PRESET_MODES:
|
||||||
|
return speed
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _async_send_command(
|
||||||
|
self, speed: Speed, flow_in: bool, flow_out: bool
|
||||||
|
) -> None:
|
||||||
|
"""Send a command to the VMC and handle exceptions."""
|
||||||
|
try:
|
||||||
|
await self.vmc.send_command(speed, flow_in=flow_in, flow_out=flow_out)
|
||||||
|
except FlowItAuthError as err:
|
||||||
|
raise ConfigEntryAuthFailed(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="auth_failed",
|
||||||
|
) from err
|
||||||
|
except (FlowItCommandError, FlowItError) as err:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="command_failed",
|
||||||
|
) from err
|
||||||
|
|
||||||
|
await self.coordinator.async_refresh()
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def async_set_percentage(self, percentage: int) -> None:
|
||||||
|
"""Set the speed percentage of the fan."""
|
||||||
|
if percentage == 0:
|
||||||
|
await self.async_turn_off()
|
||||||
|
return
|
||||||
|
|
||||||
|
speed = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
|
||||||
|
mode = self.coordinator.data.state.data.mode
|
||||||
|
await self._async_send_command(
|
||||||
|
speed,
|
||||||
|
flow_in=mode.flowIn, # codespell:ignore flowin
|
||||||
|
flow_out=mode.flowOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
|
"""Set the preset mode of the fan."""
|
||||||
|
mode = self.coordinator.data.state.data.mode
|
||||||
|
await self._async_send_command(
|
||||||
|
Speed(preset_mode),
|
||||||
|
flow_in=mode.flowIn, # codespell:ignore flowin
|
||||||
|
flow_out=mode.flowOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def async_turn_on(
|
||||||
|
self,
|
||||||
|
percentage: int | None = None,
|
||||||
|
preset_mode: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Turn on the fan."""
|
||||||
|
mode = self.coordinator.data.state.data.mode
|
||||||
|
if percentage is not None:
|
||||||
|
await self.async_set_percentage(percentage)
|
||||||
|
elif preset_mode is not None:
|
||||||
|
await self.async_set_preset_mode(preset_mode)
|
||||||
|
else:
|
||||||
|
await self._async_send_command(
|
||||||
|
Speed.LEVEL_1,
|
||||||
|
flow_in=mode.flowIn, # codespell:ignore flowin
|
||||||
|
flow_out=mode.flowOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn off the fan."""
|
||||||
|
mode = self.coordinator.data.state.data.mode
|
||||||
|
await self._async_send_command(
|
||||||
|
Speed.OFF,
|
||||||
|
flow_in=mode.flowIn, # codespell:ignore flowin
|
||||||
|
flow_out=mode.flowOut,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"domain": "flow_it",
|
||||||
|
"name": "Flow-it",
|
||||||
|
"after_dependencies": ["zeroconf"],
|
||||||
|
"codeowners": ["@albertogeniola"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/flow_it",
|
||||||
|
"integration_type": "device",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"quality_scale": "bronze",
|
||||||
|
"requirements": ["flow-it-api==0.0.1.1"],
|
||||||
|
"zeroconf": ["_tbk_vmc._tcp.local."]
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not expose any action.
|
||||||
|
appropriate-polling: done
|
||||||
|
brands:
|
||||||
|
status: done
|
||||||
|
comment: logos and icons have been submitted for a PR on the dedicated repo.
|
||||||
|
common-modules: done
|
||||||
|
config-flow-test-coverage: done
|
||||||
|
config-flow: done
|
||||||
|
dependency-transparency: done
|
||||||
|
docs-actions:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not expose custom actions.
|
||||||
|
docs-conditions:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not expose custom conditions.
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
docs-triggers:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not expose custom triggers.
|
||||||
|
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:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not expose any action.
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters: todo
|
||||||
|
docs-installation-parameters: todo
|
||||||
|
entity-unavailable: todo
|
||||||
|
integration-owner: todo
|
||||||
|
log-when-unavailable: todo
|
||||||
|
parallel-updates: todo
|
||||||
|
reauthentication-flow: todo
|
||||||
|
test-coverage: todo
|
||||||
|
|
||||||
|
# Gold
|
||||||
|
devices: done
|
||||||
|
diagnostics: todo
|
||||||
|
discovery-update-info: todo
|
||||||
|
discovery: done
|
||||||
|
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: done
|
||||||
|
inject-websession: done
|
||||||
|
strict-typing: todo
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"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": {
|
||||||
|
"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 hostname or IP address of the Flow-it device.",
|
||||||
|
"password": "The password is available on the physical device LCD display, by interacting with the menu, clicking on the 'settings' icon and then on the 'wifi icon'.",
|
||||||
|
"username": "The default username is 'api'."
|
||||||
|
},
|
||||||
|
"title": "Configure Flow-it"
|
||||||
|
},
|
||||||
|
"zeroconf_confirm": {
|
||||||
|
"data": {
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"password": "[%key:component::flow_it::config::step::user::data_description::password%]",
|
||||||
|
"username": "[%key:component::flow_it::config::step::user::data_description::username%]"
|
||||||
|
},
|
||||||
|
"description": "Do you want to set up {name}?",
|
||||||
|
"title": "Discovered Flow-it"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"auth_failed": {
|
||||||
|
"message": "Authentication failed when communicating with Flow-it VMC"
|
||||||
|
},
|
||||||
|
"command_failed": {
|
||||||
|
"message": "Failed to send command to Flow-it VMC"
|
||||||
|
},
|
||||||
|
"update_failed": {
|
||||||
|
"message": "Error communicating with API"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1
@@ -242,6 +242,7 @@ FLOWS = {
|
|||||||
"flexit_bacnet",
|
"flexit_bacnet",
|
||||||
"flipr",
|
"flipr",
|
||||||
"flo",
|
"flo",
|
||||||
|
"flow_it",
|
||||||
"flume",
|
"flume",
|
||||||
"fluss",
|
"fluss",
|
||||||
"flux_led",
|
"flux_led",
|
||||||
|
|||||||
@@ -2171,6 +2171,12 @@
|
|||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "cloud_push"
|
"iot_class": "cloud_push"
|
||||||
},
|
},
|
||||||
|
"flow_it": {
|
||||||
|
"name": "Flow-it",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"flume": {
|
"flume": {
|
||||||
"name": "Flume",
|
"name": "Flume",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
|||||||
Generated
+5
@@ -1011,6 +1011,11 @@ ZEROCONF = {
|
|||||||
"domain": "systemnexa2",
|
"domain": "systemnexa2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"_tbk_vmc._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "flow_it",
|
||||||
|
},
|
||||||
|
],
|
||||||
"_technove-stations._tcp.local.": [
|
"_technove-stations._tcp.local.": [
|
||||||
{
|
{
|
||||||
"domain": "technove",
|
"domain": "technove",
|
||||||
|
|||||||
@@ -1917,6 +1917,16 @@ disallow_untyped_defs = true
|
|||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.flow_it.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.flux_led.*]
|
[mypy-homeassistant.components.flux_led.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
|||||||
Generated
+3
@@ -1029,6 +1029,9 @@ flexit_bacnet==2.2.3
|
|||||||
# homeassistant.components.flipr
|
# homeassistant.components.flipr
|
||||||
flipr-api==1.6.1
|
flipr-api==1.6.1
|
||||||
|
|
||||||
|
# homeassistant.components.flow_it
|
||||||
|
flow-it-api==0.0.1.1
|
||||||
|
|
||||||
# homeassistant.components.fluss
|
# homeassistant.components.fluss
|
||||||
fluss-api==0.2.5
|
fluss-api==0.2.5
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Flow-it integration."""
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Common fixtures for the Flow-it tests."""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from flow_it_api.models import MachineStatusResponse
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.flow_it.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry, load_json_value_fixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||||
|
"""Override async_setup_entry."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.flow_it.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_flow_it() -> Generator[AsyncMock]:
|
||||||
|
"""Mock FlowItVMCMachine for integration tests."""
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.flow_it.FlowItVMCMachine",
|
||||||
|
autospec=True,
|
||||||
|
) as mock,
|
||||||
|
patch(
|
||||||
|
"homeassistant.components.flow_it.config_flow.FlowItVMCMachine",
|
||||||
|
new=mock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_vmc = mock.return_value
|
||||||
|
|
||||||
|
# Override methods with faulty signatures due to decorators
|
||||||
|
mock_vmc.refresh_state = AsyncMock()
|
||||||
|
mock_vmc.send_command = AsyncMock()
|
||||||
|
|
||||||
|
mock_vmc.get_info.return_value.hostname = "Flow-it Device"
|
||||||
|
|
||||||
|
json_data = load_json_value_fixture("machine_status.json", DOMAIN)
|
||||||
|
json_data["name"] = "001122334455"
|
||||||
|
mock_vmc.state = MachineStatusResponse(**json_data)
|
||||||
|
|
||||||
|
mock_vmc.register_websocket_callback = MagicMock()
|
||||||
|
mock_vmc.websocket.start = MagicMock()
|
||||||
|
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||||
|
"""Return a mock config entry."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
title="Flow-it Device",
|
||||||
|
unique_id="001122334455",
|
||||||
|
data={
|
||||||
|
"host": "http://1.1.1.1",
|
||||||
|
"username": "api",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"lastUpdate": 123456789,
|
||||||
|
"chrono_id": "test",
|
||||||
|
"status": true,
|
||||||
|
"name": "001122334455",
|
||||||
|
"data": {
|
||||||
|
"event": "update",
|
||||||
|
"sensors": {
|
||||||
|
"Sin": { "pressure": 1.0, "temperature": 293.15, "humidity": 50.0 },
|
||||||
|
"Sout": { "pressure": 1.0, "temperature": 293.15, "humidity": 50.0 },
|
||||||
|
"Iin": { "pressure": 1.0, "temperature": 293.15, "humidity": 50.0 },
|
||||||
|
"Iout": { "pressure": 1.0, "temperature": 293.15, "humidity": 50.0 }
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"speed": "2",
|
||||||
|
"autoSpeed": "1",
|
||||||
|
"flowIn": true,
|
||||||
|
"flowOut": true,
|
||||||
|
"bypassMode": "0",
|
||||||
|
"iaq": 100,
|
||||||
|
"temperatureIn": 293.15,
|
||||||
|
"temperatureOut": 293.15,
|
||||||
|
"humidityIn": 50.0,
|
||||||
|
"humidityOut": 50.0,
|
||||||
|
"pressureIn": 1.0,
|
||||||
|
"pressureOut": 1.0,
|
||||||
|
"bypassOn": false
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"hepa": { "status": 0, "changed": 0 },
|
||||||
|
"g4": { "status": 0, "changed": 0 }
|
||||||
|
},
|
||||||
|
"alert": {
|
||||||
|
"update_reboot": false,
|
||||||
|
"worries": false,
|
||||||
|
"ice": false,
|
||||||
|
"condensation": false,
|
||||||
|
"filterS": 0,
|
||||||
|
"filterI": 0,
|
||||||
|
"warmup": false,
|
||||||
|
"service": false,
|
||||||
|
"fault-code": "0",
|
||||||
|
"net-fault-code": "0",
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Test the Flow-it config flow."""
|
||||||
|
|
||||||
|
from ipaddress import ip_address
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from flow_it_api.exceptions import FlowItAuthError, FlowItConnectionError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.components.flow_it.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||||
|
|
||||||
|
USER_INPUT = {
|
||||||
|
"host": "1.1.1.1",
|
||||||
|
"username": "api",
|
||||||
|
"password": "test-password",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_flow(hass: HomeAssistant, mock_flow_it: AsyncMock) -> None:
|
||||||
|
"""Test we get the form and create an entry."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Flow-it Device"
|
||||||
|
assert result["data"] == {**USER_INPUT, "host": f"http://{USER_INPUT['host']}"}
|
||||||
|
assert result["result"].unique_id == "001122334455"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("exception", "error"),
|
||||||
|
[
|
||||||
|
(FlowItAuthError(), "invalid_auth"),
|
||||||
|
(FlowItConnectionError(), "cannot_connect"),
|
||||||
|
(Exception(), "unknown"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_user_flow_exceptions(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock, exception: Exception, error: str
|
||||||
|
) -> None:
|
||||||
|
"""Test we handle exceptions."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_flow_it.return_value.refresh_state.side_effect = exception
|
||||||
|
mock_flow_it.return_value.get_info.side_effect = exception
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": error}
|
||||||
|
|
||||||
|
mock_flow_it.return_value.refresh_state.side_effect = None
|
||||||
|
mock_flow_it.return_value.get_info.side_effect = None
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Flow-it Device"
|
||||||
|
assert result["data"] == {**USER_INPUT, "host": f"http://{USER_INPUT['host']}"}
|
||||||
|
assert result["result"].unique_id == "001122334455"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zeroconf(hass: HomeAssistant, mock_flow_it: AsyncMock) -> None:
|
||||||
|
"""Test zeroconf discovery."""
|
||||||
|
discovery_info = ZeroconfServiceInfo(
|
||||||
|
ip_address=ip_address("1.1.1.1"),
|
||||||
|
ip_addresses=[ip_address("1.1.1.1")],
|
||||||
|
port=80,
|
||||||
|
hostname="mock_hostname.local.",
|
||||||
|
type="_tbk_vmc._tcp.local.",
|
||||||
|
name="mock_name",
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
data=discovery_info,
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "zeroconf_confirm"
|
||||||
|
assert result["description_placeholders"] == {"name": "mock_name"}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
"username": "api",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Flow-it Device"
|
||||||
|
assert result["data"] == {
|
||||||
|
"host": "http://mock_hostname.local",
|
||||||
|
"username": "api",
|
||||||
|
"password": "test-password",
|
||||||
|
}
|
||||||
|
assert result["result"].unique_id == "001122334455"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("exception", "error"),
|
||||||
|
[
|
||||||
|
(FlowItAuthError(), "invalid_auth"),
|
||||||
|
(FlowItConnectionError(), "cannot_connect"),
|
||||||
|
(Exception(), "unknown"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_zeroconf_exceptions(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock, exception: Exception, error: str
|
||||||
|
) -> None:
|
||||||
|
"""Test zeroconf exceptions."""
|
||||||
|
discovery_info = ZeroconfServiceInfo(
|
||||||
|
ip_address=ip_address("1.1.1.1"),
|
||||||
|
ip_addresses=[ip_address("1.1.1.1")],
|
||||||
|
port=80,
|
||||||
|
hostname="mock_hostname.local.",
|
||||||
|
type="_tbk_vmc._tcp.local.",
|
||||||
|
name="mock_name",
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
data=discovery_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_flow_it.return_value.refresh_state.side_effect = exception
|
||||||
|
mock_flow_it.return_value.get_info.side_effect = exception
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
"username": "api",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": error}
|
||||||
|
|
||||||
|
mock_flow_it.return_value.refresh_state.side_effect = None
|
||||||
|
mock_flow_it.return_value.get_info.side_effect = None
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={
|
||||||
|
"username": "api",
|
||||||
|
"password": "test-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Flow-it Device"
|
||||||
|
assert result["data"] == {
|
||||||
|
"host": "http://mock_hostname.local",
|
||||||
|
"username": "api",
|
||||||
|
"password": "test-password",
|
||||||
|
}
|
||||||
|
assert result["result"].unique_id == "001122334455"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_already_configured(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock, mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Test user already configured."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_zeroconf_already_configured(
|
||||||
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Test zeroconf already configured aborts."""
|
||||||
|
discovery_info = ZeroconfServiceInfo(
|
||||||
|
ip_address=ip_address("1.1.1.1"),
|
||||||
|
ip_addresses=[ip_address("1.1.1.1")],
|
||||||
|
port=80,
|
||||||
|
hostname="mock_hostname.local.",
|
||||||
|
type="_tbk_vmc._tcp.local.",
|
||||||
|
name="mock_name",
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||||
|
data=discovery_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_flow_with_http(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock
|
||||||
|
) -> None:
|
||||||
|
"""Test form with http:// already in host."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{**USER_INPUT, "host": f"http://{USER_INPUT['host']}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Flow-it Device"
|
||||||
|
assert result["data"] == {**USER_INPUT, "host": f"http://{USER_INPUT['host']}"}
|
||||||
|
assert result["result"].unique_id == "001122334455"
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Test Flow-it fan platform."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from flow_it_api.const import Speed
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components.fan import (
|
||||||
|
ATTR_PERCENTAGE,
|
||||||
|
ATTR_PRESET_MODE,
|
||||||
|
DOMAIN as FAN_DOMAIN,
|
||||||
|
SERVICE_SET_PERCENTAGE,
|
||||||
|
SERVICE_SET_PRESET_MODE,
|
||||||
|
SERVICE_TURN_OFF,
|
||||||
|
SERVICE_TURN_ON,
|
||||||
|
)
|
||||||
|
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
ENTITY_ID = "fan.001122334455"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("service", "speed"),
|
||||||
|
[
|
||||||
|
(SERVICE_TURN_ON, Speed.LEVEL_1),
|
||||||
|
(SERVICE_TURN_OFF, Speed.OFF),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_fan_turn_on_off(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_flow_it: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
service: str,
|
||||||
|
speed: Speed,
|
||||||
|
) -> None:
|
||||||
|
"""Test turning on and off the fan."""
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get(ENTITY_ID)
|
||||||
|
assert state
|
||||||
|
assert state.state == STATE_ON
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
FAN_DOMAIN,
|
||||||
|
service,
|
||||||
|
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_flow_it.return_value.send_command.assert_awaited_once_with(
|
||||||
|
speed, flow_in=True, flow_out=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fan_set_percentage(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock, mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Test setting percentage."""
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_id = ENTITY_ID
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
FAN_DOMAIN,
|
||||||
|
SERVICE_SET_PERCENTAGE,
|
||||||
|
{ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 60},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_flow_it.return_value.send_command.assert_awaited_once_with(
|
||||||
|
Speed.LEVEL_3, flow_in=True, flow_out=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fan_set_percentage_zero(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock, mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Test setting percentage to 0 turns off fan."""
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_id = ENTITY_ID
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
FAN_DOMAIN,
|
||||||
|
SERVICE_SET_PERCENTAGE,
|
||||||
|
{ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 0},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_flow_it.return_value.send_command.assert_awaited_once_with(
|
||||||
|
Speed.OFF, flow_in=True, flow_out=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fan_set_preset_mode(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock, mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Test setting preset mode."""
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_id = ENTITY_ID
|
||||||
|
|
||||||
|
await hass.services.async_call(
|
||||||
|
FAN_DOMAIN,
|
||||||
|
SERVICE_SET_PRESET_MODE,
|
||||||
|
{ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "boost"},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
mock_flow_it.return_value.send_command.assert_awaited_once_with(
|
||||||
|
Speed.BOOST, flow_in=True, flow_out=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Test Flow-it integration setup and unload."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from flow_it_api.exceptions import FlowItAuthError, FlowItConnectionError
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_unload_entry(
|
||||||
|
hass: HomeAssistant, mock_flow_it: AsyncMock, mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Test setting up and unloading the integration."""
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||||
|
mock_flow_it.return_value.refresh_state.assert_awaited()
|
||||||
|
mock_flow_it.return_value.register_websocket_callback.assert_called_once()
|
||||||
|
mock_flow_it.return_value.websocket.start.assert_called_once()
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
mock_flow_it.return_value.close.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("exception", "expected_state"),
|
||||||
|
[
|
||||||
|
(FlowItAuthError(), ConfigEntryState.SETUP_ERROR),
|
||||||
|
(FlowItConnectionError(), ConfigEntryState.SETUP_RETRY),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_setup_exceptions(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_flow_it: AsyncMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
exception: Exception,
|
||||||
|
expected_state: ConfigEntryState,
|
||||||
|
) -> None:
|
||||||
|
"""Test setup handles exceptions correctly."""
|
||||||
|
mock_flow_it.return_value.refresh_state.side_effect = exception
|
||||||
|
mock_flow_it.return_value.get_info.side_effect = exception
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert mock_config_entry.state == expected_state
|
||||||
Reference in New Issue
Block a user