mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Add integration for AirVisual Pro (#79770)
* Add integration for AirVisual Pro * Tests * A few more redactions * Loggers * Consistency * Remove unnecessary f-string * Use `entry.as_dict()` in diagnostics * One call * Integration types * Cleanup * Import cleanup * Code review * Code review * Code review
This commit is contained in:
@ -46,6 +46,8 @@ omit =
|
||||
homeassistant/components/airtouch4/const.py
|
||||
homeassistant/components/airvisual/__init__.py
|
||||
homeassistant/components/airvisual/sensor.py
|
||||
homeassistant/components/airvisual_pro/__init__.py
|
||||
homeassistant/components/airvisual_pro/sensor.py
|
||||
homeassistant/components/alarmdecoder/__init__.py
|
||||
homeassistant/components/alarmdecoder/alarm_control_panel.py
|
||||
homeassistant/components/alarmdecoder/binary_sensor.py
|
||||
|
@ -55,6 +55,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/airtouch4/ @LonePurpleWolf
|
||||
/homeassistant/components/airvisual/ @bachya
|
||||
/tests/components/airvisual/ @bachya
|
||||
/homeassistant/components/airvisual_pro/ @bachya
|
||||
/tests/components/airvisual_pro/ @bachya
|
||||
/homeassistant/components/airzone/ @Noltari
|
||||
/tests/components/airzone/ @Noltari
|
||||
/homeassistant/components/aladdin_connect/ @mkmer
|
||||
|
5
homeassistant/brands/airvisual.json
Normal file
5
homeassistant/brands/airvisual.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "airvisual",
|
||||
"name": "AirVisual",
|
||||
"integrations": ["airvisual", "airvisual_pro"]
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
{
|
||||
"domain": "airvisual",
|
||||
"name": "AirVisual",
|
||||
"name": "AirVisual Cloud",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airvisual",
|
||||
"requirements": ["pyairvisual==2022.11.1"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyairvisual", "pysmb"],
|
||||
"integration_type": "device"
|
||||
"integration_type": "service"
|
||||
}
|
||||
|
102
homeassistant/components/airvisual_pro/__init__.py
Normal file
102
homeassistant/components/airvisual_pro/__init__.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""The AirVisual Pro integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual import NodeSamba
|
||||
from pyairvisual.node import NodeProError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
UPDATE_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up AirVisual Pro from a config entry."""
|
||||
|
||||
async def async_get_data() -> dict[str, Any]:
|
||||
"""Get data from the device."""
|
||||
try:
|
||||
async with NodeSamba(
|
||||
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD]
|
||||
) as node:
|
||||
return await node.async_get_latest_measurements()
|
||||
except NodeProError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
name="Node/Pro data",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
update_method=async_get_data,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
class AirVisualProEntity(CoordinatorEntity):
|
||||
"""Define a generic AirVisual Pro entity."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: DataUpdateCoordinator, description: EntityDescription
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.coordinator.data["serial_number"])},
|
||||
manufacturer="AirVisual",
|
||||
model=self.coordinator.data["status"]["model"],
|
||||
name=self.coordinator.data["settings"]["node_name"],
|
||||
hw_version=self.coordinator.data["status"]["system_version"],
|
||||
sw_version=self.coordinator.data["status"]["app_version"],
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Update the entity's underlying data."""
|
||||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Respond to a DataUpdateCoordinator update."""
|
||||
self._async_update_from_latest_data()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
self._async_update_from_latest_data()
|
66
homeassistant/components/airvisual_pro/config_flow.py
Normal file
66
homeassistant/components/airvisual_pro/config_flow.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Define a config flow manager for AirVisual Pro."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pyairvisual import NodeSamba
|
||||
from pyairvisual.node import NodeProError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
STEP_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IP_ADDRESS): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AirVisualProFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle an AirVisual Pro config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
if not user_input:
|
||||
return self.async_show_form(step_id="user", data_schema=STEP_USER_SCHEMA)
|
||||
|
||||
await self.async_set_unique_id(user_input[CONF_IP_ADDRESS])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
errors = {}
|
||||
node = NodeSamba(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD])
|
||||
|
||||
try:
|
||||
await node.async_connect()
|
||||
except NodeProError as err:
|
||||
LOGGER.error(
|
||||
"Samba error while connecting to %s: %s",
|
||||
user_input[CONF_IP_ADDRESS],
|
||||
err,
|
||||
)
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
LOGGER.error(
|
||||
"Unknown error while connecting to %s: %s",
|
||||
user_input[CONF_IP_ADDRESS],
|
||||
err,
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
finally:
|
||||
await node.async_disconnect()
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_IP_ADDRESS], data=user_input
|
||||
)
|
6
homeassistant/components/airvisual_pro/const.py
Normal file
6
homeassistant/components/airvisual_pro/const.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Constants for the AirVisual Pro integration."""
|
||||
import logging
|
||||
|
||||
DOMAIN = "airvisual_pro"
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
36
homeassistant/components/airvisual_pro/diagnostics.py
Normal file
36
homeassistant/components/airvisual_pro/diagnostics.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""Support for AirVisual Pro diagnostics."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
CONF_MAC_ADDRESS = "mac_address"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_MAC_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
CONF_SERIAL_NUMBER,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
return async_redact_data(
|
||||
{
|
||||
"entry": entry.as_dict(),
|
||||
"data": coordinator.data,
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
11
homeassistant/components/airvisual_pro/manifest.json
Normal file
11
homeassistant/components/airvisual_pro/manifest.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "airvisual_pro",
|
||||
"name": "AirVisual Pro",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airvisual_pro",
|
||||
"requirements": ["pyairvisual==2022.11.1"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairvisual", "pysmb"],
|
||||
"integration_type": "device"
|
||||
}
|
165
homeassistant/components/airvisual_pro/sensor.py
Normal file
165
homeassistant/components/airvisual_pro/sensor.py
Normal file
@ -0,0 +1,165 @@
|
||||
"""Support for AirVisual Pro sensors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
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,
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import AirVisualProEntity
|
||||
from .const import DOMAIN
|
||||
|
||||
SENSOR_KIND_AQI = "air_quality_index"
|
||||
SENSOR_KIND_BATTERY_LEVEL = "battery_level"
|
||||
SENSOR_KIND_CO2 = "carbon_dioxide"
|
||||
SENSOR_KIND_HUMIDITY = "humidity"
|
||||
SENSOR_KIND_PM_0_1 = "particulate_matter_0_1"
|
||||
SENSOR_KIND_PM_1_0 = "particulate_matter_1_0"
|
||||
SENSOR_KIND_PM_2_5 = "particulate_matter_2_5"
|
||||
SENSOR_KIND_SENSOR_LIFE = "sensor_life"
|
||||
SENSOR_KIND_TEMPERATURE = "temperature"
|
||||
SENSOR_KIND_VOC = "voc"
|
||||
|
||||
SENSOR_DESCRIPTIONS = (
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_AQI,
|
||||
name="Air quality index",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
native_unit_of_measurement="AQI",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_BATTERY_LEVEL,
|
||||
name="Battery",
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_CO2,
|
||||
name="C02",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_HUMIDITY,
|
||||
name="Humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_PM_0_1,
|
||||
name="PM 0.1",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_PM_1_0,
|
||||
name="PM 1.0",
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_PM_2_5,
|
||||
name="PM 2.5",
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_TEMPERATURE,
|
||||
name="Temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=TEMP_CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=SENSOR_KIND_VOC,
|
||||
name="VOC",
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_aqi_locale(settings: dict[str, Any]) -> str:
|
||||
"""Return the correct AQI locale based on settings data."""
|
||||
if settings["is_aqi_usa"]:
|
||||
return "aqi_us"
|
||||
return "aqi_cn"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up AirVisual sensors based on a config entry."""
|
||||
coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
async_add_entities(
|
||||
AirVisualProSensor(coordinator, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class AirVisualProSensor(AirVisualProEntity, SensorEntity):
|
||||
"""Define an AirVisual Pro sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
MEASUREMENTS_KEY_TO_VALUE = {
|
||||
SENSOR_KIND_CO2: "co2",
|
||||
SENSOR_KIND_HUMIDITY: "humidity",
|
||||
SENSOR_KIND_PM_0_1: "pm0_1",
|
||||
SENSOR_KIND_PM_1_0: "pm1_0",
|
||||
SENSOR_KIND_PM_2_5: "pm2_5",
|
||||
SENSOR_KIND_TEMPERATURE: "temperature_C",
|
||||
SENSOR_KIND_VOC: "voc",
|
||||
}
|
||||
|
||||
@property
|
||||
def measurements(self) -> dict[str, Any]:
|
||||
"""Define measurements data."""
|
||||
return self.coordinator.data["measurements"]
|
||||
|
||||
@property
|
||||
def settings(self) -> dict[str, Any]:
|
||||
"""Define settings data."""
|
||||
return self.coordinator.data["settings"]
|
||||
|
||||
@property
|
||||
def status(self) -> dict[str, Any]:
|
||||
"""Define status data."""
|
||||
return self.coordinator.data["status"]
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Update the entity from the latest data."""
|
||||
if self.entity_description.key == SENSOR_KIND_AQI:
|
||||
locale = async_get_aqi_locale(self.settings)
|
||||
self._attr_native_value = self.measurements[locale]
|
||||
elif self.entity_description.key == SENSOR_KIND_BATTERY_LEVEL:
|
||||
self._attr_native_value = self.status["battery"]
|
||||
else:
|
||||
self._attr_native_value = self.MEASUREMENTS_KEY_TO_VALUE[
|
||||
self.entity_description.key
|
||||
]
|
20
homeassistant/components/airvisual_pro/strings.json
Normal file
20
homeassistant/components/airvisual_pro/strings.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "The password can be retrieved from the AirVisual Pro's UI.",
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
}
|
||||
}
|
20
homeassistant/components/airvisual_pro/translations/en.json
Normal file
20
homeassistant/components/airvisual_pro/translations/en.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "Host",
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "The password can be retrieved from the AirVisual Pro's UI."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -30,6 +30,7 @@ FLOWS = {
|
||||
"airthings_ble",
|
||||
"airtouch4",
|
||||
"airvisual",
|
||||
"airvisual_pro",
|
||||
"airzone",
|
||||
"aladdin_connect",
|
||||
"alarmdecoder",
|
||||
|
@ -120,9 +120,20 @@
|
||||
},
|
||||
"airvisual": {
|
||||
"name": "AirVisual",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
"integrations": {
|
||||
"airvisual": {
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"name": "AirVisual Cloud"
|
||||
},
|
||||
"airvisual_pro": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "AirVisual Pro"
|
||||
}
|
||||
}
|
||||
},
|
||||
"airzone": {
|
||||
"name": "Airzone",
|
||||
|
@ -1460,6 +1460,7 @@ pyaftership==21.11.0
|
||||
pyairnow==1.1.0
|
||||
|
||||
# homeassistant.components.airvisual
|
||||
# homeassistant.components.airvisual_pro
|
||||
pyairvisual==2022.11.1
|
||||
|
||||
# homeassistant.components.almond
|
||||
|
@ -1048,6 +1048,7 @@ pyaehw4a1==0.3.9
|
||||
pyairnow==1.1.0
|
||||
|
||||
# homeassistant.components.airvisual
|
||||
# homeassistant.components.airvisual_pro
|
||||
pyairvisual==2022.11.1
|
||||
|
||||
# homeassistant.components.almond
|
||||
|
1
tests/components/airvisual_pro/__init__.py
Normal file
1
tests/components/airvisual_pro/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the AirVisual Pro integration."""
|
56
tests/components/airvisual_pro/conftest.py
Normal file
56
tests/components/airvisual_pro/conftest.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Define test fixtures for AirVisual Pro."""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.airvisual_pro.const import DOMAIN
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def config_entry_fixture(hass, config, unique_id):
|
||||
"""Define a config entry fixture."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id, data=config)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture(name="config")
|
||||
def config_fixture(hass):
|
||||
"""Define a config entry data fixture."""
|
||||
return {
|
||||
CONF_IP_ADDRESS: "192.168.1.101",
|
||||
CONF_PASSWORD: "password123",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="data", scope="session")
|
||||
def data_fixture():
|
||||
"""Define an update coordinator data example."""
|
||||
return json.loads(load_fixture("data.json", "airvisual_pro"))
|
||||
|
||||
|
||||
@pytest.fixture(name="setup_airvisual_pro")
|
||||
async def setup_airvisual_pro_fixture(hass, config, data):
|
||||
"""Define a fixture to set up AirVisual Pro."""
|
||||
with patch("homeassistant.components.airvisual_pro.NodeSamba.async_connect"), patch(
|
||||
"homeassistant.components.airvisual_pro.NodeSamba.async_get_latest_measurements",
|
||||
return_value=data,
|
||||
), patch(
|
||||
"homeassistant.components.airvisual_pro.NodeSamba.async_disconnect"
|
||||
), patch(
|
||||
"homeassistant.components.airvisual.PLATFORMS", []
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="unique_id")
|
||||
def unique_id_fixture(hass):
|
||||
"""Define a config entry unique ID fixture."""
|
||||
return "192.168.1.101"
|
1
tests/components/airvisual_pro/fixtures/__init__.py
Normal file
1
tests/components/airvisual_pro/fixtures/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Define data fixtures for AirVisual Pro."""
|
65
tests/components/airvisual_pro/fixtures/data.json
Normal file
65
tests/components/airvisual_pro/fixtures/data.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"date_and_time": {
|
||||
"date": "2022/10/06",
|
||||
"time": "16:00:44",
|
||||
"timestamp": "1665072044"
|
||||
},
|
||||
"measurements": {
|
||||
"co2": "472",
|
||||
"humidity": "57",
|
||||
"pm0_1": "0",
|
||||
"pm1_0": "0",
|
||||
"aqi_cn": "0",
|
||||
"aqi_us": "0",
|
||||
"pm2_5": "0",
|
||||
"temperature_C": "23.0",
|
||||
"temperature_F": "73.4",
|
||||
"voc": "-1"
|
||||
},
|
||||
"serial_number": "XXXXXXX",
|
||||
"settings": {
|
||||
"follow_mode": "station",
|
||||
"followed_station": "0",
|
||||
"is_aqi_usa": true,
|
||||
"is_concentration_showed": true,
|
||||
"is_indoor": true,
|
||||
"is_lcd_on": true,
|
||||
"is_network_time": true,
|
||||
"is_temperature_celsius": false,
|
||||
"language": "en-US",
|
||||
"lcd_brightness": 80,
|
||||
"node_name": "Office",
|
||||
"power_saving": {
|
||||
"2slots": [
|
||||
{ "hour_off": 9, "hour_on": 7 },
|
||||
{ "hour_off": 22, "hour_on": 18 }
|
||||
],
|
||||
"mode": "yes",
|
||||
"running_time": 99,
|
||||
"yes": [
|
||||
{ "hour": 8, "minute": 0 },
|
||||
{ "hour": 21, "minute": 0 }
|
||||
]
|
||||
},
|
||||
"sensor_mode": { "custom_mode_interval": 3, "mode": 1 },
|
||||
"speed_unit": "mph",
|
||||
"timezone": "America/New York",
|
||||
"tvoc_unit": "ppb"
|
||||
},
|
||||
"status": {
|
||||
"app_version": "1.1826",
|
||||
"battery": 100,
|
||||
"datetime": 1665072044,
|
||||
"device_name": "AIRVISUAL-XXXXXXX",
|
||||
"ip_address": "192.168.1.101",
|
||||
"mac_address": "1234567890ab",
|
||||
"model": 20,
|
||||
"sensor_life": { "pm2_5": 1567924345130 },
|
||||
"sensor_pm25_serial": "00000005050224011145",
|
||||
"sync_time": 250000,
|
||||
"system_version": "KBG63F84",
|
||||
"used_memory": 3,
|
||||
"wifi_strength": 4
|
||||
},
|
||||
"last_measurement_timestamp": 1665072044
|
||||
}
|
70
tests/components/airvisual_pro/test_config_flow.py
Normal file
70
tests/components/airvisual_pro/test_config_flow.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Test the AirVisual Pro config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyairvisual.node import NodeProError
|
||||
import pytest
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.airvisual_pro.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
|
||||
|
||||
async def test_duplicate_error(hass, config, config_entry):
|
||||
"""Test that errors are shown when duplicates are added."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=config
|
||||
)
|
||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc,errors",
|
||||
[
|
||||
(NodeProError, {"base": "cannot_connect"}),
|
||||
(Exception, {"base": "unknown"}),
|
||||
],
|
||||
)
|
||||
async def test_errors(hass, config, exc, errors, setup_airvisual_pro):
|
||||
"""Test that an exceptions show an error."""
|
||||
with patch(
|
||||
"homeassistant.components.airvisual_pro.config_flow.NodeSamba.async_connect",
|
||||
side_effect=exc,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}, data=config
|
||||
)
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"] == errors
|
||||
|
||||
# Validate that we can still proceed after an error if the underlying condition
|
||||
# resolves:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=config
|
||||
)
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "192.168.1.101"
|
||||
assert result["data"] == {
|
||||
CONF_IP_ADDRESS: "192.168.1.101",
|
||||
CONF_PASSWORD: "password123",
|
||||
}
|
||||
|
||||
|
||||
async def test_step_user(hass, config, setup_airvisual_pro):
|
||||
"""Test that the user step works."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=config
|
||||
)
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "192.168.1.101"
|
||||
assert result["data"] == {
|
||||
CONF_IP_ADDRESS: "192.168.1.101",
|
||||
CONF_PASSWORD: "password123",
|
||||
}
|
85
tests/components/airvisual_pro/test_diagnostics.py
Normal file
85
tests/components/airvisual_pro/test_diagnostics.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""Test AirVisual Pro diagnostics."""
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
|
||||
|
||||
async def test_entry_diagnostics(hass, config_entry, hass_client, setup_airvisual_pro):
|
||||
"""Test config entry diagnostics."""
|
||||
assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {
|
||||
"entry": {
|
||||
"entry_id": config_entry.entry_id,
|
||||
"version": 1,
|
||||
"domain": "airvisual_pro",
|
||||
"title": "Mock Title",
|
||||
"data": {"ip_address": "192.168.1.101", "password": REDACTED},
|
||||
"options": {},
|
||||
"pref_disable_new_entities": False,
|
||||
"pref_disable_polling": False,
|
||||
"source": "user",
|
||||
"unique_id": "192.168.1.101",
|
||||
"disabled_by": None,
|
||||
},
|
||||
"data": {
|
||||
"date_and_time": {
|
||||
"date": "2022/10/06",
|
||||
"time": "16:00:44",
|
||||
"timestamp": "1665072044",
|
||||
},
|
||||
"measurements": {
|
||||
"co2": "472",
|
||||
"humidity": "57",
|
||||
"pm0_1": "0",
|
||||
"pm1_0": "0",
|
||||
"aqi_cn": "0",
|
||||
"aqi_us": "0",
|
||||
"pm2_5": "0",
|
||||
"temperature_C": "23.0",
|
||||
"temperature_F": "73.4",
|
||||
"voc": "-1",
|
||||
},
|
||||
"serial_number": REDACTED,
|
||||
"settings": {
|
||||
"follow_mode": "station",
|
||||
"followed_station": "0",
|
||||
"is_aqi_usa": True,
|
||||
"is_concentration_showed": True,
|
||||
"is_indoor": True,
|
||||
"is_lcd_on": True,
|
||||
"is_network_time": True,
|
||||
"is_temperature_celsius": False,
|
||||
"language": "en-US",
|
||||
"lcd_brightness": 80,
|
||||
"node_name": "Office",
|
||||
"power_saving": {
|
||||
"2slots": [
|
||||
{"hour_off": 9, "hour_on": 7},
|
||||
{"hour_off": 22, "hour_on": 18},
|
||||
],
|
||||
"mode": "yes",
|
||||
"running_time": 99,
|
||||
"yes": [{"hour": 8, "minute": 0}, {"hour": 21, "minute": 0}],
|
||||
},
|
||||
"sensor_mode": {"custom_mode_interval": 3, "mode": 1},
|
||||
"speed_unit": "mph",
|
||||
"timezone": "America/New York",
|
||||
"tvoc_unit": "ppb",
|
||||
},
|
||||
"status": {
|
||||
"app_version": "1.1826",
|
||||
"battery": 100,
|
||||
"datetime": 1665072044,
|
||||
"device_name": "AIRVISUAL-XXXXXXX",
|
||||
"ip_address": "192.168.1.101",
|
||||
"mac_address": REDACTED,
|
||||
"model": 20,
|
||||
"sensor_life": {"pm2_5": 1567924345130},
|
||||
"sensor_pm25_serial": "00000005050224011145",
|
||||
"sync_time": 250000,
|
||||
"system_version": "KBG63F84",
|
||||
"used_memory": 3,
|
||||
"wifi_strength": 4,
|
||||
},
|
||||
"last_measurement_timestamp": 1665072044,
|
||||
},
|
||||
}
|
Reference in New Issue
Block a user