Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
17a9995
Create manual-build-armv7.yml
albertogeniola Aug 25, 2025
6e15d5e
Update manual-build-armv7.yml
albertogeniola Aug 25, 2025
16417d1
Save complete integration with all platforms
albertogeniola Jun 28, 2026
0c4598a
Initial Flow-it integration with Fan platform
albertogeniola Jun 28, 2026
740a24a
Remove accidental stray files and restore CLAUDE.md
albertogeniola Jun 28, 2026
2065542
Remove legacy yaml configuration option.
albertogeniola Jun 28, 2026
da36f97
Update low-level library and use websocket on_data subscriber.
albertogeniola Jun 28, 2026
ba78b85
Update UpdateFailed throw logic.
albertogeniola Jun 28, 2026
f4b25a0
Set config_entry=entry to the DataUpdateCoordinator initialization.
albertogeniola Jun 28, 2026
a433d40
Implement reauth flow.
albertogeniola Jun 28, 2026
de2921d
Avoid catching unhandled exceptions.
albertogeniola Jun 28, 2026
2892899
Move coordinator logic into dedicated module.
albertogeniola Jun 28, 2026
5bfce60
Avoid multiple calls to self.coordinator.async_refresh().
albertogeniola Jun 28, 2026
72000b5
Remove unused strings.
albertogeniola Jun 28, 2026
e375dbb
Let subclass define their unique_id.
albertogeniola Jun 28, 2026
2454b0c
Remove redundant cast to boolean.
albertogeniola Jun 29, 2026
6f4edd9
Add missing type annotations.
albertogeniola Jun 29, 2026
e41ecd7
Use new version of flow-it-api library to solve 401 issue on JWT expi…
albertogeniola Jun 29, 2026
1a99a46
Move FlowItData into coordinator module.
albertogeniola Jun 29, 2026
6fd559f
Avoid single time use variables.
albertogeniola Jun 29, 2026
574e79c
Move auth validation logic to coordinator.
albertogeniola Jun 29, 2026
0bc8e4b
Remove unuseful comment.
albertogeniola Jun 29, 2026
30ef6d4
Remove unuseful comment.
albertogeniola Jun 29, 2026
1776536
Remove VERSION attribute as the default is already okay.
albertogeniola Jun 29, 2026
3984263
Remove single-use variables.
albertogeniola Jun 29, 2026
644188f
Use YARL to build URLs.
albertogeniola Jun 29, 2026
402e905
Implement reusable user input validation utility.
albertogeniola Jun 29, 2026
60e2361
Assume unique_id is always present.
albertogeniola Jun 29, 2026
44c2d85
Improve config_flow UI using TextSelector.
albertogeniola Jun 29, 2026
0d4e085
Implement DataClass for coordinator data.
albertogeniola Jun 29, 2026
e8491a8
Refactor unique_id passing and composition logic.
albertogeniola Jun 29, 2026
93628ae
Improve error handling when sending commands to the device.
albertogeniola Jun 29, 2026
b398373
Resolve linter typing issue by using newer low-level api version.
albertogeniola Jun 29, 2026
0f52919
Move common test logic to conftest.py
albertogeniola Jun 29, 2026
cd31c8a
Add unique_id assertion
albertogeniola Jun 29, 2026
c220fed
Rename tests.
albertogeniola Jun 29, 2026
e52a041
Improve tests.
albertogeniola Jun 29, 2026
5f50f68
Improve tests
albertogeniola Jun 29, 2026
f99ff64
Improve code quality
albertogeniola Jun 29, 2026
2d2f566
Improve tests.
albertogeniola Jun 29, 2026
ead44f9
Remove reauth step logic.
albertogeniola Jul 1, 2026
b7592d3
Refactor duplicate entry setup check.
albertogeniola Jul 1, 2026
915a97f
Avoid pre-population of manual step.
albertogeniola Jul 1, 2026
ecf5ec8
Improve code for linting process.
albertogeniola Jul 1, 2026
b7923c6
Remove unused mock_flow_it_config_flow fixture.
albertogeniola Jul 1, 2026
43f291c
Store .local suffix together with hostname.
albertogeniola Jul 1, 2026
8a925c1
Remove redundant comment.
albertogeniola Jul 2, 2026
8f27909
Handle fan speed edge case.
albertogeniola Jul 2, 2026
8d7f26a
Remove useless casting.
albertogeniola Jul 2, 2026
66ed2b4
Remove useless comment.
albertogeniola Jul 2, 2026
527dc70
Improve FlowItData dataclass
albertogeniola Jul 2, 2026
6623870
Improve dataclass handling.
albertogeniola Jul 2, 2026
dd53197
Move type definition at the top.
albertogeniola Jul 2, 2026
e72bf7d
Restore pre-commit file.
albertogeniola Jul 2, 2026
6d45f66
Parametrize fan turn on-off tests.
albertogeniola Jul 2, 2026
5746921
Improve test code quality.
albertogeniola Jul 2, 2026
1e9e081
Improve test code quality.
albertogeniola Jul 2, 2026
1fb7b55
Improve test code quality.
albertogeniola Jul 2, 2026
644e1bd
Move mock data into dedicated fixture.
albertogeniola Jul 2, 2026
f8265b8
Code quality refactor.
albertogeniola Jul 2, 2026
86d571f
Code quality refactor.
albertogeniola Jul 2, 2026
84776a2
Ignore code-spell false positives.
albertogeniola Jul 3, 2026
b3a8b71
Improve type hints.
albertogeniola Jul 3, 2026
3bacb5b
Remove single use variables.
albertogeniola Jul 4, 2026
1b758b1
Remove single use variables.
albertogeniola Jul 4, 2026
a7d4d07
Remove single use variables.
albertogeniola Jul 4, 2026
f711269
Improve tests.
albertogeniola Jul 4, 2026
3f1e1e8
Address linting issues.
albertogeniola Jul 5, 2026
a28658a
Remove _async_setup override.
albertogeniola Jul 5, 2026
99a177c
Fill quality_scale entirely.
albertogeniola Jul 5, 2026
9a8be24
Remove redundant test assertions.
albertogeniola Jul 5, 2026
a5d3412
Move fixture into conftest.py
albertogeniola Jul 5, 2026
c0f3a91
Fix merge issues
albertogeniola Jul 5, 2026
3673116
Improve error handling.
albertogeniola Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 63 additions & 0 deletions homeassistant/components/flow_it/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""The Flow-it integration."""

import logging

from flow_it_api.client import FlowItVMCMachine
from flow_it_api.models import MachineData

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:
Comment thread
albertogeniola marked this conversation as resolved.
Comment thread
albertogeniola marked this conversation as resolved.
"""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()

async def on_ws_data(data: MachineData) -> None:
"""Handle data from WebSocket."""
_LOGGER.debug("Received WebSocket update")
if coordinator.data:
coordinator.data.state.data = data
coordinator.async_set_updated_data(coordinator.data)
Comment thread
albertogeniola marked this conversation as resolved.

vmc.register_websocket_callback(on_ws_data)
Comment thread
albertogeniola marked this conversation as resolved.
vmc.websocket.start()
Comment thread
albertogeniola marked this conversation as resolved.
Comment thread
albertogeniola marked this conversation as resolved.
Comment thread
albertogeniola marked this conversation as resolved.

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
# Stop websocket and close client
await vmc.close()

return unload_ok
184 changes: 184 additions & 0 deletions homeassistant/components/flow_it/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""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 import config_entries
from homeassistant.config_entries import 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,
"unique_id": vmc.state.name,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How stable is this as a unique ID. Can it change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel your doubt, it also occurred to me. However, it turns out the "name" is indeed an ID and is very stable because:

  1. Cannot be changed by the user (not from the APP, neither from cloud)
  2. Maps to the Device's MAC address
image

On top of that, I'd like to highlight that the device's firmware does not really expose any other "identifier" that can be used to uniquely recognize the device, apart from the "name". I believe that the issue here is the "poor" wording chosen by the firmware maker, which has decided to set the "name" label to an ID key.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks for explaining! :)

}
Comment on lines +36 to +43


class ConfigFlow(config_entries.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]

# Ensure host has protocol
Comment thread
albertogeniola marked this conversation as resolved.
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["unique_id"])
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["unique_id"])
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,
}

# Check if already configured
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()
6 changes: 6 additions & 0 deletions homeassistant/components/flow_it/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants for the Flow-it integration."""

DOMAIN = "flow_it"


DEFAULT_USERNAME = "api"
80 changes: 80 additions & 0 deletions homeassistant/components/flow_it/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Data update coordinator for the Flow-it integration."""

from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import override

from flow_it_api.client import FlowItVMCMachine
from flow_it_api.exceptions import (
FlowItAuthError,
FlowItConnectionError,
FlowItResponseError,
)
from flow_it_api.models import 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: ConfigEntry,
vmc: FlowItVMCMachine,
) -> None:
Comment on lines +49 to +54
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=30),
config_entry=config_entry,
)
self.vmc = vmc

@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",
translation_placeholders={"error": str(err)},
) from err
except (FlowItConnectionError, FlowItResponseError) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
else:
assert self.vmc.state is not None
return FlowItCoordinatorData(state=self.vmc.state)
35 changes: 35 additions & 0 deletions homeassistant/components/flow_it/entity.py
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
albertogeniola marked this conversation as resolved.
sw_version=coordinator.data.state.data.alert.version,
)
Loading
Loading