Add Awair Local API support (#75535)
This commit is contained in:
parent
078a4974e1
commit
ebbff7b60e
15 changed files with 603 additions and 143 deletions
|
@ -2,20 +2,27 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from asyncio import gather
|
from asyncio import gather
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from async_timeout import timeout
|
from async_timeout import timeout
|
||||||
from python_awair import Awair
|
from python_awair import Awair, AwairLocal
|
||||||
from python_awair.exceptions import AuthError
|
from python_awair.devices import AwairBaseDevice, AwairLocalDevice
|
||||||
|
from python_awair.exceptions import AuthError, AwairError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL, AwairResult
|
from .const import (
|
||||||
|
API_TIMEOUT,
|
||||||
|
DOMAIN,
|
||||||
|
LOGGER,
|
||||||
|
UPDATE_INTERVAL_CLOUD,
|
||||||
|
UPDATE_INTERVAL_LOCAL,
|
||||||
|
AwairResult,
|
||||||
|
)
|
||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR]
|
PLATFORMS = [Platform.SENSOR]
|
||||||
|
|
||||||
|
@ -23,7 +30,13 @@ PLATFORMS = [Platform.SENSOR]
|
||||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
"""Set up Awair integration from a config entry."""
|
"""Set up Awair integration from a config entry."""
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
coordinator = AwairDataUpdateCoordinator(hass, config_entry, session)
|
|
||||||
|
coordinator: AwairDataUpdateCoordinator
|
||||||
|
|
||||||
|
if CONF_HOST in config_entry.data:
|
||||||
|
coordinator = AwairLocalDataUpdateCoordinator(hass, config_entry, session)
|
||||||
|
else:
|
||||||
|
coordinator = AwairCloudDataUpdateCoordinator(hass, config_entry, session)
|
||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
@ -50,15 +63,31 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||||
class AwairDataUpdateCoordinator(DataUpdateCoordinator):
|
class AwairDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
"""Define a wrapper class to update Awair data."""
|
"""Define a wrapper class to update Awair data."""
|
||||||
|
|
||||||
def __init__(self, hass, config_entry, session) -> None:
|
def __init__(self, hass, config_entry, update_interval) -> None:
|
||||||
"""Set up the AwairDataUpdateCoordinator class."""
|
"""Set up the AwairDataUpdateCoordinator class."""
|
||||||
access_token = config_entry.data[CONF_ACCESS_TOKEN]
|
|
||||||
self._awair = Awair(access_token=access_token, session=session)
|
|
||||||
self._config_entry = config_entry
|
self._config_entry = config_entry
|
||||||
|
|
||||||
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
|
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval)
|
||||||
|
|
||||||
async def _async_update_data(self) -> Any | None:
|
async def _fetch_air_data(self, device: AwairBaseDevice):
|
||||||
|
"""Fetch latest air quality data."""
|
||||||
|
LOGGER.debug("Fetching data for %s", device.uuid)
|
||||||
|
air_data = await device.air_data_latest()
|
||||||
|
LOGGER.debug(air_data)
|
||||||
|
return AwairResult(device=device, air_data=air_data)
|
||||||
|
|
||||||
|
|
||||||
|
class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator):
|
||||||
|
"""Define a wrapper class to update Awair data from Cloud API."""
|
||||||
|
|
||||||
|
def __init__(self, hass, config_entry, session) -> None:
|
||||||
|
"""Set up the AwairCloudDataUpdateCoordinator class."""
|
||||||
|
access_token = config_entry.data[CONF_ACCESS_TOKEN]
|
||||||
|
self._awair = Awair(access_token=access_token, session=session)
|
||||||
|
|
||||||
|
super().__init__(hass, config_entry, UPDATE_INTERVAL_CLOUD)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, AwairResult] | None:
|
||||||
"""Update data via Awair client library."""
|
"""Update data via Awair client library."""
|
||||||
async with timeout(API_TIMEOUT):
|
async with timeout(API_TIMEOUT):
|
||||||
try:
|
try:
|
||||||
|
@ -74,9 +103,30 @@ class AwairDataUpdateCoordinator(DataUpdateCoordinator):
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
raise UpdateFailed(err) from err
|
raise UpdateFailed(err) from err
|
||||||
|
|
||||||
async def _fetch_air_data(self, device):
|
|
||||||
"""Fetch latest air quality data."""
|
class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator):
|
||||||
LOGGER.debug("Fetching data for %s", device.uuid)
|
"""Define a wrapper class to update Awair data from the local API."""
|
||||||
air_data = await device.air_data_latest()
|
|
||||||
LOGGER.debug(air_data)
|
_device: AwairLocalDevice | None = None
|
||||||
return AwairResult(device=device, air_data=air_data)
|
|
||||||
|
def __init__(self, hass, config_entry, session) -> None:
|
||||||
|
"""Set up the AwairLocalDataUpdateCoordinator class."""
|
||||||
|
self._awair = AwairLocal(
|
||||||
|
session=session, device_addrs=[config_entry.data[CONF_HOST]]
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(hass, config_entry, UPDATE_INTERVAL_LOCAL)
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> dict[str, AwairResult] | None:
|
||||||
|
"""Update data via Awair client library."""
|
||||||
|
async with timeout(API_TIMEOUT):
|
||||||
|
try:
|
||||||
|
if self._device is None:
|
||||||
|
LOGGER.debug("Fetching devices")
|
||||||
|
devices = await self._awair.devices()
|
||||||
|
self._device = devices[0]
|
||||||
|
result = await self._fetch_air_data(self._device)
|
||||||
|
return {result.device.uuid: result}
|
||||||
|
except AwairError as err:
|
||||||
|
LOGGER.error("Unexpected API error: %s", err)
|
||||||
|
raise UpdateFailed(err) from err
|
||||||
|
|
|
@ -4,12 +4,14 @@ from __future__ import annotations
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from python_awair import Awair
|
from aiohttp.client_exceptions import ClientConnectorError
|
||||||
|
from python_awair import Awair, AwairLocal, AwairLocalDevice
|
||||||
from python_awair.exceptions import AuthError, AwairError
|
from python_awair.exceptions import AuthError, AwairError
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import zeroconf
|
||||||
from homeassistant.config_entries import ConfigFlow
|
from homeassistant.config_entries import ConfigFlow
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
||||||
from homeassistant.data_entry_flow import FlowResult
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
@ -21,20 +23,76 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
|
_device: AwairLocalDevice
|
||||||
|
|
||||||
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: zeroconf.ZeroconfServiceInfo
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle zeroconf discovery."""
|
||||||
|
|
||||||
|
host = discovery_info.host
|
||||||
|
LOGGER.debug("Discovered device: %s", host)
|
||||||
|
|
||||||
|
self._device, _ = await self._check_local_connection(host)
|
||||||
|
|
||||||
|
if self._device is not None:
|
||||||
|
await self.async_set_unique_id(self._device.mac_address)
|
||||||
|
self._abort_if_unique_id_configured(error="already_configured_device")
|
||||||
|
self.context.update(
|
||||||
|
{
|
||||||
|
"title_placeholders": {
|
||||||
|
"model": self._device.model,
|
||||||
|
"device_id": self._device.device_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return self.async_abort(reason="unreachable")
|
||||||
|
return await self.async_step_discovery_confirm()
|
||||||
|
|
||||||
|
async def async_step_discovery_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Confirm discovery."""
|
||||||
|
if user_input is not None:
|
||||||
|
title = f"{self._device.model} ({self._device.device_id})"
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=title,
|
||||||
|
data={CONF_HOST: self._device.device_addr},
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_confirm_only()
|
||||||
|
placeholders = {
|
||||||
|
"model": self._device.model,
|
||||||
|
"device_id": self._device.device_id,
|
||||||
|
}
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_confirm",
|
||||||
|
description_placeholders=placeholders,
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, str] | None = None
|
self, user_input: dict[str, str] | None = None
|
||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle a flow initialized by the user."""
|
"""Handle a flow initialized by the user."""
|
||||||
|
|
||||||
|
return self.async_show_menu(step_id="user", menu_options=["local", "cloud"])
|
||||||
|
|
||||||
|
async def async_step_cloud(self, user_input: Mapping[str, Any]) -> FlowResult:
|
||||||
|
"""Handle collecting and verifying Awair Cloud API credentials."""
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
user, error = await self._check_connection(user_input[CONF_ACCESS_TOKEN])
|
user, error = await self._check_cloud_connection(
|
||||||
|
user_input[CONF_ACCESS_TOKEN]
|
||||||
|
)
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
await self.async_set_unique_id(user.email)
|
await self.async_set_unique_id(user.email)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured(error="already_configured_account")
|
||||||
|
|
||||||
title = f"{user.email} ({user.user_id})"
|
title = user.email
|
||||||
return self.async_create_entry(title=title, data=user_input)
|
return self.async_create_entry(title=title, data=user_input)
|
||||||
|
|
||||||
if error != "invalid_access_token":
|
if error != "invalid_access_token":
|
||||||
|
@ -43,8 +101,39 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
errors = {CONF_ACCESS_TOKEN: "invalid_access_token"}
|
errors = {CONF_ACCESS_TOKEN: "invalid_access_token"}
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="cloud",
|
||||||
data_schema=vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}),
|
data_schema=vol.Schema({vol.Optional(CONF_ACCESS_TOKEN): str}),
|
||||||
|
description_placeholders={
|
||||||
|
"url": "https://developer.getawair.com/onboard/login"
|
||||||
|
},
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_local(self, user_input: Mapping[str, Any]) -> FlowResult:
|
||||||
|
"""Handle collecting and verifying Awair Local API hosts."""
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
self._device, error = await self._check_local_connection(
|
||||||
|
user_input[CONF_HOST]
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._device is not None:
|
||||||
|
await self.async_set_unique_id(self._device.mac_address)
|
||||||
|
self._abort_if_unique_id_configured(error="already_configured_device")
|
||||||
|
title = f"{self._device.model} ({self._device.device_id})"
|
||||||
|
return self.async_create_entry(title=title, data=user_input)
|
||||||
|
|
||||||
|
if error is not None:
|
||||||
|
errors = {CONF_HOST: error}
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="local",
|
||||||
|
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||||
|
description_placeholders={
|
||||||
|
"url": "https://support.getawair.com/hc/en-us/articles/360049221014-Awair-Element-Local-API-Feature"
|
||||||
|
},
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -60,7 +149,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
access_token = user_input[CONF_ACCESS_TOKEN]
|
access_token = user_input[CONF_ACCESS_TOKEN]
|
||||||
_, error = await self._check_connection(access_token)
|
_, error = await self._check_cloud_connection(access_token)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
entry = await self.async_set_unique_id(self.unique_id)
|
entry = await self.async_set_unique_id(self.unique_id)
|
||||||
|
@ -79,7 +168,24 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _check_connection(self, access_token: str):
|
async def _check_local_connection(self, device_address: str):
|
||||||
|
"""Check the access token is valid."""
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
awair = AwairLocal(session=session, device_addrs=[device_address])
|
||||||
|
|
||||||
|
try:
|
||||||
|
devices = await awair.devices()
|
||||||
|
return (devices[0], None)
|
||||||
|
|
||||||
|
except ClientConnectorError as err:
|
||||||
|
LOGGER.error("Unable to connect error: %s", err)
|
||||||
|
return (None, "unreachable")
|
||||||
|
|
||||||
|
except AwairError as err:
|
||||||
|
LOGGER.error("Unexpected API error: %s", err)
|
||||||
|
return (None, "unknown")
|
||||||
|
|
||||||
|
async def _check_cloud_connection(self, access_token: str):
|
||||||
"""Check the access token is valid."""
|
"""Check the access token is valid."""
|
||||||
session = async_get_clientsession(self.hass)
|
session = async_get_clientsession(self.hass)
|
||||||
awair = Awair(access_token=access_token, session=session)
|
awair = Awair(access_token=access_token, session=session)
|
||||||
|
|
|
@ -6,7 +6,7 @@ from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from python_awair.air_data import AirData
|
from python_awair.air_data import AirData
|
||||||
from python_awair.devices import AwairDevice
|
from python_awair.devices import AwairBaseDevice
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
|
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
@ -39,7 +39,8 @@ DUST_ALIASES = [API_PM25, API_PM10]
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
UPDATE_INTERVAL = timedelta(minutes=5)
|
UPDATE_INTERVAL_CLOUD = timedelta(minutes=5)
|
||||||
|
UPDATE_INTERVAL_LOCAL = timedelta(seconds=30)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -129,5 +130,5 @@ SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = (
|
||||||
class AwairResult:
|
class AwairResult:
|
||||||
"""Wrapper class to hold an awair device and set of air data."""
|
"""Wrapper class to hold an awair device and set of air data."""
|
||||||
|
|
||||||
device: AwairDevice
|
device: AwairBaseDevice
|
||||||
air_data: AirData
|
air_data: AirData
|
||||||
|
|
|
@ -5,6 +5,12 @@
|
||||||
"requirements": ["python_awair==0.2.3"],
|
"requirements": ["python_awair==0.2.3"],
|
||||||
"codeowners": ["@ahayworth", "@danielsjf"],
|
"codeowners": ["@ahayworth", "@danielsjf"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["python_awair"]
|
"loggers": ["python_awair"],
|
||||||
|
"zeroconf": [
|
||||||
|
{
|
||||||
|
"type": "_http._tcp.local.",
|
||||||
|
"name": "awair*"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from python_awair.air_data import AirData
|
from python_awair.air_data import AirData
|
||||||
from python_awair.devices import AwairDevice
|
from python_awair.devices import AwairBaseDevice
|
||||||
|
|
||||||
from homeassistant.components.sensor import SensorEntity
|
from homeassistant.components.sensor import SensorEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
@ -76,7 +76,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device: AwairDevice,
|
device: AwairBaseDevice,
|
||||||
coordinator: AwairDataUpdateCoordinator,
|
coordinator: AwairDataUpdateCoordinator,
|
||||||
description: AwairSensorEntityDescription,
|
description: AwairSensorEntityDescription,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -1,29 +1,49 @@
|
||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"cloud": {
|
||||||
"description": "You must register for an Awair developer access token at: https://developer.getawair.com/onboard/login",
|
"description": "You must register for an Awair developer access token at: {url}",
|
||||||
"data": {
|
"data": {
|
||||||
"access_token": "[%key:common::config_flow::data::access_token%]",
|
"access_token": "[%key:common::config_flow::data::access_token%]",
|
||||||
"email": "[%key:common::config_flow::data::email%]"
|
"email": "[%key:common::config_flow::data::email%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"local": {
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::ip%]"
|
||||||
|
},
|
||||||
|
"description": "Awair Local API must be enabled following these steps: {url}"
|
||||||
|
},
|
||||||
"reauth_confirm": {
|
"reauth_confirm": {
|
||||||
"description": "Please re-enter your Awair developer access token.",
|
"description": "Please re-enter your Awair developer access token.",
|
||||||
"data": {
|
"data": {
|
||||||
"access_token": "[%key:common::config_flow::data::access_token%]",
|
"access_token": "[%key:common::config_flow::data::access_token%]",
|
||||||
"email": "[%key:common::config_flow::data::email%]"
|
"email": "[%key:common::config_flow::data::email%]"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"description": "Do you want to setup {model} ({device_id})?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"menu_options": {
|
||||||
|
"cloud": "Connect via the cloud",
|
||||||
|
"local": "Connect locally (preferred)"
|
||||||
|
},
|
||||||
|
"description": "Pick local for the best experience. Only use cloud if the device is not connected to the same network as Home Assistant, or if you have a legacy device."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||||
|
"unreachable": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
"already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
|
"already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||||
}
|
"unreachable": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
|
},
|
||||||
|
"flow_title": "{model} ({device_id})"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1297,6 +1297,8 @@ class ConfigFlow(data_entry_flow.FlowHandler):
|
||||||
self,
|
self,
|
||||||
updates: dict[str, Any] | None = None,
|
updates: dict[str, Any] | None = None,
|
||||||
reload_on_update: bool = True,
|
reload_on_update: bool = True,
|
||||||
|
*,
|
||||||
|
error: str = "already_configured",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Abort if the unique ID is already configured."""
|
"""Abort if the unique ID is already configured."""
|
||||||
if self.unique_id is None:
|
if self.unique_id is None:
|
||||||
|
@ -1332,7 +1334,7 @@ class ConfigFlow(data_entry_flow.FlowHandler):
|
||||||
self.hass.async_create_task(
|
self.hass.async_create_task(
|
||||||
self.hass.config_entries.async_reload(entry.entry_id)
|
self.hass.config_entries.async_reload(entry.entry_id)
|
||||||
)
|
)
|
||||||
raise data_entry_flow.AbortFlow("already_configured")
|
raise data_entry_flow.AbortFlow(error)
|
||||||
|
|
||||||
async def async_set_unique_id(
|
async def async_set_unique_id(
|
||||||
self, unique_id: str | None = None, *, raise_on_progress: bool = True
|
self, unique_id: str | None = None, *, raise_on_progress: bool = True
|
||||||
|
|
|
@ -183,6 +183,10 @@ ZEROCONF = {
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"_http._tcp.local.": [
|
"_http._tcp.local.": [
|
||||||
|
{
|
||||||
|
"domain": "awair",
|
||||||
|
"name": "awair*"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "bosch_shc",
|
"domain": "bosch_shc",
|
||||||
"name": "bosch shc*"
|
"name": "bosch shc*"
|
||||||
|
|
73
tests/components/awair/conftest.py
Normal file
73
tests/components/awair/conftest.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
"""Fixtures for testing Awair integration."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.common import load_fixture
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="cloud_devices", scope="session")
|
||||||
|
def cloud_devices_fixture():
|
||||||
|
"""Fixture representing devices returned by Awair Cloud API."""
|
||||||
|
return json.loads(load_fixture("awair/cloud_devices.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="local_devices", scope="session")
|
||||||
|
def local_devices_fixture():
|
||||||
|
"""Fixture representing devices returned by Awair local API."""
|
||||||
|
return json.loads(load_fixture("awair/local_devices.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="gen1_data", scope="session")
|
||||||
|
def gen1_data_fixture():
|
||||||
|
"""Fixture representing data returned from Gen1 Awair device."""
|
||||||
|
return json.loads(load_fixture("awair/awair.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="gen2_data", scope="session")
|
||||||
|
def gen2_data_fixture():
|
||||||
|
"""Fixture representing data returned from Gen2 Awair device."""
|
||||||
|
return json.loads(load_fixture("awair/awair-r2.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="glow_data", scope="session")
|
||||||
|
def glow_data_fixture():
|
||||||
|
"""Fixture representing data returned from Awair glow device."""
|
||||||
|
return json.loads(load_fixture("awair/glow.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="mint_data", scope="session")
|
||||||
|
def mint_data_fixture():
|
||||||
|
"""Fixture representing data returned from Awair mint device."""
|
||||||
|
return json.loads(load_fixture("awair/mint.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="no_devices", scope="session")
|
||||||
|
def no_devicess_fixture():
|
||||||
|
"""Fixture representing when no devices are found in Awair's cloud API."""
|
||||||
|
return json.loads(load_fixture("awair/no_devices.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="awair_offline", scope="session")
|
||||||
|
def awair_offline_fixture():
|
||||||
|
"""Fixture representing when Awair devices are offline."""
|
||||||
|
return json.loads(load_fixture("awair/awair-offline.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="omni_data", scope="session")
|
||||||
|
def omni_data_fixture():
|
||||||
|
"""Fixture representing data returned from Awair omni device."""
|
||||||
|
return json.loads(load_fixture("awair/omni.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="user", scope="session")
|
||||||
|
def user_fixture():
|
||||||
|
"""Fixture representing the User object returned from Awair's Cloud API."""
|
||||||
|
return json.loads(load_fixture("awair/user.json"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="local_data", scope="session")
|
||||||
|
def local_data_fixture():
|
||||||
|
"""Fixture representing data returned from Awair local device."""
|
||||||
|
return json.loads(load_fixture("awair/awair-local.json"))
|
|
@ -1,20 +1,19 @@
|
||||||
"""Constants used in Awair tests."""
|
"""Constants used in Awair tests."""
|
||||||
|
|
||||||
import json
|
from homeassistant.components import zeroconf
|
||||||
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
|
||||||
|
|
||||||
from tests.common import load_fixture
|
|
||||||
|
|
||||||
AWAIR_UUID = "awair_24947"
|
AWAIR_UUID = "awair_24947"
|
||||||
CONFIG = {CONF_ACCESS_TOKEN: "12345"}
|
CLOUD_CONFIG = {CONF_ACCESS_TOKEN: "12345"}
|
||||||
UNIQUE_ID = "foo@bar.com"
|
LOCAL_CONFIG = {CONF_HOST: "192.0.2.5"}
|
||||||
DEVICES_FIXTURE = json.loads(load_fixture("awair/devices.json"))
|
CLOUD_UNIQUE_ID = "foo@bar.com"
|
||||||
GEN1_DATA_FIXTURE = json.loads(load_fixture("awair/awair.json"))
|
LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26"
|
||||||
GEN2_DATA_FIXTURE = json.loads(load_fixture("awair/awair-r2.json"))
|
ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo(
|
||||||
GLOW_DATA_FIXTURE = json.loads(load_fixture("awair/glow.json"))
|
host="192.0.2.5",
|
||||||
MINT_DATA_FIXTURE = json.loads(load_fixture("awair/mint.json"))
|
addresses=["192.0.2.5"],
|
||||||
NO_DEVICES_FIXTURE = json.loads(load_fixture("awair/no_devices.json"))
|
hostname="mock_hostname",
|
||||||
OFFLINE_FIXTURE = json.loads(load_fixture("awair/awair-offline.json"))
|
name="awair12345",
|
||||||
OMNI_DATA_FIXTURE = json.loads(load_fixture("awair/omni.json"))
|
port=None,
|
||||||
USER_FIXTURE = json.loads(load_fixture("awair/user.json"))
|
type="_http._tcp.local.",
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
17
tests/components/awair/fixtures/awair-local.json
Normal file
17
tests/components/awair/fixtures/awair-local.json
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"timestamp": "2022-08-11T05:04:12.108Z",
|
||||||
|
"score": 94,
|
||||||
|
"dew_point": 14.47,
|
||||||
|
"temp": 23.64,
|
||||||
|
"humid": 56.45,
|
||||||
|
"abs_humid": 12.0,
|
||||||
|
"co2": 426,
|
||||||
|
"co2_est": 489,
|
||||||
|
"co2_est_baseline": 37021,
|
||||||
|
"voc": 149,
|
||||||
|
"voc_baseline": 37783,
|
||||||
|
"voc_h2_raw": 26,
|
||||||
|
"voc_ethanol_raw": 37,
|
||||||
|
"pm25": 2,
|
||||||
|
"pm10_est": 3
|
||||||
|
}
|
16
tests/components/awair/fixtures/local_devices.json
Normal file
16
tests/components/awair/fixtures/local_devices.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"device_uuid": "awair-element_24947",
|
||||||
|
"wifi_mac": "00:B0:D0:63:C2:26",
|
||||||
|
"ssid": "Internet of Things",
|
||||||
|
"ip": "192.0.2.5",
|
||||||
|
"netmask": "255.255.255.0",
|
||||||
|
"gateway": "none",
|
||||||
|
"fw_version": "1.2.8",
|
||||||
|
"timezone": "America/Los_Angeles",
|
||||||
|
"display": "score",
|
||||||
|
"led": {
|
||||||
|
"mode": "auto",
|
||||||
|
"brightness": 179
|
||||||
|
},
|
||||||
|
"voc_feature_set": 34
|
||||||
|
}
|
|
@ -1,99 +1,143 @@
|
||||||
"""Define tests for the Awair config flow."""
|
"""Define tests for the Awair config flow."""
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from unittest.mock import patch
|
from aiohttp.client_exceptions import ClientConnectorError
|
||||||
|
|
||||||
from python_awair.exceptions import AuthError, AwairError
|
from python_awair.exceptions import AuthError, AwairError
|
||||||
|
|
||||||
from homeassistant import data_entry_flow
|
from homeassistant import data_entry_flow
|
||||||
from homeassistant.components.awair.const import DOMAIN
|
from homeassistant.components.awair.const import DOMAIN
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER
|
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, SOURCE_ZEROCONF
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import CONFIG, DEVICES_FIXTURE, NO_DEVICES_FIXTURE, UNIQUE_ID, USER_FIXTURE
|
from .const import (
|
||||||
|
CLOUD_CONFIG,
|
||||||
|
CLOUD_UNIQUE_ID,
|
||||||
|
LOCAL_CONFIG,
|
||||||
|
LOCAL_UNIQUE_ID,
|
||||||
|
ZEROCONF_DISCOVERY,
|
||||||
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
async def test_show_form(hass):
|
async def test_show_form(hass: HomeAssistant):
|
||||||
"""Test that the form is served with no input."""
|
"""Test that the form is served with no input."""
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}
|
DOMAIN, context={"source": SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
assert result["type"] == data_entry_flow.FlowResultType.MENU
|
||||||
assert result["step_id"] == SOURCE_USER
|
assert result["step_id"] == SOURCE_USER
|
||||||
|
|
||||||
|
|
||||||
async def test_invalid_access_token(hass):
|
async def test_invalid_access_token(hass: HomeAssistant):
|
||||||
"""Test that errors are shown when the access token is invalid."""
|
"""Test that errors are shown when the access token is invalid."""
|
||||||
|
|
||||||
with patch("python_awair.AwairClient.query", side_effect=AuthError()):
|
with patch("python_awair.AwairClient.query", side_effect=AuthError()):
|
||||||
result = await hass.config_entries.flow.async_init(
|
menu_step = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
form_step = await hass.config_entries.flow.async_configure(
|
||||||
|
menu_step["flow_id"],
|
||||||
|
{"next_step_id": "cloud"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
form_step["flow_id"],
|
||||||
|
CLOUD_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"}
|
assert result["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"}
|
||||||
|
|
||||||
|
|
||||||
async def test_unexpected_api_error(hass):
|
async def test_unexpected_api_error(hass: HomeAssistant):
|
||||||
"""Test that we abort on generic errors."""
|
"""Test that we abort on generic errors."""
|
||||||
|
|
||||||
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
|
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
|
||||||
result = await hass.config_entries.flow.async_init(
|
menu_step = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == "abort"
|
form_step = await hass.config_entries.flow.async_configure(
|
||||||
|
menu_step["flow_id"],
|
||||||
|
{"next_step_id": "cloud"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
form_step["flow_id"],
|
||||||
|
CLOUD_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||||
assert result["reason"] == "unknown"
|
assert result["reason"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
async def test_duplicate_error(hass):
|
async def test_duplicate_error(hass: HomeAssistant, user, cloud_devices):
|
||||||
"""Test that errors are shown when adding a duplicate config."""
|
"""Test that errors are shown when adding a duplicate config."""
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
|
"python_awair.AwairClient.query",
|
||||||
), patch(
|
side_effect=[user, cloud_devices],
|
||||||
"homeassistant.components.awair.sensor.async_setup_entry",
|
|
||||||
return_value=True,
|
|
||||||
):
|
):
|
||||||
MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG).add_to_hass(
|
MockConfigEntry(
|
||||||
hass
|
domain=DOMAIN, unique_id=CLOUD_UNIQUE_ID, data=CLOUD_CONFIG
|
||||||
|
).add_to_hass(hass)
|
||||||
|
|
||||||
|
menu_step = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
form_step = await hass.config_entries.flow.async_configure(
|
||||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
menu_step["flow_id"],
|
||||||
|
{"next_step_id": "cloud"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == "abort"
|
result = await hass.config_entries.flow.async_configure(
|
||||||
assert result["reason"] == "already_configured"
|
form_step["flow_id"],
|
||||||
|
CLOUD_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||||
|
assert result["reason"] == "already_configured_account"
|
||||||
|
|
||||||
|
|
||||||
async def test_no_devices_error(hass):
|
async def test_no_devices_error(hass: HomeAssistant, user, no_devices):
|
||||||
"""Test that errors are shown when the API returns no devices."""
|
"""Test that errors are shown when the API returns no devices."""
|
||||||
|
|
||||||
with patch(
|
with patch("python_awair.AwairClient.query", side_effect=[user, no_devices]):
|
||||||
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, NO_DEVICES_FIXTURE]
|
menu_step = await hass.config_entries.flow.async_init(
|
||||||
):
|
DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG
|
||||||
result = await hass.config_entries.flow.async_init(
|
|
||||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == "abort"
|
form_step = await hass.config_entries.flow.async_configure(
|
||||||
|
menu_step["flow_id"],
|
||||||
|
{"next_step_id": "cloud"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
form_step["flow_id"],
|
||||||
|
CLOUD_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||||
assert result["reason"] == "no_devices_found"
|
assert result["reason"] == "no_devices_found"
|
||||||
|
|
||||||
|
|
||||||
async def test_reauth(hass: HomeAssistant) -> None:
|
async def test_reauth(hass: HomeAssistant, user, cloud_devices) -> None:
|
||||||
"""Test reauth flow."""
|
"""Test reauth flow."""
|
||||||
mock_config = MockConfigEntry(
|
mock_config = MockConfigEntry(
|
||||||
domain=DOMAIN, unique_id=UNIQUE_ID, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"}
|
domain=DOMAIN,
|
||||||
|
unique_id=CLOUD_UNIQUE_ID,
|
||||||
|
data={**CLOUD_CONFIG, CONF_ACCESS_TOKEN: "blah"},
|
||||||
)
|
)
|
||||||
mock_config.add_to_hass(hass)
|
mock_config.add_to_hass(hass)
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID},
|
context={"source": SOURCE_REAUTH, "unique_id": CLOUD_UNIQUE_ID},
|
||||||
data={**CONFIG, CONF_ACCESS_TOKEN: "blah"},
|
data={**CLOUD_CONFIG, CONF_ACCESS_TOKEN: "blah"},
|
||||||
)
|
)
|
||||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||||
assert result["step_id"] == "reauth_confirm"
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
@ -102,7 +146,7 @@ async def test_reauth(hass: HomeAssistant) -> None:
|
||||||
with patch("python_awair.AwairClient.query", side_effect=AuthError()):
|
with patch("python_awair.AwairClient.query", side_effect=AuthError()):
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
user_input=CONFIG,
|
user_input=CLOUD_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||||
|
@ -110,11 +154,12 @@ async def test_reauth(hass: HomeAssistant) -> None:
|
||||||
assert result["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"}
|
assert result["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"}
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
|
"python_awair.AwairClient.query",
|
||||||
|
side_effect=[user, cloud_devices],
|
||||||
), patch("homeassistant.components.awair.async_setup_entry", return_value=True):
|
), patch("homeassistant.components.awair.async_setup_entry", return_value=True):
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
user_input=CONFIG,
|
user_input=CLOUD_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||||
|
@ -124,14 +169,16 @@ async def test_reauth(hass: HomeAssistant) -> None:
|
||||||
async def test_reauth_error(hass: HomeAssistant) -> None:
|
async def test_reauth_error(hass: HomeAssistant) -> None:
|
||||||
"""Test reauth flow."""
|
"""Test reauth flow."""
|
||||||
mock_config = MockConfigEntry(
|
mock_config = MockConfigEntry(
|
||||||
domain=DOMAIN, unique_id=UNIQUE_ID, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"}
|
domain=DOMAIN,
|
||||||
|
unique_id=CLOUD_UNIQUE_ID,
|
||||||
|
data={**CLOUD_CONFIG, CONF_ACCESS_TOKEN: "blah"},
|
||||||
)
|
)
|
||||||
mock_config.add_to_hass(hass)
|
mock_config.add_to_hass(hass)
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID},
|
context={"source": SOURCE_REAUTH, "unique_id": CLOUD_UNIQUE_ID},
|
||||||
data={**CONFIG, CONF_ACCESS_TOKEN: "blah"},
|
data={**CLOUD_CONFIG, CONF_ACCESS_TOKEN: "blah"},
|
||||||
)
|
)
|
||||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||||
assert result["step_id"] == "reauth_confirm"
|
assert result["step_id"] == "reauth_confirm"
|
||||||
|
@ -140,27 +187,127 @@ async def test_reauth_error(hass: HomeAssistant) -> None:
|
||||||
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
|
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
user_input=CONFIG,
|
user_input=CLOUD_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||||
assert result["reason"] == "unknown"
|
assert result["reason"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
async def test_create_entry(hass):
|
async def test_create_cloud_entry(hass: HomeAssistant, user, cloud_devices):
|
||||||
"""Test overall flow."""
|
"""Test overall flow when using cloud api."""
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
|
"python_awair.AwairClient.query",
|
||||||
|
side_effect=[user, cloud_devices],
|
||||||
), patch(
|
), patch(
|
||||||
"homeassistant.components.awair.sensor.async_setup_entry",
|
"homeassistant.components.awair.async_setup_entry",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
):
|
):
|
||||||
result = await hass.config_entries.flow.async_init(
|
menu_step = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
|
DOMAIN, context={"source": SOURCE_USER}, data=CLOUD_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
form_step = await hass.config_entries.flow.async_configure(
|
||||||
|
menu_step["flow_id"],
|
||||||
|
{"next_step_id": "cloud"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
form_step["flow_id"],
|
||||||
|
CLOUD_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||||
assert result["title"] == "foo@bar.com (32406)"
|
assert result["title"] == "foo@bar.com"
|
||||||
assert result["data"][CONF_ACCESS_TOKEN] == CONFIG[CONF_ACCESS_TOKEN]
|
assert result["data"][CONF_ACCESS_TOKEN] == CLOUD_CONFIG[CONF_ACCESS_TOKEN]
|
||||||
assert result["result"].unique_id == UNIQUE_ID
|
assert result["result"].unique_id == CLOUD_UNIQUE_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_local_entry(hass: HomeAssistant, local_devices):
|
||||||
|
"""Test overall flow when using local API."""
|
||||||
|
|
||||||
|
with patch("python_awair.AwairClient.query", side_effect=[local_devices]), patch(
|
||||||
|
"homeassistant.components.awair.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
menu_step = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}, data=LOCAL_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
form_step = await hass.config_entries.flow.async_configure(
|
||||||
|
menu_step["flow_id"],
|
||||||
|
{"next_step_id": "local"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
form_step["flow_id"],
|
||||||
|
LOCAL_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Awair Element (24947)"
|
||||||
|
assert result["data"][CONF_HOST] == LOCAL_CONFIG[CONF_HOST]
|
||||||
|
assert result["result"].unique_id == LOCAL_UNIQUE_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_local_entry_awair_error(hass: HomeAssistant):
|
||||||
|
"""Test overall flow when using local API and device is returns error."""
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"python_awair.AwairClient.query",
|
||||||
|
side_effect=AwairError(),
|
||||||
|
):
|
||||||
|
menu_step = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_USER}, data=LOCAL_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
form_step = await hass.config_entries.flow.async_configure(
|
||||||
|
menu_step["flow_id"],
|
||||||
|
{"next_step_id": "local"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
form_step["flow_id"],
|
||||||
|
LOCAL_CONFIG,
|
||||||
|
)
|
||||||
|
|
||||||
|
# User is returned to form to try again
|
||||||
|
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||||
|
assert result["step_id"] == "local"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_zeroconf_entry(hass: HomeAssistant, local_devices):
|
||||||
|
"""Test overall flow when using discovery."""
|
||||||
|
|
||||||
|
with patch("python_awair.AwairClient.query", side_effect=[local_devices]), patch(
|
||||||
|
"homeassistant.components.awair.async_setup_entry",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
confirm_step = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
confirm_step["flow_id"],
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||||
|
assert result["title"] == "Awair Element (24947)"
|
||||||
|
assert result["data"][CONF_HOST] == ZEROCONF_DISCOVERY.host
|
||||||
|
assert result["result"].unique_id == LOCAL_UNIQUE_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unsuccessful_create_zeroconf_entry(hass: HomeAssistant):
|
||||||
|
"""Test overall flow when using discovery and device is unreachable."""
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"python_awair.AwairClient.query",
|
||||||
|
side_effect=ClientConnectorError(Mock(), OSError()),
|
||||||
|
):
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||||
|
|
|
@ -26,21 +26,16 @@ from homeassistant.const import (
|
||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
TEMP_CELSIUS,
|
TEMP_CELSIUS,
|
||||||
)
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity_component import async_update_entity
|
from homeassistant.helpers.entity_component import async_update_entity
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
AWAIR_UUID,
|
AWAIR_UUID,
|
||||||
CONFIG,
|
CLOUD_CONFIG,
|
||||||
DEVICES_FIXTURE,
|
CLOUD_UNIQUE_ID,
|
||||||
GEN1_DATA_FIXTURE,
|
LOCAL_CONFIG,
|
||||||
GEN2_DATA_FIXTURE,
|
LOCAL_UNIQUE_ID,
|
||||||
GLOW_DATA_FIXTURE,
|
|
||||||
MINT_DATA_FIXTURE,
|
|
||||||
OFFLINE_FIXTURE,
|
|
||||||
OMNI_DATA_FIXTURE,
|
|
||||||
UNIQUE_ID,
|
|
||||||
USER_FIXTURE,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
@ -50,10 +45,10 @@ SENSOR_TYPES_MAP = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def setup_awair(hass, fixtures):
|
async def setup_awair(hass: HomeAssistant, fixtures, unique_id, data):
|
||||||
"""Add Awair devices to hass, using specified fixtures for data."""
|
"""Add Awair devices to hass, using specified fixtures for data."""
|
||||||
|
|
||||||
entry = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG)
|
entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=data)
|
||||||
with patch("python_awair.AwairClient.query", side_effect=fixtures):
|
with patch("python_awair.AwairClient.query", side_effect=fixtures):
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
await hass.config_entries.async_setup(entry.entry_id)
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
@ -61,7 +56,12 @@ async def setup_awair(hass, fixtures):
|
||||||
|
|
||||||
|
|
||||||
def assert_expected_properties(
|
def assert_expected_properties(
|
||||||
hass, registry, name, unique_id, state_value, attributes
|
hass: HomeAssistant,
|
||||||
|
registry: er.RegistryEntry,
|
||||||
|
name,
|
||||||
|
unique_id,
|
||||||
|
state_value,
|
||||||
|
attributes: dict,
|
||||||
):
|
):
|
||||||
"""Assert expected properties from a dict."""
|
"""Assert expected properties from a dict."""
|
||||||
|
|
||||||
|
@ -74,11 +74,11 @@ def assert_expected_properties(
|
||||||
assert state.attributes.get(attr) == value
|
assert state.attributes.get(attr) == value
|
||||||
|
|
||||||
|
|
||||||
async def test_awair_gen1_sensors(hass):
|
async def test_awair_gen1_sensors(hass: HomeAssistant, user, cloud_devices, gen1_data):
|
||||||
"""Test expected sensors on a 1st gen Awair."""
|
"""Test expected sensors on a 1st gen Awair."""
|
||||||
|
|
||||||
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN1_DATA_FIXTURE]
|
fixtures = [user, cloud_devices, gen1_data]
|
||||||
await setup_awair(hass, fixtures)
|
await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
|
|
||||||
assert_expected_properties(
|
assert_expected_properties(
|
||||||
|
@ -166,11 +166,11 @@ async def test_awair_gen1_sensors(hass):
|
||||||
assert hass.states.get("sensor.living_room_illuminance") is None
|
assert hass.states.get("sensor.living_room_illuminance") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_awair_gen2_sensors(hass):
|
async def test_awair_gen2_sensors(hass: HomeAssistant, user, cloud_devices, gen2_data):
|
||||||
"""Test expected sensors on a 2nd gen Awair."""
|
"""Test expected sensors on a 2nd gen Awair."""
|
||||||
|
|
||||||
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN2_DATA_FIXTURE]
|
fixtures = [user, cloud_devices, gen2_data]
|
||||||
await setup_awair(hass, fixtures)
|
await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
|
|
||||||
assert_expected_properties(
|
assert_expected_properties(
|
||||||
|
@ -199,11 +199,28 @@ async def test_awair_gen2_sensors(hass):
|
||||||
assert hass.states.get("sensor.living_room_pm10") is None
|
assert hass.states.get("sensor.living_room_pm10") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_awair_mint_sensors(hass):
|
async def test_local_awair_sensors(hass: HomeAssistant, local_devices, local_data):
|
||||||
|
"""Test expected sensors on a local Awair."""
|
||||||
|
|
||||||
|
fixtures = [local_devices, local_data]
|
||||||
|
await setup_awair(hass, fixtures, LOCAL_UNIQUE_ID, LOCAL_CONFIG)
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
|
||||||
|
assert_expected_properties(
|
||||||
|
hass,
|
||||||
|
registry,
|
||||||
|
"sensor.awair_score",
|
||||||
|
f"{local_devices['device_uuid']}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}",
|
||||||
|
"94",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_awair_mint_sensors(hass: HomeAssistant, user, cloud_devices, mint_data):
|
||||||
"""Test expected sensors on an Awair mint."""
|
"""Test expected sensors on an Awair mint."""
|
||||||
|
|
||||||
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, MINT_DATA_FIXTURE]
|
fixtures = [user, cloud_devices, mint_data]
|
||||||
await setup_awair(hass, fixtures)
|
await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
|
|
||||||
assert_expected_properties(
|
assert_expected_properties(
|
||||||
|
@ -240,11 +257,11 @@ async def test_awair_mint_sensors(hass):
|
||||||
assert hass.states.get("sensor.living_room_carbon_dioxide") is None
|
assert hass.states.get("sensor.living_room_carbon_dioxide") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_awair_glow_sensors(hass):
|
async def test_awair_glow_sensors(hass: HomeAssistant, user, cloud_devices, glow_data):
|
||||||
"""Test expected sensors on an Awair glow."""
|
"""Test expected sensors on an Awair glow."""
|
||||||
|
|
||||||
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GLOW_DATA_FIXTURE]
|
fixtures = [user, cloud_devices, glow_data]
|
||||||
await setup_awair(hass, fixtures)
|
await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
|
|
||||||
assert_expected_properties(
|
assert_expected_properties(
|
||||||
|
@ -260,11 +277,11 @@ async def test_awair_glow_sensors(hass):
|
||||||
assert hass.states.get("sensor.living_room_pm2_5") is None
|
assert hass.states.get("sensor.living_room_pm2_5") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_awair_omni_sensors(hass):
|
async def test_awair_omni_sensors(hass: HomeAssistant, user, cloud_devices, omni_data):
|
||||||
"""Test expected sensors on an Awair omni."""
|
"""Test expected sensors on an Awair omni."""
|
||||||
|
|
||||||
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, OMNI_DATA_FIXTURE]
|
fixtures = [user, cloud_devices, omni_data]
|
||||||
await setup_awair(hass, fixtures)
|
await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
|
|
||||||
assert_expected_properties(
|
assert_expected_properties(
|
||||||
|
@ -295,11 +312,11 @@ async def test_awair_omni_sensors(hass):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_awair_offline(hass):
|
async def test_awair_offline(hass: HomeAssistant, user, cloud_devices, awair_offline):
|
||||||
"""Test expected behavior when an Awair is offline."""
|
"""Test expected behavior when an Awair is offline."""
|
||||||
|
|
||||||
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, OFFLINE_FIXTURE]
|
fixtures = [user, cloud_devices, awair_offline]
|
||||||
await setup_awair(hass, fixtures)
|
await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG)
|
||||||
|
|
||||||
# The expected behavior is that we won't have any sensors
|
# The expected behavior is that we won't have any sensors
|
||||||
# if the device is not online when we set it up. python_awair
|
# if the device is not online when we set it up. python_awair
|
||||||
|
@ -313,11 +330,13 @@ async def test_awair_offline(hass):
|
||||||
assert hass.states.get("sensor.living_room_awair_score") is None
|
assert hass.states.get("sensor.living_room_awair_score") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_awair_unavailable(hass):
|
async def test_awair_unavailable(
|
||||||
|
hass: HomeAssistant, user, cloud_devices, gen1_data, awair_offline
|
||||||
|
):
|
||||||
"""Test expected behavior when an Awair becomes offline later."""
|
"""Test expected behavior when an Awair becomes offline later."""
|
||||||
|
|
||||||
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN1_DATA_FIXTURE]
|
fixtures = [user, cloud_devices, gen1_data]
|
||||||
await setup_awair(hass, fixtures)
|
await setup_awair(hass, fixtures, CLOUD_UNIQUE_ID, CLOUD_CONFIG)
|
||||||
registry = er.async_get(hass)
|
registry = er.async_get(hass)
|
||||||
|
|
||||||
assert_expected_properties(
|
assert_expected_properties(
|
||||||
|
@ -329,7 +348,7 @@ async def test_awair_unavailable(hass):
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("python_awair.AwairClient.query", side_effect=OFFLINE_FIXTURE):
|
with patch("python_awair.AwairClient.query", side_effect=awair_offline):
|
||||||
await async_update_entity(hass, "sensor.living_room_awair_score")
|
await async_update_entity(hass, "sensor.living_room_awair_score")
|
||||||
assert_expected_properties(
|
assert_expected_properties(
|
||||||
hass,
|
hass,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue