Add airgradient integration (#114113)

This commit is contained in:
Joost Lekkerkerker
2024-05-09 10:54:29 +02:00
committed by GitHub
parent 32061d4eb1
commit 6485973d9b
25 changed files with 1432 additions and 0 deletions

View File

@ -48,6 +48,7 @@ homeassistant.components.adax.*
homeassistant.components.adguard.*
homeassistant.components.aftership.*
homeassistant.components.air_quality.*
homeassistant.components.airgradient.*
homeassistant.components.airly.*
homeassistant.components.airnow.*
homeassistant.components.airq.*

View File

@ -56,6 +56,8 @@ build.json @home-assistant/supervisor
/tests/components/agent_dvr/ @ispysoftware
/homeassistant/components/air_quality/ @home-assistant/core
/tests/components/air_quality/ @home-assistant/core
/homeassistant/components/airgradient/ @airgradienthq @joostlek
/tests/components/airgradient/ @airgradienthq @joostlek
/homeassistant/components/airly/ @bieniu
/tests/components/airly/ @bieniu
/homeassistant/components/airnow/ @asymworks

View File

@ -0,0 +1,34 @@
"""The Airgradient integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import AirGradientDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Airgradient from a config entry."""
coordinator = AirGradientDataUpdateCoordinator(hass, entry.data[CONF_HOST])
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -0,0 +1,83 @@
"""Config flow for Airgradient."""
from typing import Any
from airgradient import AirGradientClient, AirGradientError
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
"""AirGradient config flow."""
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.data[CONF_HOST] = host = discovery_info.host
self.data[CONF_MODEL] = discovery_info.properties["model"]
await self.async_set_unique_id(discovery_info.properties["serialno"])
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
session = async_get_clientsession(self.hass)
air_gradient = AirGradientClient(host, session=session)
await air_gradient.get_current_measures()
self.context["title_placeholders"] = {
"model": self.data[CONF_MODEL],
}
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
return self.async_create_entry(
title=self.data[CONF_MODEL],
data={CONF_HOST: self.data[CONF_HOST]},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={
"model": self.data[CONF_MODEL],
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input:
session = async_get_clientsession(self.hass)
air_gradient = AirGradientClient(user_input[CONF_HOST], session=session)
try:
current_measures = await air_gradient.get_current_measures()
except AirGradientError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(current_measures.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=current_measures.model,
data={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)

View File

@ -0,0 +1,7 @@
"""Constants for the Airgradient integration."""
import logging
DOMAIN = "airgradient"
LOGGER = logging.getLogger(__package__)

View File

@ -0,0 +1,32 @@
"""Define an object to manage fetching AirGradient data."""
from datetime import timedelta
from airgradient import AirGradientClient, AirGradientError, Measures
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
class AirGradientDataUpdateCoordinator(DataUpdateCoordinator[Measures]):
"""Class to manage fetching AirGradient data."""
def __init__(self, hass: HomeAssistant, host: str) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
logger=LOGGER,
name=f"AirGradient {host}",
update_interval=timedelta(minutes=1),
)
session = async_get_clientsession(hass)
self.client = AirGradientClient(host, session=session)
async def _async_update_data(self) -> Measures:
try:
return await self.client.get_current_measures()
except AirGradientError as error:
raise UpdateFailed(error) from error

View File

@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"total_volatile_organic_component_index": {
"default": "mdi:molecule"
},
"nitrogen_index": {
"default": "mdi:molecule"
},
"pm003_count": {
"default": "mdi:blur"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"domain": "airgradient",
"name": "Airgradient",
"codeowners": ["@airgradienthq", "@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["airgradient==0.4.0"],
"zeroconf": ["_airgradient._tcp.local."]
}

View File

@ -0,0 +1,192 @@
"""Support for AirGradient sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from airgradient.models import Measures
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirGradientDataUpdateCoordinator
from .const import DOMAIN
@dataclass(frozen=True, kw_only=True)
class AirGradientSensorEntityDescription(SensorEntityDescription):
"""Describes AirGradient sensor entity."""
value_fn: Callable[[Measures], StateType]
SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = (
AirGradientSensorEntityDescription(
key="pm01",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm01,
),
AirGradientSensorEntityDescription(
key="pm02",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm02,
),
AirGradientSensorEntityDescription(
key="pm10",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm10,
),
AirGradientSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.ambient_temperature,
),
AirGradientSensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.relative_humidity,
),
AirGradientSensorEntityDescription(
key="signal_strength",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda status: status.signal_strength,
),
AirGradientSensorEntityDescription(
key="tvoc",
translation_key="total_volatile_organic_component_index",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.total_volatile_organic_component_index,
),
AirGradientSensorEntityDescription(
key="nitrogen_index",
translation_key="nitrogen_index",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.nitrogen_index,
),
AirGradientSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.rco2,
),
AirGradientSensorEntityDescription(
key="pm003",
translation_key="pm003_count",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda status: status.pm003_count,
),
AirGradientSensorEntityDescription(
key="nox_raw",
translation_key="raw_nitrogen",
native_unit_of_measurement="ticks",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda status: status.raw_nitrogen,
),
AirGradientSensorEntityDescription(
key="tvoc_raw",
translation_key="raw_total_volatile_organic_component",
native_unit_of_measurement="ticks",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda status: status.raw_total_volatile_organic_component,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up AirGradient sensor entities based on a config entry."""
coordinator: AirGradientDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
listener: Callable[[], None] | None = None
not_setup: set[AirGradientSensorEntityDescription] = set(SENSOR_TYPES)
@callback
def add_entities() -> None:
"""Add new entities based on the latest data."""
nonlocal not_setup, listener
sensor_descriptions = not_setup
not_setup = set()
sensors = []
for description in sensor_descriptions:
if description.value_fn(coordinator.data) is None:
not_setup.add(description)
else:
sensors.append(AirGradientSensor(coordinator, description))
if sensors:
async_add_entities(sensors)
if not_setup:
if not listener:
listener = coordinator.async_add_listener(add_entities)
elif listener:
listener()
add_entities()
class AirGradientSensor(
CoordinatorEntity[AirGradientDataUpdateCoordinator], SensorEntity
):
"""Defines an AirGradient sensor."""
_attr_has_entity_name = True
entity_description: AirGradientSensorEntityDescription
def __init__(
self,
coordinator: AirGradientDataUpdateCoordinator,
description: AirGradientSensorEntityDescription,
) -> None:
"""Initialize airgradient sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.serial_number}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.data.serial_number)},
model=coordinator.data.model,
manufacturer="AirGradient",
serial_number=coordinator.data.serial_number,
sw_version=coordinator.data.firmware_version,
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,44 @@
{
"config": {
"flow_title": "{model}",
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the Airgradient device."
}
},
"discovery_confirm": {
"description": "Do you want to setup {model}?"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"entity": {
"sensor": {
"total_volatile_organic_component_index": {
"name": "Total VOC index"
},
"nitrogen_index": {
"name": "Nitrogen index"
},
"pm003_count": {
"name": "PM0.3 count"
},
"raw_total_volatile_organic_component": {
"name": "Raw total VOC"
},
"raw_nitrogen": {
"name": "Raw nitrogen"
}
}
}
}

View File

@ -27,6 +27,7 @@ FLOWS = {
"aemet",
"aftership",
"agent_dvr",
"airgradient",
"airly",
"airnow",
"airq",

View File

@ -93,6 +93,12 @@
"config_flow": true,
"iot_class": "local_polling"
},
"airgradient": {
"name": "Airgradient",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"airly": {
"name": "Airly",
"integration_type": "service",

View File

@ -277,6 +277,11 @@ ZEROCONF = {
"domain": "romy",
},
],
"_airgradient._tcp.local.": [
{
"domain": "airgradient",
},
],
"_airplay._tcp.local.": [
{
"domain": "apple_tv",

View File

@ -241,6 +241,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.airgradient.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.airly.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -406,6 +406,9 @@ aiowithings==2.1.0
# homeassistant.components.yandex_transport
aioymaps==1.2.2
# homeassistant.components.airgradient
airgradient==0.4.0
# homeassistant.components.airly
airly==1.1.0

View File

@ -379,6 +379,9 @@ aiowithings==2.1.0
# homeassistant.components.yandex_transport
aioymaps==1.2.2
# homeassistant.components.airgradient
airgradient==0.4.0
# homeassistant.components.airly
airly==1.1.0

View File

@ -0,0 +1,13 @@
"""Tests for the Airgradient integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,54 @@
"""AirGradient tests configuration."""
from collections.abc import Generator
from unittest.mock import patch
from airgradient import Measures
import pytest
from homeassistant.components.airgradient.const import DOMAIN
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry, load_fixture
from tests.components.smhi.common import AsyncMock
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.airgradient.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_airgradient_client() -> Generator[AsyncMock, None, None]:
"""Mock an AirGradient client."""
with (
patch(
"homeassistant.components.airgradient.coordinator.AirGradientClient",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.airgradient.config_flow.AirGradientClient",
new=mock_client,
),
):
client = mock_client.return_value
client.get_current_measures.return_value = Measures.from_json(
load_fixture("current_measures.json", DOMAIN)
)
yield client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Airgradient",
data={CONF_HOST: "10.0.0.131"},
unique_id="84fce612f5b8",
)

View File

@ -0,0 +1,19 @@
{
"wifi": -52,
"serialno": "84fce612f5b8",
"rco2": 778,
"pm01": 22,
"pm02": 34,
"pm10": 41,
"pm003Count": 270,
"tvocIndex": 99,
"tvoc_raw": 31792,
"noxIndex": 1,
"nox_raw": 16931,
"atmp": 27.96,
"rhum": 48,
"boot": 28,
"ledMode": "co2",
"firmwareVersion": "3.0.8",
"fwMode": "I-9PSL"
}

View File

@ -0,0 +1,8 @@
{
"wifi": -59,
"serialno": "84fce612f5b8",
"boot": 0,
"ledMode": "co2",
"firmwareVersion": "3.0.8",
"fwMode": "I-9PSL"
}

View File

@ -0,0 +1,31 @@
# serializer version: 1
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'airgradient',
'84fce612f5b8',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'AirGradient',
'model': 'I-9PSL',
'name': 'Airgradient',
'name_by_user': None,
'serial_number': '84fce612f5b8',
'suggested_area': None,
'sw_version': '3.0.8',
'via_device_id': None,
})
# ---

View File

@ -0,0 +1,605 @@
# serializer version: 1
# name: test_all_entities[sensor.airgradient_carbon_dioxide-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_carbon_dioxide',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CO2: 'carbon_dioxide'>,
'original_icon': None,
'original_name': 'Carbon dioxide',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '84fce612f5b8-co2',
'unit_of_measurement': 'ppm',
})
# ---
# name: test_all_entities[sensor.airgradient_carbon_dioxide-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'carbon_dioxide',
'friendly_name': 'Airgradient Carbon dioxide',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppm',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_carbon_dioxide',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '778',
})
# ---
# name: test_all_entities[sensor.airgradient_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'original_icon': None,
'original_name': 'Humidity',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '84fce612f5b8-humidity',
'unit_of_measurement': '%',
})
# ---
# name: test_all_entities[sensor.airgradient_humidity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Airgradient Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '48.0',
})
# ---
# name: test_all_entities[sensor.airgradient_nitrogen_index-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_nitrogen_index',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Nitrogen index',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'nitrogen_index',
'unique_id': '84fce612f5b8-nitrogen_index',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.airgradient_nitrogen_index-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient Nitrogen index',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_nitrogen_index',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_all_entities[sensor.airgradient_pm0_3_count-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_pm0_3_count',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'PM0.3 count',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'pm003_count',
'unique_id': '84fce612f5b8-pm003',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.airgradient_pm0_3_count-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient PM0.3 count',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_pm0_3_count',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '270',
})
# ---
# name: test_all_entities[sensor.airgradient_pm1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_pm1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM1: 'pm1'>,
'original_icon': None,
'original_name': 'PM1',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '84fce612f5b8-pm01',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.airgradient_pm1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm1',
'friendly_name': 'Airgradient PM1',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_pm1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '22',
})
# ---
# name: test_all_entities[sensor.airgradient_pm10-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_pm10',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM10: 'pm10'>,
'original_icon': None,
'original_name': 'PM10',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '84fce612f5b8-pm10',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.airgradient_pm10-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm10',
'friendly_name': 'Airgradient PM10',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_pm10',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '41',
})
# ---
# name: test_all_entities[sensor.airgradient_pm2_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_pm2_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM25: 'pm25'>,
'original_icon': None,
'original_name': 'PM2.5',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '84fce612f5b8-pm02',
'unit_of_measurement': 'µg/m³',
})
# ---
# name: test_all_entities[sensor.airgradient_pm2_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm25',
'friendly_name': 'Airgradient PM2.5',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_pm2_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '34',
})
# ---
# name: test_all_entities[sensor.airgradient_raw_nitrogen-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_raw_nitrogen',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Raw nitrogen',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'raw_nitrogen',
'unique_id': '84fce612f5b8-nox_raw',
'unit_of_measurement': 'ticks',
})
# ---
# name: test_all_entities[sensor.airgradient_raw_nitrogen-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient Raw nitrogen',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ticks',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_raw_nitrogen',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16931',
})
# ---
# name: test_all_entities[sensor.airgradient_raw_total_voc-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_raw_total_voc',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Raw total VOC',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'raw_total_volatile_organic_component',
'unique_id': '84fce612f5b8-tvoc_raw',
'unit_of_measurement': 'ticks',
})
# ---
# name: test_all_entities[sensor.airgradient_raw_total_voc-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient Raw total VOC',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ticks',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_raw_total_voc',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '31792',
})
# ---
# name: test_all_entities[sensor.airgradient_signal_strength-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.airgradient_signal_strength',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Signal strength',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '84fce612f5b8-signal_strength',
'unit_of_measurement': 'dBm',
})
# ---
# name: test_all_entities[sensor.airgradient_signal_strength-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': 'Airgradient Signal strength',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_signal_strength',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-52',
})
# ---
# name: test_all_entities[sensor.airgradient_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '84fce612f5b8-temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.airgradient_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Airgradient Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '27.96',
})
# ---
# name: test_all_entities[sensor.airgradient_total_voc_index-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.airgradient_total_voc_index',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Total VOC index',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'total_volatile_organic_component_index',
'unique_id': '84fce612f5b8-tvoc',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.airgradient_total_voc_index-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient Total VOC index',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.airgradient_total_voc_index',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '99',
})
# ---

View File

@ -0,0 +1,149 @@
"""Tests for the AirGradient config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock
from airgradient import AirGradientConnectionError
from homeassistant.components.airgradient import DOMAIN
from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("10.0.0.131"),
ip_addresses=[ip_address("10.0.0.131")],
hostname="airgradient_84fce612f5b8.local.",
name="airgradient_84fce612f5b8._airgradient._tcp.local.",
port=80,
type="_airgradient._tcp.local.",
properties={
"vendor": "AirGradient",
"fw_ver": "3.0.8",
"serialno": "84fce612f5b8",
"model": "I-9PSL",
},
)
async def test_full_flow(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "I-9PSL"
assert result["data"] == {
CONF_HOST: "10.0.0.131",
}
assert result["result"].unique_id == "84fce612f5b8"
async def test_flow_errors(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test flow errors."""
mock_airgradient_client.get_current_measures.side_effect = (
AirGradientConnectionError()
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
mock_airgradient_client.get_current_measures.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_duplicate(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test duplicate flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_zeroconf_flow(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test zeroconf flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "I-9PSL"
assert result["data"] == {
CONF_HOST: "10.0.0.131",
}
assert result["result"].unique_id == "84fce612f5b8"

View File

@ -0,0 +1,28 @@
"""Tests for the AirGradient integration."""
from unittest.mock import AsyncMock
from syrupy import SnapshotAssertion
from homeassistant.components.airgradient import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
from tests.components.airgradient import setup_integration
async def test_device_info(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device registry integration."""
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, mock_config_entry.unique_id)}
)
assert device_entry is not None
assert device_entry == snapshot

View File

@ -0,0 +1,76 @@
"""Tests for the AirGradient sensor platform."""
from datetime import timedelta
from unittest.mock import AsyncMock
from airgradient import AirGradientError, Measures
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.airgradient import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_fixture,
snapshot_platform,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_create_entities(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test creating entities."""
mock_airgradient_client.get_current_measures.return_value = Measures.from_json(
load_fixture("measures_after_boot.json", DOMAIN)
)
await setup_integration(hass, mock_config_entry)
assert len(hass.states.async_all()) == 0
mock_airgradient_client.get_current_measures.return_value = Measures.from_json(
load_fixture("current_measures.json", DOMAIN)
)
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 9
async def test_connection_error(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test connection error."""
await setup_integration(hass, mock_config_entry)
mock_airgradient_client.get_current_measures.side_effect = AirGradientError()
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("sensor.airgradient_humidity").state == STATE_UNAVAILABLE