Refactor / update Awair integration (#34394)

* Refactor / update Awair integration

This commit does a few things, all in service of making the Awair
integration more modern and reliable. Specifically we do the following:

- Update to python_awair 0.1.1
- Begin using config entries / flow for setting up the integration.
  - YAML support is completely removed.
  - The integration now allows adding multiple Awair accounts, should a
    user wish to do so (I found it _very_ useful in development).
- Group various Awair sensors into devices, using the device registry.
- Renames various sensors and treats the "dust" sensor as a particulate sensor.
- Device update rate-limits are no longer dynamically calculated; the
  Awair API now separates rate-limits on a per-device basis.
- Supports sound pressure and illuminance sensors found on some Awair devices.
- We report the "awair index" for certain sensors as part of device_state_attributes.
  The "index" is a subjective measure of whether or not a sensor reading
  is "good" or "bad" (and to what extent). It's a component of the Awair
  score, and it is useful on its own as an input for those who wish to
  do things like "display this color if the value is 'bad'".

This is a breaking change, and requires updates to documentation and a
warning in the README. The breaking changes in detail, are:

- Support for all YAML configuration is removed, and users will need to
  re-add the integration via the UI.
- We no longer support overriding device discovery via manual
  configuration of device UUIDs. This was previously supported because
  the Awair API had severe limits on the device list endpoints; however
  those have since been removed.
- Gen 1 devices no longer show a "dust" sensor; rather we create a PM2.5
  sensor and a PM10 sensor and just keep the values in sync. This better
  reflects the sensor capabilities: it can detect particles in a range
  from 2.5 -> 10, but cannot differentiate between sizes.
- Sensors are renamed as follows:
  - "sensor.devicename_co2"   -> "sensor.devicename_carbon_dioxide"
  - "sensor.devicename_voc"   -> "sensor.devicename_volatile_organic_compounds"
  - "sensor.devicename_score" -> "sensor.devicename_air_quality_index"
  - I've chosen to call the "Awair Score" an "air quality index" sensor,
    because fundamentally the "Awair Score" and other air quality indices
    (such as CAQI) do the same thing: they calculate a value based on a
    variety of other inputs.

Under the hood, the integration has seen some improvements:

- We use the DataUpdateCoordinator class to handle updates, rather than
  rolling our own update class.
- The code no longer tracks availability based on a timestamp returned
  from the Awair service; we assert that if we have received a response
  and the response has data for our device, then we are available (and
  otherwise, not available). We don't need to test the actual Awair API
  so heavily.
- Test coverage has been expanded to handle a variety of products that
  Awair produces, not just the one I happen to own.
- Test coverage no longer concerns itself with testing behavior that is
  now handled by the DataUpdateCoordinator; nor is it concerned with
  ensuring that the overall component sets up and registers properly.
  These are assumed to be well-tested functionaity of the core and not
  things we need to re-test ourselves.

Finally - between library updates and integration updates, this
integration is well-positioned to support future updates. I have a
proof-of-concept patch for device automations, and the underlying
library now supports subclassing authentication (which clears the way
for us to use OAuth authentication for Awair).

* Wrap test fixture in mock_coro

Truthfully I'm not sure why this was passing on my local dev
environment, but I was developing with python 3.8 before. After
installing python 3.7, I was able to reproduce the CI failures and fix
them.

* Fix broken tests after #34901 and/or #34989

* Do not rename sensors so broadly

We're going to keep the sensors named as they were before, pending the
outcome of any decisions around the air_quality component and what names
should be standardized for air-quality-like devices.

If standardized names are selected (which does seem likely), then we
will update this integration to match them - at which point, it would be
a breaking change.

But for now, we'll keep names mostly identical to what users had before.

Notable in this commit is that we generate the entity_id ourselves,
rather than just allowing it to be auto-generated from the name
attribute. This allows us to provide more human friendly names, while
keeping the old format for entity ids. For example, given an Awair
device called "Living Room", we'll generate an entity id of
"sensor.living_room_voc" but show set the name of the device to "Living
Room Volatile organic compounds".

* Support import from config.yaml

We'll create a config entry from config.yaml the first time we're
loaded, and then defer to it from then on.

We ignore all keys other than the access_token, since we no longer need
to deal with per-account rate-limits (rather, everything is per-device
now).

* Add myself to CODEOWNERS

Since I wrote the initial integration, and now this re-write, it feels
appropriate for me to take care of the integration along with `danielsjf`.

* Remove name mangling

* Update homeassistant/components/awair/manifest.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/awair/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/awair/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/awair/sensor.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Address some review feedback

* Set up reauth flow in a job, rather than awaiting

* Remove unnecessary title string

* Remove unnecessary config schema checking

As pointed out in review, because this comes in via import from
`configuration.yaml`, we can rely on the `PLATFORM_SCHEMA` validation instead.

* Fix tests

* Set unique_id appropriately for legacy devices

For users who have had this integration already installed (and who have
updated their home assistant installation sometime in recent history),
we want to ensure that unique_id's are set to the same thing as before,
to facilitate the upgrade process.

To do that, we add an additional property to the `SENSOR_TYPES` dict
(`ATTR_UNIQUE_ID`) which allows us to map modern sensor names from
python_awair to what older versions called them - ie: `humidity` ->
`HUMID`. We then use that value when constructing the unique ID. This
should allow users to upgrade and not lose configuration even if entity
IDs would otherwise change (because we have changed the name format that
generates entity IDs).

One note is that for the gen1 `DUST` sensor, we have to treat it
differently. This integration used to call that a "PM2.5" sensor, but
the unique_id generated would be something like `awair_12345_DUST`. So
we special-case that sensor, and do the same thing. We do not need to
special-case the PM10 sensor for gen1 devices, because we didn't create
a PM10 sensor in the past (we do now, because the "DUST" sensor is
really a hybrid PM2.5/PM10 sensor).

* Patch async_setup_entry for two tests

* Update awair config_flow to require / use an email address for unique_id

Also, only start one re-auth flow.

* Add a few more tests, try to get coverage up.

* Add another test

* Move attribution to device_state_attributes

* Don't require email

* Switch from Union[dict, None] to Optional[dict]

* Use a mock where requested

* Fix missing constant rename

* Use async_create_task

* Bump test coverage a bit for config_flow

* s/CONF_UNIQUE_ID/UNIQUE_ID/g

* Add warning about deprecated platform config

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Andrew Hayworth 2020-06-21 14:46:07 -05:00 committed by GitHub
parent 1de97e3a35
commit fed6625324
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1094 additions and 602 deletions

View file

@ -46,7 +46,7 @@ homeassistant/components/auth/* @home-assistant/core
homeassistant/components/automation/* @home-assistant/core homeassistant/components/automation/* @home-assistant/core
homeassistant/components/avea/* @pattyland homeassistant/components/avea/* @pattyland
homeassistant/components/avri/* @timvancann homeassistant/components/avri/* @timvancann
homeassistant/components/awair/* @danielsjf homeassistant/components/awair/* @ahayworth @danielsjf
homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/aws/* @awarecan @robbiet480
homeassistant/components/axis/* @Kane610 homeassistant/components/axis/* @Kane610
homeassistant/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/azure_event_hub/* @eavanvalkenburg

View file

@ -1 +1,112 @@
"""The awair component.""" """The awair component."""
from asyncio import gather
from typing import Any, Optional
from async_timeout import timeout
from python_awair import Awair
from python_awair.exceptions import AuthError
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import Config, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL, AwairResult
PLATFORMS = ["sensor"]
async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up Awair integration."""
return True
async def async_setup_entry(hass, config_entry) -> bool:
"""Set up Awair integration from a config entry."""
session = async_get_clientsession(hass)
coordinator = AwairDataUpdateCoordinator(hass, config_entry, session)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config_entry.entry_id] = coordinator
for platform in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, platform)
)
return True
async def async_unload_entry(hass, config_entry) -> bool:
"""Unload Awair configuration."""
tasks = []
for platform in PLATFORMS:
tasks.append(
hass.config_entries.async_forward_entry_unload(config_entry, platform)
)
unload_ok = all(await gather(*tasks))
if unload_ok:
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
class AwairDataUpdateCoordinator(DataUpdateCoordinator):
"""Define a wrapper class to update Awair data."""
def __init__(self, hass, config_entry, session) -> None:
"""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
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
async def _async_update_data(self) -> Optional[Any]:
"""Update data via Awair client library."""
with timeout(API_TIMEOUT):
try:
LOGGER.debug("Fetching users and devices")
user = await self._awair.user()
devices = await user.devices()
results = await gather(
*[self._fetch_air_data(device) for device in devices]
)
return {result.device.uuid: result for result in results}
except AuthError as err:
flow_context = {
"source": "reauth",
"unique_id": self._config_entry.unique_id,
}
matching_flows = [
flow
for flow in self.hass.config_entries.flow.async_progress()
if flow["context"] == flow_context
]
if not matching_flows:
self.hass.async_create_task(
self.hass.config_entries.flow.async_init(
DOMAIN, context=flow_context, data=self._config_entry.data,
)
)
raise UpdateFailed(err)
except Exception as err:
raise UpdateFailed(err)
async def _fetch_air_data(self, device):
"""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)

View file

@ -0,0 +1,109 @@
"""Config flow for Awair."""
from typing import Optional
from python_awair import Awair
from python_awair.exceptions import AuthError, AwairError
import voluptuous as vol
from homeassistant.config_entries import CONN_CLASS_CLOUD_POLL, ConfigFlow
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER # pylint: disable=unused-import
class AwairFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Awair."""
VERSION = 1
CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL
async def async_step_import(self, conf: dict):
"""Import a configuration from config.yaml."""
if self.hass.config_entries.async_entries(DOMAIN):
return self.async_abort(reason="already_setup")
user, error = await self._check_connection(conf[CONF_ACCESS_TOKEN])
if error is not None:
return self.async_abort(reason=error)
await self.async_set_unique_id(user.email)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{user.email} ({user.user_id})",
data={CONF_ACCESS_TOKEN: conf[CONF_ACCESS_TOKEN]},
)
async def async_step_user(self, user_input: Optional[dict] = None):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
user, error = await self._check_connection(user_input[CONF_ACCESS_TOKEN])
if user is not None:
await self.async_set_unique_id(user.email)
self._abort_if_unique_id_configured()
title = f"{user.email} ({user.user_id})"
return self.async_create_entry(title=title, data=user_input)
if error != "auth":
return self.async_abort(reason=error)
errors = {CONF_ACCESS_TOKEN: "auth"}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}),
errors=errors,
)
async def async_step_reauth(self, user_input: Optional[dict] = None):
"""Handle re-auth if token invalid."""
errors = {}
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN]
_, error = await self._check_connection(access_token)
if error is None:
for entry in self._async_current_entries():
if entry.unique_id == self.unique_id:
self.hass.config_entries.async_update_entry(
entry, data=user_input
)
return self.async_abort(reason="reauth_successful")
if error != "auth":
return self.async_abort(reason=error)
errors = {CONF_ACCESS_TOKEN: error}
return self.async_show_form(
step_id="reauth",
data_schema=vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}),
errors=errors,
)
async def _check_connection(self, access_token: str):
"""Check the access token is valid."""
session = async_get_clientsession(self.hass)
awair = Awair(access_token=access_token, session=session)
try:
user = await awair.user()
devices = await user.devices()
if not devices:
return (None, "no_devices")
return (user, None)
except AuthError:
return (None, "auth")
except AwairError as err:
LOGGER.error("Unexpected API error: %s", err)
return (None, "unknown")

View file

@ -0,0 +1,120 @@
"""Constants for the Awair component."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from python_awair.devices import AwairDevice
from homeassistant.const import (
ATTR_DEVICE_CLASS,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
UNIT_PERCENTAGE,
)
API_CO2 = "carbon_dioxide"
API_DUST = "dust"
API_HUMID = "humidity"
API_LUX = "illuminance"
API_PM10 = "particulate_matter_10"
API_PM25 = "particulate_matter_2_5"
API_SCORE = "score"
API_SPL_A = "sound_pressure_level"
API_TEMP = "temperature"
API_TIMEOUT = 20
API_VOC = "volatile_organic_compounds"
ATTRIBUTION = "Awair air quality sensor"
ATTR_ICON = "icon"
ATTR_LABEL = "label"
ATTR_UNIT = "unit"
ATTR_UNIQUE_ID = "unique_id"
DOMAIN = "awair"
DUST_ALIASES = [API_PM25, API_PM10]
LOGGER = logging.getLogger(__package__)
UPDATE_INTERVAL = timedelta(minutes=5)
SENSOR_TYPES = {
API_SCORE: {
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:blur",
ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_LABEL: "Awair score",
ATTR_UNIQUE_ID: "score", # matches legacy format
},
API_HUMID: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
ATTR_ICON: None,
ATTR_UNIT: UNIT_PERCENTAGE,
ATTR_LABEL: "Humidity",
ATTR_UNIQUE_ID: "HUMID", # matches legacy format
},
API_LUX: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE,
ATTR_ICON: None,
ATTR_UNIT: "lx",
ATTR_LABEL: "Illuminance",
ATTR_UNIQUE_ID: "illuminance",
},
API_SPL_A: {
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:ear-hearing",
ATTR_UNIT: "dBa",
ATTR_LABEL: "Sound level",
ATTR_UNIQUE_ID: "sound_level",
},
API_VOC: {
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:cloud",
ATTR_UNIT: CONCENTRATION_PARTS_PER_BILLION,
ATTR_LABEL: "Volatile organic compounds",
ATTR_UNIQUE_ID: "VOC", # matches legacy format
},
API_TEMP: {
ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE,
ATTR_ICON: None,
ATTR_UNIT: TEMP_CELSIUS,
ATTR_LABEL: "Temperature",
ATTR_UNIQUE_ID: "TEMP", # matches legacy format
},
API_PM25: {
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:blur",
ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
ATTR_LABEL: "PM2.5",
ATTR_UNIQUE_ID: "PM25", # matches legacy format
},
API_PM10: {
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:blur",
ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
ATTR_LABEL: "PM10",
ATTR_UNIQUE_ID: "PM10", # matches legacy format
},
API_CO2: {
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:cloud",
ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION,
ATTR_LABEL: "Carbon dioxide",
ATTR_UNIQUE_ID: "CO2", # matches legacy format
},
}
@dataclass
class AwairResult:
"""Wrapper class to hold an awair device and set of air data."""
device: AwairDevice
air_data: dict

View file

@ -2,6 +2,7 @@
"domain": "awair", "domain": "awair",
"name": "Awair", "name": "Awair",
"documentation": "https://www.home-assistant.io/integrations/awair", "documentation": "https://www.home-assistant.io/integrations/awair",
"requirements": ["python_awair==0.0.4"], "requirements": ["python_awair==0.1.1"],
"codeowners": ["@danielsjf"] "codeowners": ["@ahayworth", "@danielsjf"],
"config_flow": true
} }

View file

@ -1,248 +1,245 @@
"""Support for the Awair indoor air quality monitor.""" """Support for Awair sensors."""
from datetime import timedelta from typing import Callable, List, Optional
import logging
import math
from python_awair import AwairClient from python_awair.devices import AwairDevice
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( from homeassistant.components.awair import AwairDataUpdateCoordinator, AwairResult
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, from homeassistant.components.sensor import PLATFORM_SCHEMA
CONCENTRATION_PARTS_PER_BILLION, from homeassistant.config_entries import SOURCE_IMPORT
CONCENTRATION_PARTS_PER_MILLION, from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_ACCESS_TOKEN
CONF_ACCESS_TOKEN, from homeassistant.helpers import device_registry as dr
CONF_DEVICES,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle, dt from homeassistant.helpers.typing import ConfigType, HomeAssistantType
_LOGGER = logging.getLogger(__name__) from .const import (
API_DUST,
API_PM25,
API_SCORE,
API_TEMP,
API_VOC,
ATTR_ICON,
ATTR_LABEL,
ATTR_UNIQUE_ID,
ATTR_UNIT,
ATTRIBUTION,
DOMAIN,
DUST_ALIASES,
LOGGER,
SENSOR_TYPES,
)
ATTR_SCORE = "score" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
ATTR_TIMESTAMP = "timestamp" {vol.Required(CONF_ACCESS_TOKEN): cv.string}, extra=vol.ALLOW_EXTRA,
ATTR_LAST_API_UPDATE = "last_api_update"
ATTR_COMPONENT = "component"
ATTR_VALUE = "value"
ATTR_SENSORS = "sensors"
CONF_UUID = "uuid"
DEVICE_CLASS_PM2_5 = "PM2.5"
DEVICE_CLASS_PM10 = "PM10"
DEVICE_CLASS_CARBON_DIOXIDE = "CO2"
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "VOC"
DEVICE_CLASS_SCORE = "score"
SENSOR_TYPES = {
"TEMP": {
"device_class": DEVICE_CLASS_TEMPERATURE,
"unit_of_measurement": TEMP_CELSIUS,
"icon": "mdi:thermometer",
},
"HUMID": {
"device_class": DEVICE_CLASS_HUMIDITY,
"unit_of_measurement": UNIT_PERCENTAGE,
"icon": "mdi:water-percent",
},
"CO2": {
"device_class": DEVICE_CLASS_CARBON_DIOXIDE,
"unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION,
"icon": "mdi:periodic-table-co2",
},
"VOC": {
"device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
"unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION,
"icon": "mdi:cloud",
},
# Awair docs don't actually specify the size they measure for 'dust',
# but 2.5 allows the sensor to show up in HomeKit
"DUST": {
"device_class": DEVICE_CLASS_PM2_5,
"unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"icon": "mdi:cloud",
},
"PM25": {
"device_class": DEVICE_CLASS_PM2_5,
"unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"icon": "mdi:cloud",
},
"PM10": {
"device_class": DEVICE_CLASS_PM10,
"unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"icon": "mdi:cloud",
},
"score": {
"device_class": DEVICE_CLASS_SCORE,
"unit_of_measurement": UNIT_PERCENTAGE,
"icon": "mdi:percent",
},
}
AWAIR_QUOTA = 300
# This is the minimum time between throttled update calls.
# Don't bother asking us for state more often than that.
SCAN_INTERVAL = timedelta(minutes=5)
AWAIR_DEVICE_SCHEMA = vol.Schema({vol.Required(CONF_UUID): cv.string})
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ACCESS_TOKEN): cv.string,
vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [AWAIR_DEVICE_SCHEMA]),
}
) )
# Awair *heavily* throttles calls that get user information,
# and calls that get the list of user-owned devices - they
# allow 30 per DAY. So, we permit a user to provide a static
# list of devices, and they may provide the same set of information
# that the devices() call would return. However, the only thing
# used at this time is the `uuid` value.
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Connect to the Awair API and find devices.""" """Import Awair configuration from YAML."""
LOGGER.warning(
"Loading Awair via platform setup is deprecated. Please remove it from your configuration."
)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config,
)
)
token = config[CONF_ACCESS_TOKEN]
client = AwairClient(token, session=async_get_clientsession(hass))
try: async def async_setup_entry(
all_devices = [] hass: HomeAssistantType,
devices = config.get(CONF_DEVICES, await client.devices()) config_entry: ConfigType,
async_add_entities: Callable[[List[Entity], bool], None],
):
"""Set up Awair sensor entity based on a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
sensors = []
# Try to throttle dynamically based on quota and number of devices. data: List[AwairResult] = coordinator.data.values()
throttle_minutes = math.ceil(60 / ((AWAIR_QUOTA / len(devices)) / 24)) for result in data:
throttle = timedelta(minutes=throttle_minutes) if result.air_data:
sensors.append(AwairSensor(API_SCORE, result.device, coordinator))
device_sensors = result.air_data.sensors.keys()
for sensor in device_sensors:
if sensor in SENSOR_TYPES:
sensors.append(AwairSensor(sensor, result.device, coordinator))
for device in devices: # The "DUST" sensor for Awair is a combo pm2.5/pm10 sensor only
_LOGGER.debug("Found awair device: %s", device) # present on first-gen devices in lieu of separate pm2.5/pm10 sensors.
awair_data = AwairData(client, device[CONF_UUID], throttle) # We handle that by creating fake pm2.5/pm10 sensors that will always
await awair_data.async_update() # report identical values, and we let users decide how they want to use
for sensor in SENSOR_TYPES: # that data - because we can't really tell what kind of particles the
if sensor in awair_data.data: # "DUST" sensor actually detected. However, it's still useful data.
awair_sensor = AwairSensor(awair_data, device, sensor, throttle) if API_DUST in device_sensors:
all_devices.append(awair_sensor) for alias_kind in DUST_ALIASES:
sensors.append(AwairSensor(alias_kind, result.device, coordinator))
async_add_entities(all_devices, True) async_add_entities(sensors)
return
except AwairClient.AuthError:
_LOGGER.error("Awair API access_token invalid")
except AwairClient.RatelimitError:
_LOGGER.error("Awair API ratelimit exceeded.")
except (
AwairClient.QueryError,
AwairClient.NotFoundError,
AwairClient.GenericError,
) as error:
_LOGGER.error("Unexpected Awair API error: %s", error)
raise PlatformNotReady
class AwairSensor(Entity): class AwairSensor(Entity):
"""Implementation of an Awair device.""" """Defines an Awair sensor entity."""
def __init__(self, data, device, sensor_type, throttle): def __init__(
"""Initialize the sensor.""" self, kind: str, device: AwairDevice, coordinator: AwairDataUpdateCoordinator,
self._uuid = device[CONF_UUID] ) -> None:
self._device_class = SENSOR_TYPES[sensor_type]["device_class"] """Set up an individual AwairSensor."""
self._name = f"Awair {self._device_class}" self._kind = kind
unit = SENSOR_TYPES[sensor_type]["unit_of_measurement"] self._device = device
self._unit_of_measurement = unit self._coordinator = coordinator
self._data = data
self._type = sensor_type
self._throttle = throttle
@property @property
def name(self): def should_poll(self) -> bool:
"""Return the polling requirement of the entity."""
return False
@property
def name(self) -> str:
"""Return the name of the sensor.""" """Return the name of the sensor."""
return self._name name = SENSOR_TYPES[self._kind][ATTR_LABEL]
if self._device.name:
name = f"{self._device.name} {name}"
return name
@property @property
def device_class(self): def unique_id(self) -> str:
"""Return the device class.""" """Return the uuid as the unique_id."""
return self._device_class unique_id_tag = SENSOR_TYPES[self._kind][ATTR_UNIQUE_ID]
# This integration used to create a sensor that was labelled as a "PM2.5"
# sensor for first-gen Awair devices, but its unique_id reflected the truth:
# under the hood, it was a "DUST" sensor. So we preserve that specific unique_id
# for users with first-gen devices that are upgrading.
if self._kind == API_PM25 and API_DUST in self._air_data.sensors:
unique_id_tag = "DUST"
return f"{self._device.uuid}_{unique_id_tag}"
@property @property
def icon(self): def available(self) -> bool:
"""Icon to use in the frontend.""" """Determine if the sensor is available based on API results."""
return SENSOR_TYPES[self._type]["icon"] # If the last update was successful...
if self._coordinator.last_update_success and self._air_data:
# and the results included our sensor type...
if self._kind in self._air_data.sensors:
# then we are available.
return True
# or, we're a dust alias
if self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors:
return True
# or we are API_SCORE
if self._kind == API_SCORE:
# then we are available.
return True
# Otherwise, we are not.
return False
@property @property
def state(self): def state(self) -> float:
"""Return the state of the device.""" """Return the state, rounding off to reasonable values."""
return self._data.data[self._type] state: float
# Special-case for "SCORE", which we treat as the AQI
if self._kind == API_SCORE:
state = self._air_data.score
elif self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors:
state = self._air_data.sensors.dust
else:
state = self._air_data.sensors[self._kind]
if self._kind == API_VOC or self._kind == API_SCORE:
return round(state)
if self._kind == API_TEMP:
return round(state, 1)
return round(state, 2)
@property @property
def device_state_attributes(self): def icon(self) -> str:
"""Return additional attributes.""" """Return the icon."""
return self._data.attrs return SENSOR_TYPES[self._kind][ATTR_ICON]
# The Awair device should be reporting metrics in quite regularly.
# Based on the raw data from the API, it looks like every ~10 seconds
# is normal. Here we assert that the device is not available if the
# last known API timestamp is more than (3 * throttle) minutes in the
# past. It implies that either hass is somehow unable to query the API
# for new data or that the device is not checking in. Either condition
# fits the definition for 'not available'. We pick (3 * throttle) minutes
# to allow for transient errors to correct themselves.
@property
def available(self):
"""Device availability based on the last update timestamp."""
if ATTR_LAST_API_UPDATE not in self.device_state_attributes:
return False
last_api_data = self.device_state_attributes[ATTR_LAST_API_UPDATE]
return (dt.utcnow() - last_api_data) < (3 * self._throttle)
@property @property
def unique_id(self): def device_class(self) -> str:
"""Return the unique id of this entity.""" """Return the device_class."""
return f"{self._uuid}_{self._type}" return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS]
@property @property
def unit_of_measurement(self): def unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity.""" """Return the unit the value is expressed in."""
return self._unit_of_measurement return SENSOR_TYPES[self._kind][ATTR_UNIT]
async def async_update(self): @property
"""Get the latest data.""" def device_state_attributes(self) -> dict:
await self._data.async_update() """Return the Awair Index alongside state attributes.
The Awair Index is a subjective score ranging from 0-4 (inclusive) that
is is used by the Awair app when displaying the relative "safety" of a
given measurement. Each value is mapped to a color indicating the safety:
class AwairData: 0: green
"""Get data from Awair API.""" 1: yellow
2: light-orange
3: orange
4: red
def __init__(self, client, uuid, throttle): The API indicates that both positive and negative values may be returned,
"""Initialize the data object.""" but the negative values are mapped to identical colors as the positive values.
self._client = client Knowing that, we just return the absolute value of a given index so that
self._uuid = uuid users don't have to handle positive/negative values that ultimately "mean"
self.data = {} the same thing.
self.attrs = {}
self.async_update = Throttle(throttle)(self._async_update)
async def _async_update(self): https://docs.developer.getawair.com/?version=latest#awair-score-and-index
"""Get the data from Awair API.""" """
resp = await self._client.air_data_latest(self._uuid) attrs = {ATTR_ATTRIBUTION: ATTRIBUTION}
if self._kind in self._air_data.indices:
attrs["awair_index"] = abs(self._air_data.indices[self._kind])
elif self._kind in DUST_ALIASES and API_DUST in self._air_data.indices:
attrs["awair_index"] = abs(self._air_data.indices.dust)
if not resp: return attrs
return
timestamp = dt.parse_datetime(resp[0][ATTR_TIMESTAMP]) @property
self.attrs[ATTR_LAST_API_UPDATE] = timestamp def device_info(self) -> dict:
self.data[ATTR_SCORE] = resp[0][ATTR_SCORE] """Device information."""
info = {
"identifiers": {(DOMAIN, self._device.uuid)},
"manufacturer": "Awair",
"model": self._device.model,
}
# The air_data_latest call only returns one item, so this should if self._device.name:
# be safe to only process one entry. info["name"] = self._device.name
for sensor in resp[0][ATTR_SENSORS]:
self.data[sensor[ATTR_COMPONENT]] = round(sensor[ATTR_VALUE], 1)
_LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data) if self._device.mac_address:
info["connections"] = {
(dr.CONNECTION_NETWORK_MAC, self._device.mac_address)
}
return info
async def async_added_to_hass(self) -> None:
"""Connect to dispatcher listening for entity data notifications."""
self.async_on_remove(
self._coordinator.async_add_listener(self.async_write_ha_state)
)
async def async_update(self) -> None:
"""Update Awair entity."""
await self._coordinator.async_request_refresh()
@property
def _air_data(self) -> Optional[AwairResult]:
"""Return the latest data for our device, or None."""
result: Optional[AwairResult] = self._coordinator.data.get(self._device.uuid)
if result:
return result.air_data
return None

View file

@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"description": "You must register for an Awair developer access token at: https://developer.getawair.com/onboard/login",
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]",
"email": "[%key:common::config_flow::data::email%]"
}
},
"reauth": {
"description": "Please re-enter your Awair developer access token.",
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]",
"email": "[%key:common::config_flow::data::email%]"
}
}
},
"error": {
"auth": "[%key:common::config_flow::error::invalid_access_token%]",
"unknown": "Unknown Awair API error."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"no_devices": "[%key:common::config_flow::abort::no_devices_found%]",
"reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully"
}
}
}

View file

@ -19,6 +19,7 @@ FLOWS = [
"atag", "atag",
"august", "august",
"avri", "avri",
"awair",
"axis", "axis",
"blebox", "blebox",
"blink", "blink",

View file

@ -1772,7 +1772,7 @@ python-whois==0.7.2
python-wink==1.10.5 python-wink==1.10.5
# homeassistant.components.awair # homeassistant.components.awair
python_awair==0.0.4 python_awair==0.1.1
# homeassistant.components.swiss_public_transport # homeassistant.components.swiss_public_transport
python_opendata_transport==0.2.1 python_opendata_transport==0.2.1

View file

@ -760,7 +760,7 @@ python-twitch-client==0.6.0
python-velbus==2.0.43 python-velbus==2.0.43
# homeassistant.components.awair # homeassistant.components.awair
python_awair==0.0.4 python_awair==0.1.1
# homeassistant.components.tile # homeassistant.components.tile
pytile==3.0.6 pytile==3.0.6

View file

@ -0,0 +1,20 @@
"""Constants used in Awair tests."""
import json
from homeassistant.const import CONF_ACCESS_TOKEN
from tests.common import load_fixture
AWAIR_UUID = "awair_24947"
CONFIG = {CONF_ACCESS_TOKEN: "12345"}
UNIQUE_ID = "foo@bar.com"
DEVICES_FIXTURE = json.loads(load_fixture("awair/devices.json"))
GEN1_DATA_FIXTURE = json.loads(load_fixture("awair/awair.json"))
GEN2_DATA_FIXTURE = json.loads(load_fixture("awair/awair-r2.json"))
GLOW_DATA_FIXTURE = json.loads(load_fixture("awair/glow.json"))
MINT_DATA_FIXTURE = json.loads(load_fixture("awair/mint.json"))
NO_DEVICES_FIXTURE = json.loads(load_fixture("awair/no_devices.json"))
OFFLINE_FIXTURE = json.loads(load_fixture("awair/awair-offline.json"))
OMNI_DATA_FIXTURE = json.loads(load_fixture("awair/omni.json"))
USER_FIXTURE = json.loads(load_fixture("awair/user.json"))

View file

@ -0,0 +1,190 @@
"""Define tests for the Awair config flow."""
from asynctest import patch
from python_awair.exceptions import AuthError, AwairError
from homeassistant import data_entry_flow
from homeassistant.components.awair.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN
from .const import CONFIG, DEVICES_FIXTURE, NO_DEVICES_FIXTURE, UNIQUE_ID, USER_FIXTURE
from tests.common import MockConfigEntry
async def test_show_form(hass):
"""Test that the form is served with no input."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == SOURCE_USER
async def test_invalid_access_token(hass):
"""Test that errors are shown when the access token is invalid."""
with patch("python_awair.AwairClient.query", side_effect=AuthError()):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
assert result["errors"] == {CONF_ACCESS_TOKEN: "auth"}
async def test_unexpected_api_error(hass):
"""Test that we abort on generic errors."""
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
assert result["type"] == "abort"
assert result["reason"] == "unknown"
async def test_duplicate_error(hass):
"""Test that errors are shown when adding a duplicate config."""
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
"homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
):
MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG).add_to_hass(
hass
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
async def test_no_devices_error(hass):
"""Test that errors are shown when the API returns no devices."""
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, NO_DEVICES_FIXTURE]
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
assert result["type"] == "abort"
assert result["reason"] == "no_devices"
async def test_import(hass):
"""Test config.yaml import."""
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
"homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_ACCESS_TOKEN: CONFIG[CONF_ACCESS_TOKEN]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "foo@bar.com (32406)"
assert result["data"][CONF_ACCESS_TOKEN] == CONFIG[CONF_ACCESS_TOKEN]
assert result["result"].unique_id == UNIQUE_ID
async def test_import_aborts_on_api_error(hass):
"""Test config.yaml imports on api error."""
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_ACCESS_TOKEN: CONFIG[CONF_ACCESS_TOKEN]},
)
assert result["type"] == "abort"
assert result["reason"] == "unknown"
async def test_import_aborts_if_configured(hass):
"""Test config import doesn't re-import unnecessarily."""
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
"homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
):
MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG).add_to_hass(
hass
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_ACCESS_TOKEN: CONFIG[CONF_ACCESS_TOKEN]},
)
assert result["type"] == "abort"
assert result["reason"] == "already_setup"
async def test_reauth(hass):
"""Test reauth flow."""
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
"homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
):
mock_config = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG)
mock_config.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"}
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG,
)
assert result["type"] == "abort"
assert result["reason"] == "reauth_successful"
with patch("python_awair.AwairClient.query", side_effect=AuthError()):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG,
)
assert result["errors"] == {CONF_ACCESS_TOKEN: "auth"}
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG,
)
assert result["type"] == "abort"
assert result["reason"] == "unknown"
async def test_create_entry(hass):
"""Test overall flow."""
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
"homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "foo@bar.com (32406)"
assert result["data"][CONF_ACCESS_TOKEN] == CONFIG[CONF_ACCESS_TOKEN]
assert result["result"].unique_id == UNIQUE_ID

View file

@ -1,312 +1,342 @@
"""Tests for the Awair sensor platform.""" """Tests for the Awair sensor platform."""
from contextlib import contextmanager from homeassistant.components.awair.const import (
from datetime import timedelta API_CO2,
import json API_HUMID,
import logging API_LUX,
API_PM10,
from homeassistant.components.awair.sensor import ( API_PM25,
ATTR_LAST_API_UPDATE, API_SCORE,
ATTR_TIMESTAMP, API_SPL_A,
DEVICE_CLASS_CARBON_DIOXIDE, API_TEMP,
DEVICE_CLASS_PM2_5, API_VOC,
DEVICE_CLASS_SCORE, ATTR_UNIQUE_ID,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DOMAIN,
SENSOR_TYPES,
) )
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import ( from homeassistant.const import (
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_PARTS_PER_MILLION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
TEMP_CELSIUS, TEMP_CELSIUS,
UNIT_PERCENTAGE, UNIT_PERCENTAGE,
) )
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import parse_datetime, utcnow from .const import (
AWAIR_UUID,
CONFIG,
DEVICES_FIXTURE,
GEN1_DATA_FIXTURE,
GEN2_DATA_FIXTURE,
GLOW_DATA_FIXTURE,
MINT_DATA_FIXTURE,
OFFLINE_FIXTURE,
OMNI_DATA_FIXTURE,
UNIQUE_ID,
USER_FIXTURE,
)
from tests.async_mock import patch from tests.async_mock import patch
from tests.common import async_fire_time_changed, load_fixture from tests.common import MockConfigEntry
DISCOVERY_CONFIG = {"sensor": {"platform": "awair", "access_token": "qwerty"}}
MANUAL_CONFIG = {
"sensor": {
"platform": "awair",
"access_token": "qwerty",
"devices": [{"uuid": "awair_foo"}],
}
}
_LOGGER = logging.getLogger(__name__)
NOW = utcnow()
AIR_DATA_FIXTURE = json.loads(load_fixture("awair_air_data_latest.json"))
AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP] = str(NOW)
AIR_DATA_FIXTURE_UPDATED = json.loads(
load_fixture("awair_air_data_latest_updated.json")
)
AIR_DATA_FIXTURE_UPDATED[0][ATTR_TIMESTAMP] = str(NOW + timedelta(minutes=5))
AIR_DATA_FIXTURE_EMPTY = []
@contextmanager async def setup_awair(hass, fixtures):
def alter_time(retval): """Add Awair devices to hass, using specified fixtures for data."""
"""Manage multiple time mocks."""
patch_one = patch("homeassistant.util.dt.utcnow", return_value=retval)
patch_two = patch("homeassistant.util.utcnow", return_value=retval)
patch_three = patch(
"homeassistant.components.awair.sensor.dt.utcnow", return_value=retval
)
with patch_one, patch_two, patch_three: entry = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG)
yield with patch("python_awair.AwairClient.query", side_effect=fixtures):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
async def setup_awair(hass, config=None, data_fixture=AIR_DATA_FIXTURE):
"""Load the Awair platform."""
devices_json = json.loads(load_fixture("awair_devices.json"))
devices_mock = devices_json
devices_patch = patch("python_awair.AwairClient.devices", return_value=devices_mock)
air_data_mock = data_fixture
air_data_patch = patch(
"python_awair.AwairClient.air_data_latest", return_value=air_data_mock
)
if config is None:
config = DISCOVERY_CONFIG
with devices_patch, air_data_patch, alter_time(NOW):
assert await async_setup_component(hass, SENSOR_DOMAIN, config)
await hass.async_block_till_done() await hass.async_block_till_done()
async def test_platform_manually_configured(hass): def assert_expected_properties(
"""Test that we can manually configure devices.""" hass, registry, name, unique_id, state_value, attributes
await setup_awair(hass, MANUAL_CONFIG) ):
"""Assert expected properties from a dict."""
assert len(hass.states.async_all()) == 6 entry = registry.async_get(name)
assert entry.unique_id == unique_id
# Ensure that we loaded the device with uuid 'awair_foo', not the state = hass.states.get(name)
# 'awair_12345' device that we stub out for API device discovery assert state
entity = hass.data[SENSOR_DOMAIN].get_entity("sensor.awair_co2") assert state.state == state_value
assert entity.unique_id == "awair_foo_CO2" for attr, value in attributes.items():
assert state.attributes.get(attr) == value
async def test_platform_automatically_configured(hass): async def test_awair_gen1_sensors(hass):
"""Test that we can discover devices from the API.""" """Test expected sensors on a 1st gen Awair."""
await setup_awair(hass)
assert len(hass.states.async_all()) == 6 fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN1_DATA_FIXTURE]
await setup_awair(hass, fixtures)
registry = await hass.helpers.entity_registry.async_get_registry()
# Ensure that we loaded the device with uuid 'awair_12345', which is assert_expected_properties(
# the device that we stub out for API device discovery hass,
entity = hass.data[SENSOR_DOMAIN].get_entity("sensor.awair_co2") registry,
assert entity.unique_id == "awair_12345_CO2" "sensor.living_room_awair_score",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}",
"88",
async def test_bad_platform_setup(hass): {ATTR_ICON: "mdi:blur"},
"""Tests that we throw correct exceptions when setting up Awair."""
from python_awair import AwairClient
auth_patch = patch(
"python_awair.AwairClient.devices", side_effect=AwairClient.AuthError
)
rate_patch = patch(
"python_awair.AwairClient.devices", side_effect=AwairClient.RatelimitError
)
generic_patch = patch(
"python_awair.AwairClient.devices", side_effect=AwairClient.GenericError
) )
with auth_patch: assert_expected_properties(
assert await async_setup_component(hass, SENSOR_DOMAIN, DISCOVERY_CONFIG) hass,
assert not hass.states.async_all() registry,
"sensor.living_room_temperature",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_TEMP][ATTR_UNIQUE_ID]}",
"21.8",
{ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, "awair_index": 1.0},
)
with rate_patch: assert_expected_properties(
assert await async_setup_component(hass, SENSOR_DOMAIN, DISCOVERY_CONFIG) hass,
assert not hass.states.async_all() registry,
"sensor.living_room_humidity",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_HUMID][ATTR_UNIQUE_ID]}",
"41.59",
{ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, "awair_index": 0.0},
)
with generic_patch: assert_expected_properties(
assert await async_setup_component(hass, SENSOR_DOMAIN, DISCOVERY_CONFIG) hass,
assert not hass.states.async_all() registry,
"sensor.living_room_carbon_dioxide",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_CO2][ATTR_UNIQUE_ID]}",
"654.0",
{
ATTR_ICON: "mdi:cloud",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION,
"awair_index": 0.0,
},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_volatile_organic_compounds",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_VOC][ATTR_UNIQUE_ID]}",
"366",
{
ATTR_ICON: "mdi:cloud",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION,
"awair_index": 1.0,
},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_pm2_5",
# gen1 unique_id should be awair_12345-DUST, which matches old integration behavior
f"{AWAIR_UUID}_DUST",
"14.3",
{
ATTR_ICON: "mdi:blur",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"awair_index": 1.0,
},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_pm10",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM10][ATTR_UNIQUE_ID]}",
"14.3",
{
ATTR_ICON: "mdi:blur",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"awair_index": 1.0,
},
)
# We should not have a dust sensor; it's aliased as pm2.5
# and pm10 sensors.
assert hass.states.get("sensor.living_room_dust") is None
# We should not have sound or lux sensors.
assert hass.states.get("sensor.living_room_sound_level") is None
assert hass.states.get("sensor.living_room_illuminance") is None
async def test_awair_setup_no_data(hass): async def test_awair_gen2_sensors(hass):
"""Ensure that we do not crash during setup when no data is returned.""" """Test expected sensors on a 2nd gen Awair."""
await setup_awair(hass, data_fixture=AIR_DATA_FIXTURE_EMPTY)
assert not hass.states.async_all() fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN2_DATA_FIXTURE]
await setup_awair(hass, fixtures)
registry = await hass.helpers.entity_registry.async_get_registry()
assert_expected_properties(
hass,
registry,
"sensor.living_room_awair_score",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}",
"97",
{ATTR_ICON: "mdi:blur"},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_pm2_5",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}",
"2.0",
{
ATTR_ICON: "mdi:blur",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"awair_index": 0.0,
},
)
# The Awair 2nd gen reports specifically a pm2.5 sensor,
# and so we don't alias anything. Make sure we didn't do that.
assert hass.states.get("sensor.living_room_pm10") is None
async def test_awair_misc_attributes(hass): async def test_awair_mint_sensors(hass):
"""Test that desired attributes are set.""" """Test expected sensors on an Awair mint."""
await setup_awair(hass)
attributes = hass.states.get("sensor.awair_co2").attributes fixtures = [USER_FIXTURE, DEVICES_FIXTURE, MINT_DATA_FIXTURE]
assert attributes[ATTR_LAST_API_UPDATE] == parse_datetime( await setup_awair(hass, fixtures)
AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP] registry = await hass.helpers.entity_registry.async_get_registry()
assert_expected_properties(
hass,
registry,
"sensor.living_room_awair_score",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}",
"98",
{ATTR_ICON: "mdi:blur"},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_pm2_5",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}",
"1.0",
{
ATTR_ICON: "mdi:blur",
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
"awair_index": 0.0,
},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_illuminance",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}",
"441.7",
{ATTR_UNIT_OF_MEASUREMENT: "lx"},
)
# The Mint does not have a CO2 sensor.
assert hass.states.get("sensor.living_room_carbon_dioxide") is None
async def test_awair_glow_sensors(hass):
"""Test expected sensors on an Awair glow."""
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GLOW_DATA_FIXTURE]
await setup_awair(hass, fixtures)
registry = await hass.helpers.entity_registry.async_get_registry()
assert_expected_properties(
hass,
registry,
"sensor.living_room_awair_score",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}",
"93",
{ATTR_ICON: "mdi:blur"},
)
# The glow does not have a particle sensor
assert hass.states.get("sensor.living_room_pm2_5") is None
async def test_awair_omni_sensors(hass):
"""Test expected sensors on an Awair omni."""
fixtures = [USER_FIXTURE, DEVICES_FIXTURE, OMNI_DATA_FIXTURE]
await setup_awair(hass, fixtures)
registry = await hass.helpers.entity_registry.async_get_registry()
assert_expected_properties(
hass,
registry,
"sensor.living_room_awair_score",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}",
"99",
{ATTR_ICON: "mdi:blur"},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_sound_level",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_SPL_A][ATTR_UNIQUE_ID]}",
"47.0",
{ATTR_ICON: "mdi:ear-hearing", ATTR_UNIT_OF_MEASUREMENT: "dBa"},
)
assert_expected_properties(
hass,
registry,
"sensor.living_room_illuminance",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}",
"804.9",
{ATTR_UNIT_OF_MEASUREMENT: "lx"},
) )
async def test_awair_score(hass): async def test_awair_offline(hass):
"""Test that we create a sensor for the 'Awair score'.""" """Test expected behavior when an Awair is offline."""
await setup_awair(hass)
sensor = hass.states.get("sensor.awair_score") fixtures = [USER_FIXTURE, DEVICES_FIXTURE, OFFLINE_FIXTURE]
assert sensor.state == "78" await setup_awair(hass, fixtures)
assert sensor.attributes["device_class"] == DEVICE_CLASS_SCORE
assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE # The expected behavior is that we won't have any sensors
# if the device is not online when we set it up. python_awair
# does not make any assumptions about what sensors a device
# might have - they are created dynamically.
# We check for the absence of the "awair score", which every
# device *should* have if it's online. If we don't see it,
# then we probably didn't set anything up. Which is correct,
# in this case.
assert hass.states.get("sensor.living_room_awair_score") is None
async def test_awair_temp(hass): async def test_awair_unavailable(hass):
"""Test that we create a temperature sensor.""" """Test expected behavior when an Awair becomes offline later."""
await setup_awair(hass)
sensor = hass.states.get("sensor.awair_temperature") fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN1_DATA_FIXTURE]
assert sensor.state == "22.4" await setup_awair(hass, fixtures)
assert sensor.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE registry = await hass.helpers.entity_registry.async_get_registry()
assert sensor.attributes["unit_of_measurement"] == TEMP_CELSIUS
assert_expected_properties(
async def test_awair_humid(hass): hass,
"""Test that we create a humidity sensor.""" registry,
await setup_awair(hass) "sensor.living_room_awair_score",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}",
sensor = hass.states.get("sensor.awair_humidity") "88",
assert sensor.state == "32.7" {ATTR_ICON: "mdi:blur"},
assert sensor.attributes["device_class"] == DEVICE_CLASS_HUMIDITY
assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
async def test_awair_co2(hass):
"""Test that we create a CO2 sensor."""
await setup_awair(hass)
sensor = hass.states.get("sensor.awair_co2")
assert sensor.state == "612"
assert sensor.attributes["device_class"] == DEVICE_CLASS_CARBON_DIOXIDE
assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_MILLION
async def test_awair_voc(hass):
"""Test that we create a CO2 sensor."""
await setup_awair(hass)
sensor = hass.states.get("sensor.awair_voc")
assert sensor.state == "1012"
assert sensor.attributes["device_class"] == DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS
assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_BILLION
async def test_awair_dust(hass):
"""Test that we create a pm25 sensor."""
await setup_awair(hass)
# The Awair Gen1 that we mock actually returns 'DUST', but that
# is mapped to pm25 internally so that it shows up in Homekit
sensor = hass.states.get("sensor.awair_pm2_5")
assert sensor.state == "6.2"
assert sensor.attributes["device_class"] == DEVICE_CLASS_PM2_5
assert (
sensor.attributes["unit_of_measurement"]
== CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
) )
with patch("python_awair.AwairClient.query", side_effect=OFFLINE_FIXTURE):
async def test_awair_unsupported_sensors(hass): await hass.helpers.entity_component.async_update_entity(
"""Ensure we don't create sensors the stubbed device doesn't support.""" "sensor.living_room_awair_score"
await setup_awair(hass) )
assert_expected_properties(
# Our tests mock an Awair Gen 1 device, which should never return hass,
# PM10 sensor readings. Assert that we didn't create a pm10 sensor, registry,
# which could happen if someone were ever to refactor incorrectly. "sensor.living_room_awair_score",
assert hass.states.get("sensor.awair_pm10") is None f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}",
STATE_UNAVAILABLE,
{ATTR_ICON: "mdi:blur"},
async def test_availability(hass): )
"""Ensure that we mark the component available/unavailable correctly."""
await setup_awair(hass)
assert hass.states.get("sensor.awair_score").state == "78"
future = NOW + timedelta(minutes=30)
data_patch = patch(
"python_awair.AwairClient.air_data_latest", return_value=AIR_DATA_FIXTURE,
)
with data_patch, alter_time(future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get("sensor.awair_score").state == STATE_UNAVAILABLE
future = NOW + timedelta(hours=1)
fixture = AIR_DATA_FIXTURE_UPDATED
fixture[0][ATTR_TIMESTAMP] = str(future)
data_patch = patch("python_awair.AwairClient.air_data_latest", return_value=fixture)
with data_patch, alter_time(future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get("sensor.awair_score").state == "79"
future = NOW + timedelta(minutes=90)
fixture = AIR_DATA_FIXTURE_EMPTY
data_patch = patch("python_awair.AwairClient.air_data_latest", return_value=fixture)
with data_patch, alter_time(future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get("sensor.awair_score").state == STATE_UNAVAILABLE
async def test_async_update(hass):
"""Ensure we can update sensors."""
await setup_awair(hass)
future = NOW + timedelta(minutes=10)
data_patch = patch(
"python_awair.AwairClient.air_data_latest",
return_value=AIR_DATA_FIXTURE_UPDATED,
)
with data_patch, alter_time(future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
score_sensor = hass.states.get("sensor.awair_score")
assert score_sensor.state == "79"
assert hass.states.get("sensor.awair_temperature").state == "23.4"
assert hass.states.get("sensor.awair_humidity").state == "33.7"
assert hass.states.get("sensor.awair_co2").state == "613"
assert hass.states.get("sensor.awair_voc").state == "1013"
assert hass.states.get("sensor.awair_pm2_5").state == "7.2"
async def test_throttle_async_update(hass):
"""Ensure we throttle updates."""
await setup_awair(hass)
future = NOW + timedelta(minutes=1)
data_patch = patch(
"python_awair.AwairClient.air_data_latest",
return_value=AIR_DATA_FIXTURE_UPDATED,
)
with data_patch, alter_time(future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get("sensor.awair_score").state == "78"
future = NOW + timedelta(minutes=15)
with data_patch, alter_time(future):
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get("sensor.awair_score").state == "79"

View file

@ -0,0 +1 @@
{"data":[]}

1
tests/fixtures/awair/awair-r2.json vendored Normal file
View file

@ -0,0 +1 @@
{"data":[{"timestamp":"2020-04-10T16:41:57.771Z","score":97.0,"sensors":[{"comp":"temp","value":18.829999923706055},{"comp":"humid","value":50.52000045776367},{"comp":"co2","value":431.0},{"comp":"voc","value":57.0},{"comp":"pm25","value":2.0}],"indices":[{"comp":"temp","value":0.0},{"comp":"humid","value":1.0},{"comp":"co2","value":0.0},{"comp":"voc","value":0.0},{"comp":"pm25","value":0.0}]}]}

1
tests/fixtures/awair/awair.json vendored Normal file
View file

@ -0,0 +1 @@
{"data":[{"timestamp":"2020-04-10T15:38:24.111Z","score":88.0,"sensors":[{"comp":"temp","value":21.770000457763672},{"comp":"humid","value":41.59000015258789},{"comp":"co2","value":654.0},{"comp":"voc","value":366.0},{"comp":"dust","value":14.300000190734863}],"indices":[{"comp":"temp","value":-1.0},{"comp":"humid","value":0.0},{"comp":"co2","value":0.0},{"comp":"voc","value":1.0},{"comp":"dust","value":1.0}]}]}

1
tests/fixtures/awair/devices.json vendored Normal file
View file

@ -0,0 +1 @@
{"devices":[{"name":"Living Room","macAddress":"70886B104941","latitude":0.0,"preference":"GENERAL","timezone":"","roomType":"LIVING_ROOM","deviceType":"awair","longitude":0.0,"spaceType":"HOME","deviceUUID":"awair_24947","deviceId":24947,"locationName":"Chicago, IL"}]}

1
tests/fixtures/awair/glow.json vendored Normal file
View file

@ -0,0 +1 @@
{"data":[{"timestamp":"2020-04-10T16:46:15.486Z","score":93.0,"sensors":[{"comp":"temp","value":21.93000030517578},{"comp":"humid","value":42.31999969482422},{"comp":"co2","value":429.0},{"comp":"voc","value":288.0}],"indices":[{"comp":"temp","value":-1.0},{"comp":"humid","value":0.0},{"comp":"co2","value":0.0},{"comp":"voc","value":0.0}]}]}

1
tests/fixtures/awair/mint.json vendored Normal file
View file

@ -0,0 +1 @@
{"data":[{"timestamp":"2020-04-10T16:25:03.606Z","score":98.0,"sensors":[{"comp":"temp","value":20.639999389648438},{"comp":"humid","value":45.04999923706055},{"comp":"voc","value":269.0},{"comp":"pm25","value":1.0},{"comp":"lux","value":441.70001220703125}],"indices":[{"comp":"temp","value":0.0},{"comp":"humid","value":0.0},{"comp":"voc","value":0.0},{"comp":"pm25","value":0.0}]}]}

1
tests/fixtures/awair/no_devices.json vendored Normal file
View file

@ -0,0 +1 @@
{"devices":[]}

1
tests/fixtures/awair/omni.json vendored Normal file
View file

@ -0,0 +1 @@
{"data":[{"timestamp":"2020-04-10T16:18:10.298Z","score":99.0,"sensors":[{"comp":"temp","value":21.40999984741211},{"comp":"humid","value":42.7400016784668},{"comp":"co2","value":436.0},{"comp":"voc","value":171.0},{"comp":"pm25","value":0.0},{"comp":"lux","value":804.9000244140625},{"comp":"spl_a","value":47.0}],"indices":[{"comp":"temp","value":0.0},{"comp":"humid","value":0.0},{"comp":"co2","value":0.0},{"comp":"voc","value":0.0},{"comp":"pm25","value":0.0}]}]}

1
tests/fixtures/awair/user.json vendored Normal file
View file

@ -0,0 +1 @@
{"dobDay":8,"usages":[{"scope":"API_USAGE","usage":302},{"scope":"USER_DEVICE_LIST","usage":50},{"scope":"USER_INFO","usage":80}],"tier":"Large_developer","email":"foo@bar.com","dobYear":2020,"permissions":[{"scope":"USER_DEVICE_LIST","quota":2147483647},{"scope":"USER_INFO","quota":2147483647},{"scope":"FIFTEEN_MIN","quota":30000},{"scope":"FIVE_MIN","quota":30000},{"scope":"RAW","quota":30000},{"scope":"LATEST","quota":30000},{"scope":"PUT_PREFERENCE","quota":30000},{"scope":"PUT_DISPLAY_MODE","quota":30000},{"scope":"PUT_LED_MODE","quota":30000},{"scope":"PUT_KNOCKING_MODE","quota":30000},{"scope":"PUT_TIMEZONE","quota":30000},{"scope":"PUT_DEVICE_NAME","quota":30000},{"scope":"PUT_LOCATION","quota":30000},{"scope":"PUT_ROOM_TYPE","quota":30000},{"scope":"PUT_SPACE_TYPE","quota":30000},{"scope":"GET_DISPLAY_MODE","quota":30000},{"scope":"GET_LED_MODE","quota":30000},{"scope":"GET_KNOCKING_MODE","quota":30000},{"scope":"GET_POWER_STATUS","quota":30000},{"scope":"GET_TIMEZONE","quota":30000}],"dobMonth":4,"sex":"MALE","lastName":"Hayworth","firstName":"Andrew","id":"32406"}

View file

@ -1,50 +0,0 @@
[
{
"timestamp": "2018-11-21T15:46:16.346Z",
"score": 78,
"sensors": [
{
"component": "TEMP",
"value": 22.4
},
{
"component": "HUMID",
"value": 32.73
},
{
"component": "CO2",
"value": 612
},
{
"component": "VOC",
"value": 1012
},
{
"component": "DUST",
"value": 6.2
}
],
"indices": [
{
"component": "TEMP",
"value": 0
},
{
"component": "HUMID",
"value": -2
},
{
"component": "CO2",
"value": 0
},
{
"component": "VOC",
"value": 2
},
{
"component": "DUST",
"value": 0
}
]
}
]

View file

@ -1,50 +0,0 @@
[
{
"timestamp": "2018-11-21T15:46:16.346Z",
"score": 79,
"sensors": [
{
"component": "TEMP",
"value": 23.4
},
{
"component": "HUMID",
"value": 33.73
},
{
"component": "CO2",
"value": 613
},
{
"component": "VOC",
"value": 1013
},
{
"component": "DUST",
"value": 7.2
}
],
"indices": [
{
"component": "TEMP",
"value": 0
},
{
"component": "HUMID",
"value": -2
},
{
"component": "CO2",
"value": 0
},
{
"component": "VOC",
"value": 2
},
{
"component": "DUST",
"value": 0
}
]
}
]

View file

@ -1,25 +0,0 @@
[
{
"uuid": "awair_12345",
"deviceType": "awair",
"deviceId": "12345",
"name": "Awair",
"preference": "GENERAL",
"macAddress": "FFFFFFFFFFFF",
"room": {
"id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
"name": "My Room",
"kind": "LIVING_ROOM",
"Space": {
"id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
"kind": "HOME",
"location": {
"name": "Chicago, IL",
"timezone": "",
"lat": 0,
"lon": -0
}
}
}
}
]