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:
Alberto Geniola
2026-08-14 14:46:41 +02:00
committed by GitHub
co-authored by Erwin Douna Copilot Autofix powered by AI
parent cba75939ed
commit fbb769fc16
22 changed files with 1224 additions and 0 deletions
+1
View File
@@ -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.*
Generated
+2
View File
@@ -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
@@ -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,
)
+167
View File
@@ -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"
}
}
}
+1
View File
@@ -242,6 +242,7 @@ FLOWS = {
"flexit_bacnet",
"flipr",
"flo",
"flow_it",
"flume",
"fluss",
"flux_led",
@@ -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",
+5
View File
@@ -1011,6 +1011,11 @@ ZEROCONF = {
"domain": "systemnexa2",
},
],
"_tbk_vmc._tcp.local.": [
{
"domain": "flow_it",
},
],
"_technove-stations._tcp.local.": [
{
"domain": "technove",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Tests for the Flow-it integration."""
+69
View File
@@ -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"
+115
View File
@@ -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
)
+54
View File
@@ -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