-
-
Notifications
You must be signed in to change notification settings - Fork 37.1k
Add laundrify integration #65090
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
Add laundrify integration #65090
Changes from all commits
ad43394
f05473d
64b2f6d
51b7daa
d410b77
e5c59ba
7ed2366
902fb85
6803155
cbd5cde
be025a3
8418e5d
831beaa
6bb6e22
95018ce
8e681da
e54dbf5
92283ac
7444241
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| """The laundrify integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| from laundrify_aio import LaundrifyAPI | ||
| from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_ACCESS_TOKEN, Platform | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import DEFAULT_POLL_INTERVAL, DOMAIN | ||
| from .coordinator import LaundrifyUpdateCoordinator | ||
|
|
||
| PLATFORMS = [Platform.BINARY_SENSOR] | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Set up laundrify from a config entry.""" | ||
|
|
||
| session = async_get_clientsession(hass) | ||
| api_client = LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN], session) | ||
|
|
||
| try: | ||
| await api_client.validate_token() | ||
| except UnauthorizedException as err: | ||
| raise ConfigEntryAuthFailed("Invalid authentication") from err | ||
| except ApiConnectionException as err: | ||
| raise ConfigEntryNotReady("Cannot reach laundrify API") from err | ||
|
|
||
| coordinator = LaundrifyUpdateCoordinator(hass, api_client, DEFAULT_POLL_INTERVAL) | ||
|
|
||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { | ||
| "api": api_client, | ||
| "coordinator": coordinator, | ||
| } | ||
|
|
||
| hass.config_entries.async_setup_platforms(entry, PLATFORMS) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
|
|
||
| if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): | ||
| hass.data[DOMAIN].pop(entry.entry_id) | ||
|
|
||
| return unload_ok | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| """Platform for binary sensor integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
|
|
||
| from homeassistant.components.binary_sensor import ( | ||
| BinarySensorDeviceClass, | ||
| BinarySensorEntity, | ||
| ) | ||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.core import HomeAssistant, callback | ||
| from homeassistant.helpers.entity import DeviceInfo | ||
| from homeassistant.helpers.entity_platform import AddEntitiesCallback | ||
| from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||
|
|
||
| from .const import DOMAIN, MANUFACTURER, MODEL | ||
| from .coordinator import LaundrifyUpdateCoordinator | ||
| from .model import LaundrifyDevice | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback | ||
| ) -> None: | ||
| """Set up sensors from a config entry created in the integrations UI.""" | ||
|
|
||
| coordinator = hass.data[DOMAIN][config.entry_id]["coordinator"] | ||
|
|
||
| async_add_entities( | ||
| LaundrifyPowerPlug(coordinator, device) for device in coordinator.data.values() | ||
| ) | ||
|
|
||
|
|
||
| class LaundrifyPowerPlug( | ||
| CoordinatorEntity[LaundrifyUpdateCoordinator], BinarySensorEntity | ||
| ): | ||
| """Representation of a laundrify Power Plug.""" | ||
|
|
||
| _attr_device_class = BinarySensorDeviceClass.RUNNING | ||
| _attr_icon = "mdi:washing-machine" | ||
|
|
||
| def __init__( | ||
| self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice | ||
| ) -> None: | ||
| """Pass coordinator to CoordinatorEntity.""" | ||
| super().__init__(coordinator) | ||
| self._device = device | ||
| self._attr_unique_id = device["_id"] | ||
|
|
||
| @property | ||
| def device_info(self) -> DeviceInfo: | ||
| """Configure the Device of this Entity.""" | ||
| return DeviceInfo( | ||
| identifiers={(DOMAIN, self._device["_id"])}, | ||
| name=self.name, | ||
| manufacturer=MANUFACTURER, | ||
| model=MODEL, | ||
| sw_version=self._device["firmwareVersion"], | ||
| ) | ||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Check if the device is available.""" | ||
| return ( | ||
| self.unique_id in self.coordinator.data | ||
| and self.coordinator.last_update_success | ||
| ) | ||
|
|
||
| @property | ||
| def name(self) -> str: | ||
| """Name of the entity.""" | ||
| return self._device["name"] | ||
|
|
||
| @property | ||
| def is_on(self) -> bool: | ||
| """Return entity state.""" | ||
| return self._device["status"] == "ON" | ||
raman325 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| @callback | ||
| def _handle_coordinator_update(self) -> None: | ||
| """Handle updated data from the coordinator.""" | ||
| self._device = self.coordinator.data[self.unique_id] | ||
| super()._handle_coordinator_update() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """Config flow for laundrify integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from laundrify_aio import LaundrifyAPI | ||
| from laundrify_aio.exceptions import ( | ||
| ApiConnectionException, | ||
| InvalidFormat, | ||
| UnknownAuthCode, | ||
| ) | ||
| from voluptuous import Required, Schema | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow | ||
| from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE | ||
| from homeassistant.data_entry_flow import FlowResult | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| CONFIG_SCHEMA = Schema({Required(CONF_CODE): str}) | ||
|
|
||
|
|
||
| class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for laundrify.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Handle a flow initialized by the user.""" | ||
| return await self.async_step_init(user_input) | ||
|
|
||
| async def async_step_init( | ||
|
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. We don't need to define this init step. Just merge this with the user step. |
||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Handle the initial step.""" | ||
| if user_input is None: | ||
| return self.async_show_form(step_id="init", data_schema=CONFIG_SCHEMA) | ||
|
|
||
| errors = {} | ||
|
|
||
| try: | ||
| access_token = await LaundrifyAPI.exchange_auth_code(user_input[CONF_CODE]) | ||
|
|
||
| session = async_get_clientsession(self.hass) | ||
| api_client = LaundrifyAPI(access_token, session) | ||
|
|
||
| account_id = await api_client.get_account_id() | ||
| except InvalidFormat: | ||
| errors[CONF_CODE] = "invalid_format" | ||
| except UnknownAuthCode: | ||
| errors[CONF_CODE] = "invalid_auth" | ||
| except ApiConnectionException: | ||
| errors["base"] = "cannot_connect" | ||
| except Exception: # pylint: disable=broad-except | ||
| _LOGGER.exception("Unexpected exception") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| entry_data = {CONF_ACCESS_TOKEN: access_token} | ||
|
|
||
| await self.async_set_unique_id(account_id) | ||
| self._abort_if_unique_id_configured() | ||
|
|
||
| # Create a new entry if it doesn't exist | ||
| return self.async_create_entry( | ||
| title=DOMAIN, | ||
| data=entry_data, | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="init", data_schema=CONFIG_SCHEMA, errors=errors | ||
| ) | ||
|
|
||
| async def async_step_reauth( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Perform reauth upon an API authentication error.""" | ||
| return await self.async_step_reauth_confirm() | ||
|
|
||
| async def async_step_reauth_confirm( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> FlowResult: | ||
| """Dialog that informs the user that reauth is required.""" | ||
| if user_input is None: | ||
| return self.async_show_form( | ||
| step_id="reauth_confirm", | ||
| data_schema=Schema({}), | ||
| ) | ||
| return await self.async_step_init() | ||
xLarry marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| """Constants for the laundrify integration.""" | ||
|
|
||
| DOMAIN = "laundrify" | ||
|
|
||
| MANUFACTURER = "laundrify" | ||
| MODEL = "WLAN-Adapter (SU02)" | ||
xLarry marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| DEFAULT_POLL_INTERVAL = 60 | ||
|
|
||
| REQUEST_TIMEOUT = 10 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| """Custom DataUpdateCoordinator for the laundrify integration.""" | ||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| import async_timeout | ||
| from laundrify_aio import LaundrifyAPI | ||
| from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException | ||
|
|
||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryAuthFailed | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from .const import DOMAIN, REQUEST_TIMEOUT | ||
| from .model import LaundrifyDevice | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class LaundrifyUpdateCoordinator(DataUpdateCoordinator): | ||
| """Class to manage fetching laundrify API data.""" | ||
|
|
||
| def __init__( | ||
| self, hass: HomeAssistant, laundrify_api: LaundrifyAPI, poll_interval: int | ||
| ) -> None: | ||
| """Initialize laundrify coordinator.""" | ||
| super().__init__( | ||
| hass, | ||
| _LOGGER, | ||
| name=DOMAIN, | ||
| update_interval=timedelta(seconds=poll_interval), | ||
| ) | ||
| self.laundrify_api = laundrify_api | ||
|
|
||
| async def _async_update_data(self) -> dict[str, LaundrifyDevice]: | ||
| """Fetch data from laundrify API.""" | ||
| try: | ||
| # Note: asyncio.TimeoutError and aiohttp.ClientError are already | ||
| # handled by the data update coordinator. | ||
| async with async_timeout.timeout(REQUEST_TIMEOUT): | ||
| return {m["_id"]: m for m in await self.laundrify_api.get_machines()} | ||
xLarry marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| except UnauthorizedException as err: | ||
| # Raising ConfigEntryAuthFailed will cancel future updates | ||
| # and start a config flow with SOURCE_REAUTH (async_step_reauth) | ||
| raise ConfigEntryAuthFailed from err | ||
| except ApiConnectionException as err: | ||
| raise UpdateFailed(f"Error communicating with API: {err}") from err | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "domain": "laundrify", | ||
| "name": "laundrify", | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/laundrify", | ||
| "requirements": ["laundrify_aio==1.1.1"], | ||
| "codeowners": ["@xLarry"], | ||
| "iot_class": "cloud_polling" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| """Models for laundrify platform.""" | ||
| from __future__ import annotations | ||
|
|
||
| from typing import TypedDict | ||
|
|
||
|
|
||
| class LaundrifyDevice(TypedDict): | ||
|
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. The library should preferably define its model, eg with a dataclass, so we don't need to define the type here but can use the type from the library. |
||
| """laundrify Power Plug.""" | ||
|
|
||
| _id: str | ||
| name: str | ||
| status: str | ||
| firmwareVersion: str | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| { | ||
| "config": { | ||
| "step": { | ||
| "init": { | ||
| "description": "Please enter your personal Auth Code that is shown in the laundrify-App.", | ||
| "data": { | ||
| "code": "Auth Code (xxx-xxx)" | ||
| } | ||
| }, | ||
| "reauth_confirm": { | ||
| "title": "[%key:common::config_flow::title::reauth%]", | ||
| "description": "The laundrify integration needs to re-authenticate." | ||
| } | ||
| }, | ||
| "error": { | ||
| "invalid_format": "Invalid format. Please specify as xxx-xxx.", | ||
| "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%]" | ||
| }, | ||
| "abort": { | ||
| "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" | ||
|
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. single_instance_allowed abort reason is never used. We're missing a reason for |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| { | ||
| "config": { | ||
| "abort": { | ||
| "single_instance_allowed": "Already configured. Only a single configuration possible." | ||
| }, | ||
| "error": { | ||
| "cannot_connect": "Failed to connect", | ||
| "invalid_auth": "Invalid authentication", | ||
| "invalid_format": "Invalid format. Please specify as xxx-xxx.", | ||
| "unknown": "Unexpected error" | ||
| }, | ||
| "step": { | ||
| "init": { | ||
| "data": { | ||
| "code": "Auth Code (xxx-xxx)" | ||
| }, | ||
| "description": "Please enter your personal AuthCode that is shown in the laundrify-App." | ||
| }, | ||
| "reauth_confirm": { | ||
| "description": "The laundrify integration needs to re-authenticate.", | ||
| "title": "Reauthenticate Integration" | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -183,6 +183,7 @@ | |
| "kraken", | ||
| "kulersky", | ||
| "launch_library", | ||
| "laundrify", | ||
| "life360", | ||
| "lifx", | ||
| "litejet", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.