From 0587f834dfb81889e7a72af0a5753a63d48a485a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 7 May 2021 15:59:29 +0200 Subject: [PATCH] Add Nettigo Air Monitor integration (#49099) --- .strict-typing | 1 + CODEOWNERS | 1 + homeassistant/components/nam/__init__.py | 106 ++++++++ homeassistant/components/nam/air_quality.py | 94 +++++++ homeassistant/components/nam/config_flow.py | 121 +++++++++ homeassistant/components/nam/const.py | 130 ++++++++++ homeassistant/components/nam/manifest.json | 11 + homeassistant/components/nam/model.py | 14 ++ homeassistant/components/nam/sensor.py | 94 +++++++ homeassistant/components/nam/strings.json | 24 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 4 + mypy.ini | 13 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nam/__init__.py | 60 +++++ tests/components/nam/test_air_quality.py | 148 +++++++++++ tests/components/nam/test_config_flow.py | 175 +++++++++++++ tests/components/nam/test_init.py | 57 +++++ tests/components/nam/test_sensor.py | 266 ++++++++++++++++++++ 20 files changed, 1326 insertions(+) create mode 100644 homeassistant/components/nam/__init__.py create mode 100644 homeassistant/components/nam/air_quality.py create mode 100644 homeassistant/components/nam/config_flow.py create mode 100644 homeassistant/components/nam/const.py create mode 100644 homeassistant/components/nam/manifest.json create mode 100644 homeassistant/components/nam/model.py create mode 100644 homeassistant/components/nam/sensor.py create mode 100644 homeassistant/components/nam/strings.json create mode 100644 tests/components/nam/__init__.py create mode 100644 tests/components/nam/test_air_quality.py create mode 100644 tests/components/nam/test_config_flow.py create mode 100644 tests/components/nam/test_init.py create mode 100644 tests/components/nam/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 1838866aadd..ede89de467f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -25,6 +25,7 @@ homeassistant.components.light.* homeassistant.components.lock.* homeassistant.components.mailbox.* homeassistant.components.media_player.* +homeassistant.components.nam.* homeassistant.components.notify.* homeassistant.components.number.* homeassistant.components.persistent_notification.* diff --git a/CODEOWNERS b/CODEOWNERS index f241832fb4e..eb46da1353d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -308,6 +308,7 @@ homeassistant/components/my/* @home-assistant/core homeassistant/components/myq/* @bdraco homeassistant/components/mysensors/* @MartinHjelmare @functionpointer homeassistant/components/mystrom/* @fabaff +homeassistant/components/nam/* @bieniu homeassistant/components/neato/* @dshokouhi @Santobert homeassistant/components/nederlandse_spoorwegen/* @YarmoM homeassistant/components/nello/* @pschmitt diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py new file mode 100644 index 00000000000..04458967bed --- /dev/null +++ b/homeassistant/components/nam/__init__.py @@ -0,0 +1,106 @@ +"""The Nettigo Air Monitor component.""" +from __future__ import annotations + +import logging +from typing import cast + +from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientConnectorError +import async_timeout +from nettigo_air_monitor import ( + ApiError, + DictToObj, + InvalidSensorData, + NettigoAirMonitor, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_NAME, DEFAULT_UPDATE_INTERVAL, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["air_quality", "sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Nettigo as config entry.""" + host = entry.data[CONF_HOST] + + websession = async_get_clientsession(hass) + + coordinator = NAMDataUpdateCoordinator(hass, websession, host, entry.unique_id) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class NAMDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Nettigo Air Monitor data.""" + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + host: str, + unique_id: str | None, + ) -> None: + """Initialize.""" + self.host = host + self.nam = NettigoAirMonitor(session, host) + self._unique_id = unique_id + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL + ) + + async def _async_update_data(self) -> DictToObj: + """Update data via library.""" + try: + # Device firmware uses synchronous code and doesn't respond to http queries + # when reading data from sensors. The nettigo-air-quality library tries to + # get the data 4 times, so we use a longer than usual timeout here. + with async_timeout.timeout(30): + data = await self.nam.async_update() + except (ApiError, ClientConnectorError, InvalidSensorData) as error: + raise UpdateFailed(error) from error + + _LOGGER.debug(data) + + return data + + @property + def unique_id(self) -> str | None: + """Return a unique_id.""" + return self._unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, cast(str, self._unique_id))}, + "name": DEFAULT_NAME, + "sw_version": self.nam.software_version, + "manufacturer": MANUFACTURER, + } diff --git a/homeassistant/components/nam/air_quality.py b/homeassistant/components/nam/air_quality.py new file mode 100644 index 00000000000..7823ffb110e --- /dev/null +++ b/homeassistant/components/nam/air_quality.py @@ -0,0 +1,94 @@ +"""Support for the Nettigo Air Monitor air_quality service.""" +from __future__ import annotations + +from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import NAMDataUpdateCoordinator +from .const import AIR_QUALITY_SENSORS, DEFAULT_NAME, DOMAIN, SUFFIX_P1, SUFFIX_P2 + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add a Nettigo Air Monitor entities from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [] + for sensor in AIR_QUALITY_SENSORS: + if f"{sensor}{SUFFIX_P1}" in coordinator.data: + entities.append(NAMAirQuality(coordinator, sensor)) + + async_add_entities(entities, False) + + +class NAMAirQuality(CoordinatorEntity, AirQualityEntity): + """Define an Nettigo Air Monitor air quality.""" + + coordinator: NAMDataUpdateCoordinator + + def __init__(self, coordinator: NAMDataUpdateCoordinator, sensor_type: str) -> None: + """Initialize.""" + super().__init__(coordinator) + self.sensor_type = sensor_type + + @property + def name(self) -> str: + """Return the name.""" + return f"{DEFAULT_NAME} {AIR_QUALITY_SENSORS[self.sensor_type]}" + + @property + def particulate_matter_2_5(self) -> StateType: + """Return the particulate matter 2.5 level.""" + return round_state( + getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P2}") + ) + + @property + def particulate_matter_10(self) -> StateType: + """Return the particulate matter 10 level.""" + return round_state( + getattr(self.coordinator.data, f"{self.sensor_type}{SUFFIX_P1}") + ) + + @property + def carbon_dioxide(self) -> StateType: + """Return the particulate matter 10 level.""" + return round_state(getattr(self.coordinator.data, "conc_co2_ppm", None)) + + @property + def unique_id(self) -> str: + """Return a unique_id for this entity.""" + return f"{self.coordinator.unique_id}-{self.sensor_type}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return self.coordinator.device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + available = super().available + + # For a short time after booting, the device does not return values for all + # sensors. For this reason, we mark entities for which data is missing as + # unavailable. + return available and bool( + getattr(self.coordinator.data, f"{self.sensor_type}_p2", None) + ) + + +def round_state(state: StateType) -> StateType: + """Round state.""" + if isinstance(state, float): + return round(state) + + return state diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py new file mode 100644 index 00000000000..ccb5e6e6e84 --- /dev/null +++ b/homeassistant/components/nam/config_flow.py @@ -0,0 +1,121 @@ +"""Adds config flow for Nettigo Air Monitor.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, cast + +from aiohttp.client_exceptions import ClientConnectorError +import async_timeout +from nettigo_air_monitor import ApiError, CannotGetMac, NettigoAirMonitor +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ATTR_NAME, CONF_HOST +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Nettigo Air Monitor.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize flow.""" + self.host: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + self.host = user_input[CONF_HOST] + try: + mac = await self._async_get_mac(cast(str, self.host)) + except (ApiError, ClientConnectorError, asyncio.TimeoutError): + errors["base"] = "cannot_connect" + except CannotGetMac: + return self.async_abort(reason="device_unsupported") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + return self.async_create_entry( + title=cast(str, self.host), + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=""): str, + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle zeroconf discovery.""" + self.host = discovery_info[CONF_HOST] + + try: + mac = await self._async_get_mac(cast(str, self.host)) + except (ApiError, ClientConnectorError, asyncio.TimeoutError): + return self.async_abort(reason="cannot_connect") + except CannotGetMac: + return self.async_abort(reason="device_unsupported") + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + self.context["title_placeholders"] = { + ATTR_NAME: discovery_info[ATTR_NAME].split(".")[0] + } + + return await self.async_step_confirm_discovery() + + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle discovery confirm.""" + errors: dict = {} + + if user_input is not None: + return self.async_create_entry( + title=cast(str, self.host), + data={CONF_HOST: self.host}, + ) + + self._set_confirm_only() + + return self.async_show_form( + step_id="confirm_discovery", + description_placeholders={CONF_HOST: self.host}, + errors=errors, + ) + + async def _async_get_mac(self, host: str) -> str: + """Get device MAC address.""" + websession = async_get_clientsession(self.hass) + nam = NettigoAirMonitor(websession, host) + # Device firmware uses synchronous code and doesn't respond to http queries + # when reading data from sensors. The nettigo-air-monitor library tries to get + # the data 4 times, so we use a longer than usual timeout here. + with async_timeout.timeout(30): + return cast(str, await nam.async_get_mac_address()) diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py new file mode 100644 index 00000000000..b14bcaa6fa1 --- /dev/null +++ b/homeassistant/components/nam/const.py @@ -0,0 +1,130 @@ +"""Constants for Nettigo Air Monitor integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + PRESSURE_HPA, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + TEMP_CELSIUS, +) + +from .model import SensorDescription + +DEFAULT_NAME: Final = "Nettigo Air Monitor" +DEFAULT_UPDATE_INTERVAL: Final = timedelta(minutes=6) +DOMAIN: Final = "nam" +MANUFACTURER: Final = "Nettigo" + +SUFFIX_P1: Final = "_p1" +SUFFIX_P2: Final = "_p2" + +AIR_QUALITY_SENSORS: Final[dict[str, str]] = {"sds": "SDS011", "sps30": "SPS30"} + +SENSORS: Final[dict[str, SensorDescription]] = { + "bme280_humidity": { + "label": f"{DEFAULT_NAME} BME280 Humidity", + "unit": PERCENTAGE, + "device_class": DEVICE_CLASS_HUMIDITY, + "icon": None, + "enabled": True, + }, + "bme280_pressure": { + "label": f"{DEFAULT_NAME} BME280 Pressure", + "unit": PRESSURE_HPA, + "device_class": DEVICE_CLASS_PRESSURE, + "icon": None, + "enabled": True, + }, + "bme280_temperature": { + "label": f"{DEFAULT_NAME} BME280 Temperature", + "unit": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + "icon": None, + "enabled": True, + }, + "bmp280_pressure": { + "label": f"{DEFAULT_NAME} BMP280 Pressure", + "unit": PRESSURE_HPA, + "device_class": DEVICE_CLASS_PRESSURE, + "icon": None, + "enabled": True, + }, + "bmp280_temperature": { + "label": f"{DEFAULT_NAME} BMP280 Temperature", + "unit": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + "icon": None, + "enabled": True, + }, + "heca_humidity": { + "label": f"{DEFAULT_NAME} HECA Humidity", + "unit": PERCENTAGE, + "device_class": DEVICE_CLASS_HUMIDITY, + "icon": None, + "enabled": True, + }, + "heca_temperature": { + "label": f"{DEFAULT_NAME} HECA Temperature", + "unit": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + "icon": None, + "enabled": True, + }, + "sht3x_humidity": { + "label": f"{DEFAULT_NAME} SHT3X Humidity", + "unit": PERCENTAGE, + "device_class": DEVICE_CLASS_HUMIDITY, + "icon": None, + "enabled": True, + }, + "sht3x_temperature": { + "label": f"{DEFAULT_NAME} SHT3X Temperature", + "unit": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + "icon": None, + "enabled": True, + }, + "sps30_p0": { + "label": f"{DEFAULT_NAME} SPS30 Particulate Matter 1.0", + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "device_class": None, + "icon": "mdi:blur", + "enabled": True, + }, + "sps30_p4": { + "label": f"{DEFAULT_NAME} SPS30 Particulate Matter 4.0", + "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "device_class": None, + "icon": "mdi:blur", + "enabled": True, + }, + "humidity": { + "label": f"{DEFAULT_NAME} DHT22 Humidity", + "unit": PERCENTAGE, + "device_class": DEVICE_CLASS_HUMIDITY, + "icon": None, + "enabled": True, + }, + "signal": { + "label": f"{DEFAULT_NAME} Signal Strength", + "unit": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + "device_class": DEVICE_CLASS_SIGNAL_STRENGTH, + "icon": None, + "enabled": False, + }, + "temperature": { + "label": f"{DEFAULT_NAME} DHT22 Temperature", + "unit": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + "icon": None, + "enabled": True, + }, +} diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json new file mode 100644 index 00000000000..80a31fe1596 --- /dev/null +++ b/homeassistant/components/nam/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "nam", + "name": "Nettigo Air Monitor", + "documentation": "https://www.home-assistant.io/integrations/nam", + "codeowners": ["@bieniu"], + "requirements": ["nettigo-air-monitor==0.2.5"], + "zeroconf": [{"type": "_http._tcp.local.", "name": "nam-*"}], + "config_flow": true, + "quality_scale": "platinum", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/nam/model.py b/homeassistant/components/nam/model.py new file mode 100644 index 00000000000..8d1bfe29a4a --- /dev/null +++ b/homeassistant/components/nam/model.py @@ -0,0 +1,14 @@ +"""Type definitions for Nettig Air Monitor integration.""" +from __future__ import annotations + +from typing import TypedDict + + +class SensorDescription(TypedDict): + """Sensor description class.""" + + label: str + unit: str | None + device_class: str | None + icon: str | None + enabled: bool diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py new file mode 100644 index 00000000000..39da7742bed --- /dev/null +++ b/homeassistant/components/nam/sensor.py @@ -0,0 +1,94 @@ +"""Support for the Nettigo Air Monitor service.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import NAMDataUpdateCoordinator +from .const import DOMAIN, SENSORS + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add a Nettigo Air Monitor entities from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + sensors = [] + for sensor in SENSORS: + if sensor in coordinator.data: + sensors.append(NAMSensor(coordinator, sensor)) + + async_add_entities(sensors, False) + + +class NAMSensor(CoordinatorEntity, SensorEntity): + """Define an Nettigo Air Monitor sensor.""" + + coordinator: NAMDataUpdateCoordinator + + def __init__(self, coordinator: NAMDataUpdateCoordinator, sensor_type: str) -> None: + """Initialize.""" + super().__init__(coordinator) + self.sensor_type = sensor_type + self._description = SENSORS[self.sensor_type] + + @property + def name(self) -> str: + """Return the name.""" + return self._description["label"] + + @property + def state(self) -> Any: + """Return the state.""" + return getattr(self.coordinator.data, self.sensor_type) + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit the value is expressed in.""" + return self._description["unit"] + + @property + def device_class(self) -> str | None: + """Return the class of this sensor.""" + return self._description["device_class"] + + @property + def icon(self) -> str | None: + """Return the icon.""" + return self._description["icon"] + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._description["enabled"] + + @property + def unique_id(self) -> str: + """Return a unique_id for this entity.""" + return f"{self.coordinator.unique_id}-{self.sensor_type}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + return self.coordinator.device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + available = super().available + + # For a short time after booting, the device does not return values for all + # sensors. For this reason, we mark entities for which data is missing as + # unavailable. + return available and bool( + getattr(self.coordinator.data, self.sensor_type, None) + ) diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json new file mode 100644 index 00000000000..e8994a346bf --- /dev/null +++ b/homeassistant/components/nam/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Set up Nettigo Air Monitor integration.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm_discovery": { + "description": "Do you want to set up Nettigo Air Monitor at {host}?" + } + }, + "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%]", + "device_unsupported": "The device is unsupported." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 35b48cf4cb5..6adcb16cc15 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -159,6 +159,7 @@ FLOWS = [ "mutesync", "myq", "mysensors", + "nam", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 0b1c0adb9c6..d4e490170d0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -94,6 +94,10 @@ ZEROCONF = { } ], "_http._tcp.local.": [ + { + "domain": "nam", + "name": "nam-*" + }, { "domain": "rachio", "name": "rachio*" diff --git a/mypy.ini b/mypy.ini index e457c331199..5b757c9eb1d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -334,6 +334,19 @@ warn_return_any = true warn_unreachable = true warn_unused_ignores = true +[mypy-homeassistant.components.nam.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +strict_equality = true +warn_return_any = true +warn_unreachable = true +warn_unused_ignores = true + [mypy-homeassistant.components.notify.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 19b8afc732c..559658659a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -987,6 +987,9 @@ netdata==0.2.0 # homeassistant.components.ssdp netdisco==2.8.3 +# homeassistant.components.nam +nettigo-air-monitor==0.2.5 + # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09625c588b3..01173646f32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -535,6 +535,9 @@ nessclient==0.9.15 # homeassistant.components.ssdp netdisco==2.8.3 +# homeassistant.components.nam +nettigo-air-monitor==0.2.5 + # homeassistant.components.nexia nexia==0.9.6 diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py new file mode 100644 index 00000000000..1b6f89b76df --- /dev/null +++ b/tests/components/nam/__init__.py @@ -0,0 +1,60 @@ +"""Tests for the Nettigo Air Monitor integration.""" +from unittest.mock import patch + +from homeassistant.components.nam.const import DOMAIN + +from tests.common import MockConfigEntry + +INCOMPLETE_NAM_DATA = { + "software_version": "NAMF-2020-36", + "sensordatavalues": [], +} + +nam_data = { + "software_version": "NAMF-2020-36", + "sensordatavalues": [ + {"value_type": "SDS_P1", "value": "18.65"}, + {"value_type": "SDS_P2", "value": "11.03"}, + {"value_type": "SPS30_P0", "value": "31.23"}, + {"value_type": "SPS30_P1", "value": "21.23"}, + {"value_type": "SPS30_P2", "value": "34.32"}, + {"value_type": "SPS30_P4", "value": "24.72"}, + {"value_type": "conc_co2_ppm", "value": "865"}, + {"value_type": "BME280_temperature", "value": "7.56"}, + {"value_type": "BME280_humidity", "value": "45.69"}, + {"value_type": "BME280_pressure", "value": "101101.17"}, + {"value_type": "BMP280_temperature", "value": "5.56"}, + {"value_type": "BMP280_pressure", "value": "102201.18"}, + {"value_type": "SHT3X_temperature", "value": "6.28"}, + {"value_type": "SHT3X_humidity", "value": "34.69"}, + {"value_type": "humidity", "value": "46.23"}, + {"value_type": "temperature", "value": "6.26"}, + {"value_type": "HECA_temperature", "value": "7.95"}, + {"value_type": "HECA_humidity", "value": "49.97"}, + {"value_type": "signal", "value": "-72"}, + ], +} + + +async def init_integration(hass, co2_sensor=True) -> MockConfigEntry: + """Set up the Nettigo Air Monitor integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + + if not co2_sensor: + # Remove conc_co2_ppm value + nam_data["sensordatavalues"].pop(6) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + return_value=nam_data, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/nam/test_air_quality.py b/tests/components/nam/test_air_quality.py new file mode 100644 index 00000000000..f9a213cec3e --- /dev/null +++ b/tests/components/nam/test_air_quality.py @@ -0,0 +1,148 @@ +"""Test air_quality of Nettigo Air Monitor integration.""" +from datetime import timedelta +from unittest.mock import patch + +from nettigo_air_monitor import ApiError + +from homeassistant.components.air_quality import ATTR_CO2, ATTR_PM_2_5, ATTR_PM_10 +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import INCOMPLETE_NAM_DATA, nam_data + +from tests.common import async_fire_time_changed +from tests.components.nam import init_integration + + +async def test_air_quality(hass): + """Test states of the air_quality.""" + await init_integration(hass) + registry = er.async_get(hass) + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state + assert state.state == "11" + assert state.attributes.get(ATTR_PM_10) == 19 + assert state.attributes.get(ATTR_PM_2_5) == 11 + assert state.attributes.get(ATTR_CO2) == 865 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + + entry = registry.async_get("air_quality.nettigo_air_monitor_sds011") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sds" + + state = hass.states.get("air_quality.nettigo_air_monitor_sps30") + assert state + assert state.state == "34" + assert state.attributes.get(ATTR_PM_10) == 21 + assert state.attributes.get(ATTR_PM_2_5) == 34 + assert state.attributes.get(ATTR_CO2) == 865 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + + entry = registry.async_get("air_quality.nettigo_air_monitor_sps30") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sps30" + + +async def test_air_quality_without_co2_value(hass): + """Test states of the air_quality.""" + await init_integration(hass, co2_sensor=False) + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state + assert state.attributes.get(ATTR_CO2) is None + + +async def test_incompleta_data_after_device_restart(hass): + """Test states of the air_quality after device restart.""" + await init_integration(hass) + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state + assert state.state == "11" + assert state.attributes.get(ATTR_PM_10) == 19 + assert state.attributes.get(ATTR_PM_2_5) == 11 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + + future = utcnow() + timedelta(minutes=6) + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + return_value=INCOMPLETE_NAM_DATA, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_availability(hass): + """Ensure that we mark the entities unavailable correctly when device causes an error.""" + await init_integration(hass) + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "11" + + future = utcnow() + timedelta(minutes=6) + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + side_effect=ApiError("API Error"), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=12) + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + return_value=nam_data, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "11" + + +async def test_manual_update_entity(hass): + """Test manual update entity via service homeasasistant/update_entity.""" + await init_integration(hass) + + await async_setup_component(hass, "homeassistant", {}) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + return_value=nam_data, + ) as mock_get_data: + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["air_quality.nettigo_air_monitor_sds011"]}, + blocking=True, + ) + + assert mock_get_data.call_count == 1 diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py new file mode 100644 index 00000000000..99a252ada0a --- /dev/null +++ b/tests/components/nam/test_config_flow.py @@ -0,0 +1,175 @@ +"""Define tests for the Nettigo Air Monitor config flow.""" +import asyncio +from unittest.mock import patch + +from nettigo_air_monitor import ApiError, CannotGetMac +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.nam.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF + +from tests.common import MockConfigEntry + +DISCOVERY_INFO = {"host": "10.10.2.3", "name": "NAM-12345"} +VALID_CONFIG = {"host": "10.10.2.3"} + + +async def test_form_create_entry(hass): + """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.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + assert result["errors"] == {} + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( + "homeassistant.components.nam.async_setup_entry", return_value=True + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "10.10.2.3" + assert result["data"]["host"] == "10.10.2.3" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "error", + [ + (ApiError("Invalid response from device 10.10.2.3: 404"), "cannot_connect"), + (asyncio.TimeoutError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_form_errors(hass, error): + """Test we handle errors.""" + exc, base_error = error + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=exc, + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["errors"] == {"base": base_error} + + +async def test_form_abort(hass): + """Test we handle abort after error.""" + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=CannotGetMac("Cannot get MAC address from device"), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "device_unsupported" + + +async def test_form_already_configured(hass): + """Test that errors are shown when duplicates are added.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", data=VALID_CONFIG + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "1.1.1.1"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + # Test config entry got updated with latest IP + assert entry.data["host"] == "1.1.1.1" + + +async def test_zeroconf(hass): + """Test we get the form.""" + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": SOURCE_ZEROCONF}, + ) + context = next( + flow["context"] + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + assert context["title_placeholders"]["name"] == "NAM-12345" + assert context["confirm_only"] is True + + with patch( + "homeassistant.components.nam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "10.10.2.3" + assert result["data"] == {"host": "10.10.2.3"} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "error", + [ + (ApiError("Invalid response from device 10.10.2.3: 404"), "cannot_connect"), + (CannotGetMac("Cannot get MAC address from device"), "device_unsupported"), + ], +) +async def test_zeroconf_errors(hass, error): + """Test we handle errors.""" + exc, reason = error + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=exc, + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DISCOVERY_INFO, + context={"source": SOURCE_ZEROCONF}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == reason diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py new file mode 100644 index 00000000000..01cf97fa6ab --- /dev/null +++ b/tests/components/nam/test_init.py @@ -0,0 +1,57 @@ +"""Test init of Nettigo Air Monitor integration.""" +from unittest.mock import patch + +from nettigo_air_monitor import ApiError + +from homeassistant.components.nam.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import STATE_UNAVAILABLE + +from tests.common import MockConfigEntry +from tests.components.nam import init_integration + + +async def test_async_setup_entry(hass): + """Test a successful setup entry.""" + await init_integration(hass) + + state = hass.states.get("air_quality.nettigo_air_monitor_sds011") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "11" + + +async def test_config_not_ready(hass): + """Test for setup failure if the connection to the device fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="10.10.2.3", + unique_id="aa:bb:cc:dd:ee:ff", + data={"host": "10.10.2.3"}, + ) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + side_effect=ApiError("API Error"), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_entry(hass): + """Test successful unload of entry.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py new file mode 100644 index 00000000000..148b048da90 --- /dev/null +++ b/tests/components/nam/test_sensor.py @@ -0,0 +1,266 @@ +"""Test sensor of Nettigo Air Monitor integration.""" +from datetime import timedelta +from unittest.mock import patch + +from nettigo_air_monitor import ApiError + +from homeassistant.components.nam.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + PRESSURE_HPA, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + STATE_UNAVAILABLE, + TEMP_CELSIUS, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from . import INCOMPLETE_NAM_DATA, nam_data + +from tests.common import async_fire_time_changed +from tests.components.nam import init_integration + + +async def test_sensor(hass): + """Test states of the air_quality.""" + registry = er.async_get(hass) + + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "aa:bb:cc:dd:ee:ff-signal", + suggested_object_id="nettigo_air_monitor_signal_strength", + disabled_by=None, + ) + + await init_integration(hass) + + state = hass.states.get("sensor.nettigo_air_monitor_bme280_humidity") + assert state + assert state.state == "45.7" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.nettigo_air_monitor_bme280_humidity") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_humidity" + + state = hass.states.get("sensor.nettigo_air_monitor_bme280_temperature") + assert state + assert state.state == "7.6" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + + entry = registry.async_get("sensor.nettigo_air_monitor_bme280_temperature") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_temperature" + + state = hass.states.get("sensor.nettigo_air_monitor_bme280_pressure") + assert state + assert state.state == "1011" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA + + entry = registry.async_get("sensor.nettigo_air_monitor_bme280_pressure") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bme280_pressure" + + state = hass.states.get("sensor.nettigo_air_monitor_bmp280_temperature") + assert state + assert state.state == "5.6" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + + entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_temperature") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp280_temperature" + + state = hass.states.get("sensor.nettigo_air_monitor_bmp280_pressure") + assert state + assert state.state == "1022" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA + + entry = registry.async_get("sensor.nettigo_air_monitor_bmp280_pressure") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-bmp280_pressure" + + state = hass.states.get("sensor.nettigo_air_monitor_sht3x_humidity") + assert state + assert state.state == "34.7" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_humidity") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sht3x_humidity" + + state = hass.states.get("sensor.nettigo_air_monitor_sht3x_temperature") + assert state + assert state.state == "6.3" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + + entry = registry.async_get("sensor.nettigo_air_monitor_sht3x_temperature") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-sht3x_temperature" + + state = hass.states.get("sensor.nettigo_air_monitor_dht22_humidity") + assert state + assert state.state == "46.2" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-humidity" + + state = hass.states.get("sensor.nettigo_air_monitor_dht22_temperature") + assert state + assert state.state == "6.3" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + + entry = registry.async_get("sensor.nettigo_air_monitor_dht22_temperature") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-temperature" + + state = hass.states.get("sensor.nettigo_air_monitor_heca_humidity") + assert state + assert state.state == "50.0" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + + entry = registry.async_get("sensor.nettigo_air_monitor_heca_humidity") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-heca_humidity" + + state = hass.states.get("sensor.nettigo_air_monitor_heca_temperature") + assert state + assert state.state == "8.0" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + + entry = registry.async_get("sensor.nettigo_air_monitor_heca_temperature") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-heca_temperature" + + state = hass.states.get("sensor.nettigo_air_monitor_signal_strength") + assert state + assert state.state == "-72" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_SIGNAL_STRENGTH + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + + entry = registry.async_get("sensor.nettigo_air_monitor_signal_strength") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-signal" + + +async def test_sensor_disabled(hass): + """Test sensor disabled by default.""" + await init_integration(hass) + registry = er.async_get(hass) + + entry = registry.async_get("sensor.nettigo_air_monitor_signal_strength") + assert entry + assert entry.unique_id == "aa:bb:cc:dd:ee:ff-signal" + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + assert updated_entry != entry + assert updated_entry.disabled is False + + +async def test_incompleta_data_after_device_restart(hass): + """Test states of the air_quality after device restart.""" + await init_integration(hass) + + state = hass.states.get("sensor.nettigo_air_monitor_heca_temperature") + assert state + assert state.state == "8.0" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + + future = utcnow() + timedelta(minutes=6) + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + return_value=INCOMPLETE_NAM_DATA, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.nettigo_air_monitor_heca_temperature") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_availability(hass): + """Ensure that we mark the entities unavailable correctly when device causes an error.""" + await init_integration(hass) + + state = hass.states.get("sensor.nettigo_air_monitor_bme280_temperature") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "7.6" + + future = utcnow() + timedelta(minutes=6) + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + side_effect=ApiError("API Error"), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.nettigo_air_monitor_bme280_temperature") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=12) + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + return_value=nam_data, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.nettigo_air_monitor_bme280_temperature") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "7.6" + + +async def test_manual_update_entity(hass): + """Test manual update entity via service homeasasistant/update_entity.""" + await init_integration(hass) + + await async_setup_component(hass, "homeassistant", {}) + + with patch( + "homeassistant.components.nam.NettigoAirMonitor._async_get_data", + return_value=nam_data, + ) as mock_get_data: + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.nettigo_air_monitor_bme280_temperature"]}, + blocking=True, + ) + + assert mock_get_data.call_count == 1