Add laundrify integration (#65090)
* First version of laundrify integration * Code cleanup * Code cleanup after review #2 * Move coordinator to its own file * Save devices as dict and implement available prop as fn * Validate token on init, abort if already configured * Some more cleanup after review * Add strict type hints * Minor changes after code review * Remove OptionsFlow (use default poll interval instead) * Fix CODEOWNERS to pass hassfest job * Fix formatting to pass prettier job * Fix mypy typing error * Update internal device property after fetching data * Call parental update handler and remove obsolete code * Add coordinator tests and fix some config flow tests * Refactor tests * Refactor fixtures * Device unavailable if polling fails
This commit is contained in:
parent
3f8c896cb2
commit
abf9aab18f
22 changed files with 709 additions and 0 deletions
|
@ -138,6 +138,7 @@ homeassistant.components.kaleidescape.*
|
||||||
homeassistant.components.knx.*
|
homeassistant.components.knx.*
|
||||||
homeassistant.components.kraken.*
|
homeassistant.components.kraken.*
|
||||||
homeassistant.components.lametric.*
|
homeassistant.components.lametric.*
|
||||||
|
homeassistant.components.laundrify.*
|
||||||
homeassistant.components.lcn.*
|
homeassistant.components.lcn.*
|
||||||
homeassistant.components.light.*
|
homeassistant.components.light.*
|
||||||
homeassistant.components.local_ip.*
|
homeassistant.components.local_ip.*
|
||||||
|
|
|
@ -556,6 +556,8 @@ build.json @home-assistant/supervisor
|
||||||
/homeassistant/components/lametric/ @robbiet480 @frenck
|
/homeassistant/components/lametric/ @robbiet480 @frenck
|
||||||
/homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol
|
/homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol
|
||||||
/tests/components/launch_library/ @ludeeus @DurgNomis-drol
|
/tests/components/launch_library/ @ludeeus @DurgNomis-drol
|
||||||
|
/homeassistant/components/laundrify/ @xLarry
|
||||||
|
/tests/components/laundrify/ @xLarry
|
||||||
/homeassistant/components/lcn/ @alengwenus
|
/homeassistant/components/lcn/ @alengwenus
|
||||||
/tests/components/lcn/ @alengwenus
|
/tests/components/lcn/ @alengwenus
|
||||||
/homeassistant/components/lg_netcast/ @Drafteed
|
/homeassistant/components/lg_netcast/ @Drafteed
|
||||||
|
|
52
homeassistant/components/laundrify/__init__.py
Normal file
52
homeassistant/components/laundrify/__init__.py
Normal file
|
@ -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
|
84
homeassistant/components/laundrify/binary_sensor.py
Normal file
84
homeassistant/components/laundrify/binary_sensor.py
Normal file
|
@ -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"
|
||||||
|
|
||||||
|
@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()
|
94
homeassistant/components/laundrify/config_flow.py
Normal file
94
homeassistant/components/laundrify/config_flow.py
Normal file
|
@ -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(
|
||||||
|
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()
|
10
homeassistant/components/laundrify/const.py
Normal file
10
homeassistant/components/laundrify/const.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
"""Constants for the laundrify integration."""
|
||||||
|
|
||||||
|
DOMAIN = "laundrify"
|
||||||
|
|
||||||
|
MANUFACTURER = "laundrify"
|
||||||
|
MODEL = "WLAN-Adapter (SU02)"
|
||||||
|
|
||||||
|
DEFAULT_POLL_INTERVAL = 60
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT = 10
|
46
homeassistant/components/laundrify/coordinator.py
Normal file
46
homeassistant/components/laundrify/coordinator.py
Normal file
|
@ -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()}
|
||||||
|
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
|
9
homeassistant/components/laundrify/manifest.json
Normal file
9
homeassistant/components/laundrify/manifest.json
Normal file
|
@ -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"
|
||||||
|
}
|
13
homeassistant/components/laundrify/model.py
Normal file
13
homeassistant/components/laundrify/model.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
"""Models for laundrify platform."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class LaundrifyDevice(TypedDict):
|
||||||
|
"""laundrify Power Plug."""
|
||||||
|
|
||||||
|
_id: str
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
firmwareVersion: str
|
25
homeassistant/components/laundrify/strings.json
Normal file
25
homeassistant/components/laundrify/strings.json
Normal file
|
@ -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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
homeassistant/components/laundrify/translations/en.json
Normal file
25
homeassistant/components/laundrify/translations/en.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -187,6 +187,7 @@ FLOWS = {
|
||||||
"kraken",
|
"kraken",
|
||||||
"kulersky",
|
"kulersky",
|
||||||
"launch_library",
|
"launch_library",
|
||||||
|
"laundrify",
|
||||||
"life360",
|
"life360",
|
||||||
"lifx",
|
"lifx",
|
||||||
"litejet",
|
"litejet",
|
||||||
|
|
11
mypy.ini
11
mypy.ini
|
@ -1281,6 +1281,17 @@ no_implicit_optional = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
|
|
||||||
|
[mypy-homeassistant.components.laundrify.*]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_subclassing_any = true
|
||||||
|
disallow_untyped_calls = true
|
||||||
|
disallow_untyped_decorators = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unreachable = true
|
||||||
|
|
||||||
[mypy-homeassistant.components.lcn.*]
|
[mypy-homeassistant.components.lcn.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
|
|
@ -926,6 +926,9 @@ krakenex==2.1.0
|
||||||
# homeassistant.components.eufy
|
# homeassistant.components.eufy
|
||||||
lakeside==0.12
|
lakeside==0.12
|
||||||
|
|
||||||
|
# homeassistant.components.laundrify
|
||||||
|
laundrify_aio==1.1.1
|
||||||
|
|
||||||
# homeassistant.components.foscam
|
# homeassistant.components.foscam
|
||||||
libpyfoscam==1.0
|
libpyfoscam==1.0
|
||||||
|
|
||||||
|
|
|
@ -654,6 +654,9 @@ kostal_plenticore==0.2.0
|
||||||
# homeassistant.components.kraken
|
# homeassistant.components.kraken
|
||||||
krakenex==2.1.0
|
krakenex==2.1.0
|
||||||
|
|
||||||
|
# homeassistant.components.laundrify
|
||||||
|
laundrify_aio==1.1.1
|
||||||
|
|
||||||
# homeassistant.components.foscam
|
# homeassistant.components.foscam
|
||||||
libpyfoscam==1.0
|
libpyfoscam==1.0
|
||||||
|
|
||||||
|
|
22
tests/components/laundrify/__init__.py
Normal file
22
tests/components/laundrify/__init__.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
"""Tests for the laundrify integration."""
|
||||||
|
|
||||||
|
from homeassistant.components.laundrify import DOMAIN
|
||||||
|
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
|
def create_entry(
|
||||||
|
hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN
|
||||||
|
) -> MockConfigEntry:
|
||||||
|
"""Create laundrify entry in Home Assistant."""
|
||||||
|
entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
unique_id=VALID_ACCOUNT_ID,
|
||||||
|
data={CONF_ACCESS_TOKEN: access_token},
|
||||||
|
)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
return entry
|
51
tests/components/laundrify/conftest.py
Normal file
51
tests/components/laundrify/conftest.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
"""Configure py.test."""
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID
|
||||||
|
|
||||||
|
from tests.common import load_fixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="laundrify_setup_entry")
|
||||||
|
def laundrify_setup_entry_fixture():
|
||||||
|
"""Mock laundrify setup entry function."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.laundrify.async_setup_entry", return_value=True
|
||||||
|
) as mock_setup_entry:
|
||||||
|
yield mock_setup_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="laundrify_exchange_code")
|
||||||
|
def laundrify_exchange_code_fixture():
|
||||||
|
"""Mock laundrify exchange_auth_code function."""
|
||||||
|
with patch(
|
||||||
|
"laundrify_aio.LaundrifyAPI.exchange_auth_code",
|
||||||
|
return_value=VALID_ACCESS_TOKEN,
|
||||||
|
) as exchange_code_mock:
|
||||||
|
yield exchange_code_mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="laundrify_validate_token")
|
||||||
|
def laundrify_validate_token_fixture():
|
||||||
|
"""Mock laundrify validate_token function."""
|
||||||
|
with patch(
|
||||||
|
"laundrify_aio.LaundrifyAPI.validate_token",
|
||||||
|
return_value=True,
|
||||||
|
) as validate_token_mock:
|
||||||
|
yield validate_token_mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="laundrify_api_mock", autouse=True)
|
||||||
|
def laundrify_api_fixture(laundrify_exchange_code, laundrify_validate_token):
|
||||||
|
"""Mock valid laundrify API responses."""
|
||||||
|
with patch(
|
||||||
|
"laundrify_aio.LaundrifyAPI.get_account_id",
|
||||||
|
return_value=VALID_ACCOUNT_ID,
|
||||||
|
), patch(
|
||||||
|
"laundrify_aio.LaundrifyAPI.get_machines",
|
||||||
|
return_value=json.loads(load_fixture("laundrify/machines.json")),
|
||||||
|
) as get_machines_mock:
|
||||||
|
yield get_machines_mock
|
11
tests/components/laundrify/const.py
Normal file
11
tests/components/laundrify/const.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
"""Constants for the laundrify tests."""
|
||||||
|
|
||||||
|
from homeassistant.const import CONF_CODE
|
||||||
|
|
||||||
|
VALID_AUTH_CODE = "999-001"
|
||||||
|
VALID_ACCESS_TOKEN = "validAccessToken1234"
|
||||||
|
VALID_ACCOUNT_ID = "1234"
|
||||||
|
|
||||||
|
VALID_USER_INPUT = {
|
||||||
|
CONF_CODE: VALID_AUTH_CODE,
|
||||||
|
}
|
8
tests/components/laundrify/fixtures/machines.json
Normal file
8
tests/components/laundrify/fixtures/machines.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"_id": "14",
|
||||||
|
"name": "Demo Waschmaschine",
|
||||||
|
"status": "OFF",
|
||||||
|
"firmwareVersion": "2.1.0"
|
||||||
|
}
|
||||||
|
]
|
129
tests/components/laundrify/test_config_flow.py
Normal file
129
tests/components/laundrify/test_config_flow.py
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
"""Test the laundrify config flow."""
|
||||||
|
|
||||||
|
from laundrify_aio import exceptions
|
||||||
|
|
||||||
|
from homeassistant.components.laundrify.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
|
||||||
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE, CONF_SOURCE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.data_entry_flow import (
|
||||||
|
RESULT_TYPE_ABORT,
|
||||||
|
RESULT_TYPE_CREATE_ENTRY,
|
||||||
|
RESULT_TYPE_FORM,
|
||||||
|
)
|
||||||
|
|
||||||
|
from . import create_entry
|
||||||
|
from .const import VALID_ACCESS_TOKEN, VALID_AUTH_CODE, VALID_USER_INPUT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form(hass: HomeAssistant, laundrify_setup_entry) -> None:
|
||||||
|
"""Test we get the form."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input=VALID_USER_INPUT,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == DOMAIN
|
||||||
|
assert result["data"] == {
|
||||||
|
CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN,
|
||||||
|
}
|
||||||
|
assert len(laundrify_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_invalid_format(
|
||||||
|
hass: HomeAssistant, laundrify_exchange_code
|
||||||
|
) -> None:
|
||||||
|
"""Test we handle invalid format."""
|
||||||
|
laundrify_exchange_code.side_effect = exceptions.InvalidFormat
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
data={CONF_CODE: "invalidFormat"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {CONF_CODE: "invalid_format"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_invalid_auth(hass: HomeAssistant, laundrify_exchange_code) -> None:
|
||||||
|
"""Test we handle invalid auth."""
|
||||||
|
laundrify_exchange_code.side_effect = exceptions.UnknownAuthCode
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
data=VALID_USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {CONF_CODE: "invalid_auth"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_cannot_connect(hass: HomeAssistant, laundrify_exchange_code):
|
||||||
|
"""Test we handle cannot connect error."""
|
||||||
|
laundrify_exchange_code.side_effect = exceptions.ApiConnectionException
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
data=VALID_USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_unkown_exception(hass: HomeAssistant, laundrify_exchange_code):
|
||||||
|
"""Test we handle all other errors."""
|
||||||
|
laundrify_exchange_code.side_effect = Exception
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN,
|
||||||
|
context={CONF_SOURCE: SOURCE_USER},
|
||||||
|
data=VALID_USER_INPUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] == {"base": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_reauth(hass: HomeAssistant) -> None:
|
||||||
|
"""Test the reauth form is shown."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_REAUTH}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
assert result["errors"] is None
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_FORM
|
||||||
|
|
||||||
|
|
||||||
|
async def test_integration_already_exists(hass: HomeAssistant):
|
||||||
|
"""Test we only allow a single config flow."""
|
||||||
|
create_entry(hass)
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={CONF_SOURCE: SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_CODE: VALID_AUTH_CODE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "already_configured"
|
50
tests/components/laundrify/test_coordinator.py
Normal file
50
tests/components/laundrify/test_coordinator.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
"""Test the laundrify coordinator."""
|
||||||
|
|
||||||
|
from laundrify_aio import exceptions
|
||||||
|
|
||||||
|
from homeassistant.components.laundrify.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from . import create_entry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_update_success(hass: HomeAssistant):
|
||||||
|
"""Test the coordinator update is performed successfully."""
|
||||||
|
config_entry = create_entry(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert coordinator.last_update_success
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_update_unauthorized(hass: HomeAssistant, laundrify_api_mock):
|
||||||
|
"""Test the coordinator update fails if an UnauthorizedException is thrown."""
|
||||||
|
config_entry = create_entry(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
|
||||||
|
laundrify_api_mock.side_effect = exceptions.UnauthorizedException
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert not coordinator.last_update_success
|
||||||
|
|
||||||
|
|
||||||
|
async def test_coordinator_update_connection_failed(
|
||||||
|
hass: HomeAssistant, laundrify_api_mock
|
||||||
|
):
|
||||||
|
"""Test the coordinator update fails if an ApiConnectionException is thrown."""
|
||||||
|
config_entry = create_entry(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"]
|
||||||
|
laundrify_api_mock.side_effect = exceptions.ApiConnectionException
|
||||||
|
await coordinator.async_refresh()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert not coordinator.last_update_success
|
59
tests/components/laundrify/test_init.py
Normal file
59
tests/components/laundrify/test_init.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
"""Test the laundrify init file."""
|
||||||
|
|
||||||
|
from laundrify_aio import exceptions
|
||||||
|
|
||||||
|
from homeassistant.components.laundrify.const import DOMAIN
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from . import create_entry
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_entry_api_unauthorized(
|
||||||
|
hass: HomeAssistant, laundrify_validate_token
|
||||||
|
):
|
||||||
|
"""Test that ConfigEntryAuthFailed is thrown when authentication fails."""
|
||||||
|
laundrify_validate_token.side_effect = exceptions.UnauthorizedException
|
||||||
|
config_entry = create_entry(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
assert config_entry.state == ConfigEntryState.SETUP_ERROR
|
||||||
|
assert not hass.data.get(DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_entry_api_cannot_connect(
|
||||||
|
hass: HomeAssistant, laundrify_validate_token
|
||||||
|
):
|
||||||
|
"""Test that ApiConnectionException is thrown when connection fails."""
|
||||||
|
laundrify_validate_token.side_effect = exceptions.ApiConnectionException
|
||||||
|
config_entry = create_entry(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
||||||
|
assert not hass.data.get(DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_entry_successful(hass: HomeAssistant):
|
||||||
|
"""Test entry can be setup successfully."""
|
||||||
|
config_entry = create_entry(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
assert config_entry.state == ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_entry_unload(hass: HomeAssistant):
|
||||||
|
"""Test unloading the laundrify entry."""
|
||||||
|
config_entry = create_entry(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
|
|
||||||
|
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||||
|
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
Loading…
Add table
Add a link
Reference in a new issue