From 3d4ee5906da9f0537fff68c5c01bfce5c0bde771 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 12 Dec 2022 14:28:27 -0700 Subject: [PATCH] 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 --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/brands/airvisual.json | 5 + .../components/airvisual/manifest.json | 4 +- .../components/airvisual_pro/__init__.py | 102 +++++++++++ .../components/airvisual_pro/config_flow.py | 66 +++++++ .../components/airvisual_pro/const.py | 6 + .../components/airvisual_pro/diagnostics.py | 36 ++++ .../components/airvisual_pro/manifest.json | 11 ++ .../components/airvisual_pro/sensor.py | 165 ++++++++++++++++++ .../components/airvisual_pro/strings.json | 20 +++ .../airvisual_pro/translations/en.json | 20 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 +- requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/airvisual_pro/__init__.py | 1 + tests/components/airvisual_pro/conftest.py | 56 ++++++ .../airvisual_pro/fixtures/__init__.py | 1 + .../airvisual_pro/fixtures/data.json | 65 +++++++ .../airvisual_pro/test_config_flow.py | 70 ++++++++ .../airvisual_pro/test_diagnostics.py | 85 +++++++++ 22 files changed, 732 insertions(+), 5 deletions(-) create mode 100644 homeassistant/brands/airvisual.json create mode 100644 homeassistant/components/airvisual_pro/__init__.py create mode 100644 homeassistant/components/airvisual_pro/config_flow.py create mode 100644 homeassistant/components/airvisual_pro/const.py create mode 100644 homeassistant/components/airvisual_pro/diagnostics.py create mode 100644 homeassistant/components/airvisual_pro/manifest.json create mode 100644 homeassistant/components/airvisual_pro/sensor.py create mode 100644 homeassistant/components/airvisual_pro/strings.json create mode 100644 homeassistant/components/airvisual_pro/translations/en.json create mode 100644 tests/components/airvisual_pro/__init__.py create mode 100644 tests/components/airvisual_pro/conftest.py create mode 100644 tests/components/airvisual_pro/fixtures/__init__.py create mode 100644 tests/components/airvisual_pro/fixtures/data.json create mode 100644 tests/components/airvisual_pro/test_config_flow.py create mode 100644 tests/components/airvisual_pro/test_diagnostics.py diff --git a/.coveragerc b/.coveragerc index cec757853d7..ee82681f0fe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 6ef5133b938..6897bbba741 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/brands/airvisual.json b/homeassistant/brands/airvisual.json new file mode 100644 index 00000000000..2f9e7588a77 --- /dev/null +++ b/homeassistant/brands/airvisual.json @@ -0,0 +1,5 @@ +{ + "domain": "airvisual", + "name": "AirVisual", + "integrations": ["airvisual", "airvisual_pro"] +} diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index ae9eeb270a8..b9823be2168 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -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" } diff --git a/homeassistant/components/airvisual_pro/__init__.py b/homeassistant/components/airvisual_pro/__init__.py new file mode 100644 index 00000000000..dace986db5b --- /dev/null +++ b/homeassistant/components/airvisual_pro/__init__.py @@ -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() diff --git a/homeassistant/components/airvisual_pro/config_flow.py b/homeassistant/components/airvisual_pro/config_flow.py new file mode 100644 index 00000000000..85e03eec504 --- /dev/null +++ b/homeassistant/components/airvisual_pro/config_flow.py @@ -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 + ) diff --git a/homeassistant/components/airvisual_pro/const.py b/homeassistant/components/airvisual_pro/const.py new file mode 100644 index 00000000000..83a6cc5739c --- /dev/null +++ b/homeassistant/components/airvisual_pro/const.py @@ -0,0 +1,6 @@ +"""Constants for the AirVisual Pro integration.""" +import logging + +DOMAIN = "airvisual_pro" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/airvisual_pro/diagnostics.py b/homeassistant/components/airvisual_pro/diagnostics.py new file mode 100644 index 00000000000..16759c97580 --- /dev/null +++ b/homeassistant/components/airvisual_pro/diagnostics.py @@ -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, + ) diff --git a/homeassistant/components/airvisual_pro/manifest.json b/homeassistant/components/airvisual_pro/manifest.json new file mode 100644 index 00000000000..6e47f1ebf9b --- /dev/null +++ b/homeassistant/components/airvisual_pro/manifest.json @@ -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" +} diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py new file mode 100644 index 00000000000..98f14c79c4c --- /dev/null +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -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 + ] diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json new file mode 100644 index 00000000000..2349b7cb69f --- /dev/null +++ b/homeassistant/components/airvisual_pro/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/airvisual_pro/translations/en.json b/homeassistant/components/airvisual_pro/translations/en.json new file mode 100644 index 00000000000..ac54d4d2f09 --- /dev/null +++ b/homeassistant/components/airvisual_pro/translations/en.json @@ -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." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3955d695c9e..ef22ba649fa 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -30,6 +30,7 @@ FLOWS = { "airthings_ble", "airtouch4", "airvisual", + "airvisual_pro", "airzone", "aladdin_connect", "alarmdecoder", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f3187dc502c..250c1d515e7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index 31337646760..49707e1b689 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 896edc87966..63ae147cd5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/airvisual_pro/__init__.py b/tests/components/airvisual_pro/__init__.py new file mode 100644 index 00000000000..7fe3b734343 --- /dev/null +++ b/tests/components/airvisual_pro/__init__.py @@ -0,0 +1 @@ +"""Tests for the AirVisual Pro integration.""" diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py new file mode 100644 index 00000000000..86fbdc89224 --- /dev/null +++ b/tests/components/airvisual_pro/conftest.py @@ -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" diff --git a/tests/components/airvisual_pro/fixtures/__init__.py b/tests/components/airvisual_pro/fixtures/__init__.py new file mode 100644 index 00000000000..526edd09229 --- /dev/null +++ b/tests/components/airvisual_pro/fixtures/__init__.py @@ -0,0 +1 @@ +"""Define data fixtures for AirVisual Pro.""" diff --git a/tests/components/airvisual_pro/fixtures/data.json b/tests/components/airvisual_pro/fixtures/data.json new file mode 100644 index 00000000000..1865542488b --- /dev/null +++ b/tests/components/airvisual_pro/fixtures/data.json @@ -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 +} diff --git a/tests/components/airvisual_pro/test_config_flow.py b/tests/components/airvisual_pro/test_config_flow.py new file mode 100644 index 00000000000..f9114c29868 --- /dev/null +++ b/tests/components/airvisual_pro/test_config_flow.py @@ -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", + } diff --git a/tests/components/airvisual_pro/test_diagnostics.py b/tests/components/airvisual_pro/test_diagnostics.py new file mode 100644 index 00000000000..ab780b90704 --- /dev/null +++ b/tests/components/airvisual_pro/test_diagnostics.py @@ -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, + }, + }