diff --git a/.strict-typing b/.strict-typing index 2a6a23707ea3..627963271238 100644 --- a/.strict-typing +++ b/.strict-typing @@ -216,6 +216,7 @@ homeassistant.components.filter.* homeassistant.components.firefly_iii.* homeassistant.components.fitbit.* homeassistant.components.flexit_bacnet.* +homeassistant.components.flow_it.* homeassistant.components.flux_led.* homeassistant.components.folder_watcher.* homeassistant.components.forecast_solar.* diff --git a/CODEOWNERS b/CODEOWNERS index 0eea937c45a1..63bcae0502c1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -578,6 +578,8 @@ CLAUDE.md @home-assistant/core /tests/components/flipr/ @cnico /homeassistant/components/flo/ @dmulcahey /tests/components/flo/ @dmulcahey +/homeassistant/components/flow_it/ @albertogeniola +/tests/components/flow_it/ @albertogeniola /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor /homeassistant/components/fluss/ @fluss @Marcello17 diff --git a/homeassistant/components/flow_it/__init__.py b/homeassistant/components/flow_it/__init__.py new file mode 100644 index 000000000000..6d29f8bb207c --- /dev/null +++ b/homeassistant/components/flow_it/__init__.py @@ -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 diff --git a/homeassistant/components/flow_it/config_flow.py b/homeassistant/components/flow_it/config_flow.py new file mode 100644 index 000000000000..43a7f49264cc --- /dev/null +++ b/homeassistant/components/flow_it/config_flow.py @@ -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() diff --git a/homeassistant/components/flow_it/const.py b/homeassistant/components/flow_it/const.py new file mode 100644 index 000000000000..5024b1cc815f --- /dev/null +++ b/homeassistant/components/flow_it/const.py @@ -0,0 +1,6 @@ +"""Constants for the Flow-it integration.""" + +DOMAIN = "flow_it" + + +DEFAULT_USERNAME = "api" diff --git a/homeassistant/components/flow_it/coordinator.py b/homeassistant/components/flow_it/coordinator.py new file mode 100644 index 000000000000..4982c64addb3 --- /dev/null +++ b/homeassistant/components/flow_it/coordinator.py @@ -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) diff --git a/homeassistant/components/flow_it/entity.py b/homeassistant/components/flow_it/entity.py new file mode 100644 index 000000000000..0487226c14ac --- /dev/null +++ b/homeassistant/components/flow_it/entity.py @@ -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, + ) diff --git a/homeassistant/components/flow_it/fan.py b/homeassistant/components/flow_it/fan.py new file mode 100644 index 000000000000..dbba86e43893 --- /dev/null +++ b/homeassistant/components/flow_it/fan.py @@ -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, + ) diff --git a/homeassistant/components/flow_it/manifest.json b/homeassistant/components/flow_it/manifest.json new file mode 100644 index 000000000000..83ad91dac355 --- /dev/null +++ b/homeassistant/components/flow_it/manifest.json @@ -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."] +} diff --git a/homeassistant/components/flow_it/quality_scale.yaml b/homeassistant/components/flow_it/quality_scale.yaml new file mode 100644 index 000000000000..e48aa706c772 --- /dev/null +++ b/homeassistant/components/flow_it/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/flow_it/strings.json b/homeassistant/components/flow_it/strings.json new file mode 100644 index 000000000000..864d826bab79 --- /dev/null +++ b/homeassistant/components/flow_it/strings.json @@ -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" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2457db7e94ff..9d191fd34776 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -242,6 +242,7 @@ FLOWS = { "flexit_bacnet", "flipr", "flo", + "flow_it", "flume", "fluss", "flux_led", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f1f93d8a69bc..4f2eda4b5bc2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2171,6 +2171,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "flow_it": { + "name": "Flow-it", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "flume": { "name": "Flume", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 46b601de0e95..dede930fe343 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -1011,6 +1011,11 @@ ZEROCONF = { "domain": "systemnexa2", }, ], + "_tbk_vmc._tcp.local.": [ + { + "domain": "flow_it", + }, + ], "_technove-stations._tcp.local.": [ { "domain": "technove", diff --git a/mypy.ini b/mypy.ini index 0d36aa56582a..5efa32b96073 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1917,6 +1917,16 @@ disallow_untyped_defs = true warn_return_any = 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.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index c3e4de0daac6..7f0191805fc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1029,6 +1029,9 @@ flexit_bacnet==2.2.3 # homeassistant.components.flipr flipr-api==1.6.1 +# homeassistant.components.flow_it +flow-it-api==0.0.1.1 + # homeassistant.components.fluss fluss-api==0.2.5 diff --git a/tests/components/flow_it/__init__.py b/tests/components/flow_it/__init__.py new file mode 100644 index 000000000000..467396e8ac90 --- /dev/null +++ b/tests/components/flow_it/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flow-it integration.""" diff --git a/tests/components/flow_it/conftest.py b/tests/components/flow_it/conftest.py new file mode 100644 index 000000000000..c5d69657ddb9 --- /dev/null +++ b/tests/components/flow_it/conftest.py @@ -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 diff --git a/tests/components/flow_it/fixtures/machine_status.json b/tests/components/flow_it/fixtures/machine_status.json new file mode 100644 index 000000000000..8013856ffa8f --- /dev/null +++ b/tests/components/flow_it/fixtures/machine_status.json @@ -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" + } + } +} diff --git a/tests/components/flow_it/test_config_flow.py b/tests/components/flow_it/test_config_flow.py new file mode 100644 index 000000000000..9e3cacc49250 --- /dev/null +++ b/tests/components/flow_it/test_config_flow.py @@ -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" diff --git a/tests/components/flow_it/test_fan.py b/tests/components/flow_it/test_fan.py new file mode 100644 index 000000000000..ebc771d0e156 --- /dev/null +++ b/tests/components/flow_it/test_fan.py @@ -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 + ) diff --git a/tests/components/flow_it/test_init.py b/tests/components/flow_it/test_init.py new file mode 100644 index 000000000000..9b2473dca4ad --- /dev/null +++ b/tests/components/flow_it/test_init.py @@ -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