-
-
Notifications
You must be signed in to change notification settings - Fork 37.8k
Add flow it integration #175076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Add flow it integration #175076
Changes from all commits
17a9995
6e15d5e
16417d1
0c4598a
740a24a
2065542
da36f97
ba78b85
f4b25a0
a433d40
de2921d
2892899
5bfce60
72000b5
e375dbb
2454b0c
6f4edd9
e41ecd7
1a99a46
6fd559f
574e79c
0bc8e4b
30ef6d4
1776536
3984263
644188f
402e905
60e2361
44c2d85
0d4e085
e8491a8
93628ae
b398373
0f52919
cd31c8a
c220fed
e52a041
5f50f68
f99ff64
2d2f566
ead44f9
b7592d3
915a97f
ecf5ec8
b7923c6
43f291c
8a925c1
8f27909
8d7f26a
66ed2b4
527dc70
6623870
dd53197
e72bf7d
6d45f66
5746921
1e9e081
1fb7b55
644e1bd
f8265b8
86d571f
84776a2
b3a8b71
3bacb5b
1b758b1
a7d4d07
f711269
3f1e1e8
a28658a
99a177c
9a8be24
a5d3412
c0f3a91
3673116
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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: | ||
|
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) | ||
|
albertogeniola marked this conversation as resolved.
|
||
|
|
||
| vmc.register_websocket_callback(on_ws_data) | ||
|
albertogeniola marked this conversation as resolved.
|
||
| vmc.websocket.start() | ||
|
albertogeniola marked this conversation as resolved.
albertogeniola marked this conversation as resolved.
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 | ||
| 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, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How stable is this as a unique ID. Can it change?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
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() | ||
| 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" |
| 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) | ||
| 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", | ||
|
albertogeniola marked this conversation as resolved.
|
||
| sw_version=coordinator.data.state.data.alert.version, | ||
| ) | ||

Uh oh!
There was an error while loading. Please reload this page.