diff --git a/CODEOWNERS b/CODEOWNERS index 50b39ca4c21..042f4d591a0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1724,6 +1724,8 @@ build.json @home-assistant/supervisor /tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen /homeassistant/components/twitch/ @joostlek /tests/components/twitch/ @joostlek +/homeassistant/components/uhoo/ @getuhoo @joshsmonta +/tests/components/uhoo/ @getuhoo @joshsmonta /homeassistant/components/ukraine_alarm/ @PaulAnnekov /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 diff --git a/homeassistant/components/uhoo/__init__.py b/homeassistant/components/uhoo/__init__.py new file mode 100644 index 00000000000..1b9a223efb5 --- /dev/null +++ b/homeassistant/components/uhoo/__init__.py @@ -0,0 +1,46 @@ +"""Initializes the uhoo api client and setup needed for the devices.""" + +from aiodns.error import DNSError +from aiohttp.client_exceptions import ClientConnectionError +from uhooapi import Client +from uhooapi.errors import UhooError, UnauthorizedError + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import UhooConfigEntry, UhooDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, config_entry: UhooConfigEntry) -> bool: + """Set up uHoo integration from a config entry.""" + + # get api key and session from configuration + api_key = config_entry.data[CONF_API_KEY] + session = async_get_clientsession(hass) + client = Client(api_key, session, debug=False) + coordinator = UhooDataUpdateCoordinator(hass, client=client, entry=config_entry) + + try: + await client.login() + await client.setup_devices() + except (ClientConnectionError, DNSError) as err: + raise ConfigEntryNotReady(f"Cannot connect to uHoo servers: {err}") from err + except UnauthorizedError as err: + raise ConfigEntryError(f"Invalid API credentials: {err}") from err + except UhooError as err: + raise ConfigEntryNotReady(err) from err + + await coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: UhooConfigEntry +) -> bool: + """Handle removal of an entry.""" + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/uhoo/config_flow.py b/homeassistant/components/uhoo/config_flow.py new file mode 100644 index 00000000000..dbaa8d1c6ad --- /dev/null +++ b/homeassistant/components/uhoo/config_flow.py @@ -0,0 +1,67 @@ +"""Custom uhoo config flow setup.""" + +from typing import Any + +from uhooapi import Client +from uhooapi.errors import UhooError, UnauthorizedError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER + +USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + } +) + + +class UhooConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for uHoo.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the start of the config flow.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + session = async_create_clientsession(self.hass) + client = Client(user_input[CONF_API_KEY], session, debug=True) + try: + await client.login() + except UnauthorizedError: + errors["base"] = "invalid_auth" + except UhooError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + key_snippet = user_input[CONF_API_KEY][-5:] + return self.async_create_entry( + title=f"uHoo ({key_snippet})", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uhoo/const.py b/homeassistant/components/uhoo/const.py new file mode 100644 index 00000000000..3666ab0d0b4 --- /dev/null +++ b/homeassistant/components/uhoo/const.py @@ -0,0 +1,26 @@ +"""Static consts for uhoo integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "uhoo" +PLATFORMS = ["sensor"] +LOGGER = logging.getLogger(__package__) + +NAME = "uHoo Integration" +MODEL = "uHoo Indoor Air Monitor" +MANUFACTURER = "uHoo Pte. Ltd." + +UPDATE_INTERVAL = timedelta(seconds=300) + +API_VIRUS = "virus_index" +API_MOLD = "mold_index" +API_TEMP = "temperature" +API_HUMIDITY = "humidity" +API_PM25 = "pm25" +API_TVOC = "tvoc" +API_CO2 = "co2" +API_CO = "co" +API_PRESSURE = "air_pressure" +API_OZONE = "ozone" +API_NO2 = "no2" diff --git a/homeassistant/components/uhoo/coordinator.py b/homeassistant/components/uhoo/coordinator.py new file mode 100644 index 00000000000..da42fb2c88a --- /dev/null +++ b/homeassistant/components/uhoo/coordinator.py @@ -0,0 +1,40 @@ +"""Custom uhoo data update coordinator.""" + +from uhooapi import Client, Device +from uhooapi.errors import UhooError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER, UPDATE_INTERVAL + +type UhooConfigEntry = ConfigEntry[UhooDataUpdateCoordinator] + + +class UhooDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): + """Class to manage fetching data from the uHoo API.""" + + def __init__( + self, hass: HomeAssistant, client: Client, entry: UhooConfigEntry + ) -> None: + """Initialize DataUpdateCoordinator.""" + self.client = client + super().__init__( + hass, + LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Device]: + try: + await self.client.login() + if self.client.devices: + for device_id in self.client.devices: + await self.client.get_latest_data(device_id) + except UhooError as error: + raise UpdateFailed(f"The device is unavailable: {error}") from error + else: + return self.client.devices diff --git a/homeassistant/components/uhoo/manifest.json b/homeassistant/components/uhoo/manifest.json new file mode 100644 index 00000000000..28b729984ed --- /dev/null +++ b/homeassistant/components/uhoo/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "uhoo", + "name": "uHoo", + "codeowners": ["@getuhoo", "@joshsmonta"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/uhooair", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["uhooapi==1.2.6"] +} diff --git a/homeassistant/components/uhoo/quality_scale.yaml b/homeassistant/components/uhoo/quality_scale.yaml new file mode 100644 index 00000000000..5a63545c164 --- /dev/null +++ b/homeassistant/components/uhoo/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/uhoo/sensor.py b/homeassistant/components/uhoo/sensor.py new file mode 100644 index 00000000000..eed8cd4195e --- /dev/null +++ b/homeassistant/components/uhoo/sensor.py @@ -0,0 +1,197 @@ +"""Custom uhoo sensors setup.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from uhooapi import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + API_CO, + API_CO2, + API_HUMIDITY, + API_MOLD, + API_NO2, + API_OZONE, + API_PM25, + API_PRESSURE, + API_TEMP, + API_TVOC, + API_VIRUS, + DOMAIN, + MANUFACTURER, + MODEL, +) +from .coordinator import UhooConfigEntry, UhooDataUpdateCoordinator + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class UhooSensorEntityDescription(SensorEntityDescription): + """Extended SensorEntityDescription with a type-safe value function.""" + + value_fn: Callable[[Device], float | None] + + +SENSOR_TYPES: tuple[UhooSensorEntityDescription, ...] = ( + UhooSensorEntityDescription( + key=API_CO, + device_class=SensorDeviceClass.CO, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.co, + ), + UhooSensorEntityDescription( + key=API_CO2, + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.co2, + ), + UhooSensorEntityDescription( + key=API_PM25, + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.pm25, + ), + UhooSensorEntityDescription( + key=API_HUMIDITY, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.humidity, + ), + UhooSensorEntityDescription( + key=API_TEMP, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, # Base unit + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.temperature, + ), + UhooSensorEntityDescription( + key=API_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.air_pressure, + ), + UhooSensorEntityDescription( + key=API_TVOC, + translation_key="volatile_organic_compounds", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.tvoc, + ), + UhooSensorEntityDescription( + key=API_NO2, + translation_key="nitrogen_dioxide", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.no2, + ), + UhooSensorEntityDescription( + key=API_OZONE, + translation_key="ozone", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.ozone, + ), + UhooSensorEntityDescription( + key=API_VIRUS, + translation_key=API_VIRUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.virus_index, + ), + UhooSensorEntityDescription( + key=API_MOLD, + translation_key=API_MOLD, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.mold_index, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UhooConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Setup sensor platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + UhooSensorEntity(description, serial_number, coordinator) + for serial_number in coordinator.data + for description in SENSOR_TYPES + ) + + +class UhooSensorEntity(CoordinatorEntity[UhooDataUpdateCoordinator], SensorEntity): + """Uhoo Sensor Object with init and methods.""" + + entity_description: UhooSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + description: UhooSensorEntityDescription, + serial_number: str, + coordinator: UhooDataUpdateCoordinator, + ) -> None: + """Initialize Uhoo Sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._serial_number = serial_number + self._attr_unique_id = f"{serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + name=self.device.device_name, + model=MODEL, + manufacturer=MANUFACTURER, + serial_number=serial_number, + ) + + @property + def device(self) -> Device: + """Return the device object for this sensor's serial number.""" + return self.coordinator.data[self._serial_number] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self._serial_number in self.coordinator.data + + @property + def native_value(self) -> StateType: + """State of the sensor.""" + return self.entity_description.value_fn(self.device) + + @property + def native_unit_of_measurement(self) -> str | None: + """Return unit of measurement.""" + if self.entity_description.key == API_TEMP: + if self.device.user_settings["temp"] == "f": + return UnitOfTemperature.FAHRENHEIT + return UnitOfTemperature.CELSIUS + return super().native_unit_of_measurement diff --git a/homeassistant/components/uhoo/strings.json b/homeassistant/components/uhoo/strings.json new file mode 100644 index 00000000000..d9da4499a02 --- /dev/null +++ b/homeassistant/components/uhoo/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your uHoo API key. You can find this in your uHoo account settings." + }, + "description": "Enter your uHoo API key to connect.", + "title": "Connect to uHoo" + } + } + }, + "entity": { + "sensor": { + "mold_index": { + "name": "Mold index" + }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "ozone": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "virus_index": { + "name": "Virus index" + }, + "volatile_organic_compounds": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b1164609237..78258aaa181 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -723,6 +723,7 @@ FLOWS = { "twilio", "twinkly", "twitch", + "uhoo", "ukraine_alarm", "unifi", "unifiprotect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b770644e657..f1e20587fd6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7194,6 +7194,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "uhoo": { + "name": "uHoo", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "uk_transport": { "name": "UK Transport", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index ca8d827fb63..11541119fed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3082,6 +3082,9 @@ typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.uhoo +uhooapi==1.2.6 + # homeassistant.components.unifiprotect uiprotect==10.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bccfcbff9c..a9c5f97957b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2579,6 +2579,9 @@ typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.uhoo +uhooapi==1.2.6 + # homeassistant.components.unifiprotect uiprotect==10.0.1 diff --git a/tests/components/uhoo/__init__.py b/tests/components/uhoo/__init__.py new file mode 100644 index 00000000000..89420b33180 --- /dev/null +++ b/tests/components/uhoo/__init__.py @@ -0,0 +1,13 @@ +"""Tests for uhoo-homeassistant integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the uHoo integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/uhoo/conftest.py b/tests/components/uhoo/conftest.py new file mode 100644 index 00000000000..ff5baee1171 --- /dev/null +++ b/tests/components/uhoo/conftest.py @@ -0,0 +1,111 @@ +"""Global fixtures for uHoo integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.uhoo.const import DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_device() -> MagicMock: + """Mock a uHoo device.""" + device = MagicMock() + device.humidity = 45.5 + device.temperature = 22.0 + device.co = 1.5 + device.co2 = 450.0 + device.pm25 = 12.3 + device.air_pressure = 1013.25 + device.tvoc = 150.0 + device.no2 = 20.0 + device.ozone = 30.0 + device.virus_index = 2.0 + device.mold_index = 1.5 + device.device_name = "Test Device" + device.serial_number = "23f9239m92m3ffkkdkdd" + device.user_settings = {"temp": "c"} + return device + + +@pytest.fixture +def mock_device2() -> MagicMock: + """Mock a uHoo device.""" + device = MagicMock() + device.humidity = 50.0 + device.temperature = 21.0 + device.co = 1.0 + device.co2 = 400.0 + device.pm25 = 10.0 + device.air_pressure = 1010.0 + device.tvoc = 100.0 + device.no2 = 15.0 + device.ozone = 25.0 + device.virus_index = 1.0 + device.mold_index = 1.0 + device.device_name = "Test Device 2" + device.serial_number = "13e2r2fi2ii2i3993822" + device.user_settings = {"temp": "c"} + return device + + +@pytest.fixture +def mock_uhoo_client(mock_device) -> Generator[AsyncMock]: + """Mock uHoo client.""" + with ( + patch( + "homeassistant.components.uhoo.config_flow.Client", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.uhoo.Client", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_latest_data.return_value = [ + { + "serialNumber": "23f9239m92m3ffkkdkdd", + "deviceName": "Test Device", + "humidity": 45.5, + "temperature": 22.0, + "co": 0.0, + "co2": 400.0, + "pm25": 10.0, + "airPressure": 1010.0, + "tvoc": 100.0, + "no2": 15.0, + "ozone": 25.0, + "virusIndex": 1.0, + "moldIndex": 1.0, + "userSettings": {"temp": "c"}, + } + ] + client.devices = {"23f9239m92m3ffkkdkdd": mock_device} + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry for uHoo integration.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="valid-api-key-12345", + data={CONF_API_KEY: "valid-api-key-12345"}, + title="uHoo (12345)", + entry_id="01J0BC4QM2YBRP6H5G933CETT7", + ) + + +@pytest.fixture +def mock_setup_entry(): + """Mock the setup entry.""" + with patch( + "homeassistant.components.uhoo.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup diff --git a/tests/components/uhoo/snapshots/test_sensor.ambr b/tests/components/uhoo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..26e353d6e1e --- /dev/null +++ b/tests/components/uhoo/snapshots/test_sensor.ambr @@ -0,0 +1,595 @@ +# serializer version: 1 +# name: test_sensor_snapshot[sensor.test_device_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Carbon dioxide', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Test Device Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.test_device_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '450.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_carbon_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Carbon monoxide', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_co', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Test Device Carbon monoxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.test_device_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Humidity', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test Device Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_device_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45.5', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_mold_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_mold_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mold index', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mold index', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mold_index', + 'unique_id': '23f9239m92m3ffkkdkdd_mold_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_mold_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Device Mold index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_device_mold_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_nitrogen_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_nitrogen_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Nitrogen dioxide', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nitrogen dioxide', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nitrogen_dioxide', + 'unique_id': '23f9239m92m3ffkkdkdd_no2', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Device Nitrogen dioxide', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.test_device_nitrogen_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_ozone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_ozone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ozone', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ozone', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ozone', + 'unique_id': '23f9239m92m3ffkkdkdd_ozone', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Device Ozone', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.test_device_ozone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'PM2.5', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_pm25', + 'unit_of_measurement': 'μg/m³', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Test Device PM2.5', + 'state_class': , + 'unit_of_measurement': 'μg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_device_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.3', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Pressure', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_air_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': 'Test Device Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_device_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1013.25', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Device Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_device_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_virus_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_virus_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Virus index', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Virus index', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'virus_index', + 'unique_id': '23f9239m92m3ffkkdkdd_virus_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_virus_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Device Virus index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_device_virus_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_volatile_organic_compounds-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_device_volatile_organic_compounds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Volatile organic compounds', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volatile_organic_compounds', + 'unique_id': '23f9239m92m3ffkkdkdd_tvoc', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_volatile_organic_compounds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds', + 'friendly_name': 'Test Device Volatile organic compounds', + 'state_class': , + 'unit_of_measurement': 'ppb', + }), + 'context': , + 'entity_id': 'sensor.test_device_volatile_organic_compounds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150.0', + }) +# --- diff --git a/tests/components/uhoo/test_config_flow.py b/tests/components/uhoo/test_config_flow.py new file mode 100644 index 00000000000..69855e193f1 --- /dev/null +++ b/tests/components/uhoo/test_config_flow.py @@ -0,0 +1,96 @@ +"""Test the Uhoo config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from uhooapi.errors import UhooError, UnauthorizedError + +from homeassistant.components.uhoo.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow( + hass: HomeAssistant, mock_uhoo_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test a complete user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_API_KEY: "valid-api-key-12345"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uHoo (12345)" + assert result["data"] == {CONF_API_KEY: "valid-api-key-12345"} + + mock_setup_entry.assert_called_once() + + +async def test_user_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test duplicate entry aborts.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "valid-api-key-12345"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error_type"), + [ + (UhooError("asd"), "cannot_connect"), + (UnauthorizedError("Invalid credentials"), "invalid_auth"), + (Exception(), "unknown"), + ], +) +async def test_user_flow_exceptions( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + exception: Exception, + error_type: str, +) -> None: + """Test form when client raises various exceptions.""" + mock_uhoo_client.login.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "test-api-key"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error_type} + + mock_uhoo_client.login.assert_called_once() + mock_uhoo_client.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "test-api-key"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/uhoo/test_init.py b/tests/components/uhoo/test_init.py new file mode 100644 index 00000000000..bd7fd897a5d --- /dev/null +++ b/tests/components/uhoo/test_init.py @@ -0,0 +1,64 @@ +"""Tests for __init__.py with coordinator.""" + +from unittest.mock import AsyncMock + +from aiodns.error import DNSError +from aiohttp.client_exceptions import ClientConnectionError +import pytest +from uhooapi.errors import UhooError, UnauthorizedError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "field", + [ + "login", + "setup_devices", + ], +) +@pytest.mark.parametrize( + ("exc", "state"), + [ + (ClientConnectionError, ConfigEntryState.SETUP_RETRY), + (DNSError, ConfigEntryState.SETUP_RETRY), + (UhooError, ConfigEntryState.SETUP_RETRY), + (UnauthorizedError, ConfigEntryState.SETUP_ERROR), + ], +) +async def test_setup_failure( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + mock_config_entry: MockConfigEntry, + field: str, + exc: Exception, + state: ConfigEntryState, +) -> None: + """Test setup failure.""" + # Set the exception on the specified field + getattr(mock_uhoo_client, field).side_effect = exc + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is state diff --git a/tests/components/uhoo/test_sensor.py b/tests/components/uhoo/test_sensor.py new file mode 100644 index 00000000000..5efe7446571 --- /dev/null +++ b/tests/components/uhoo/test_sensor.py @@ -0,0 +1,138 @@ +"""Tests for sensor.py with Uhoo sensors.""" + +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion +from uhooapi.errors import UhooError + +from homeassistant.components.uhoo.const import UPDATE_INTERVAL +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensor_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_uhoo_client: AsyncMock, + mock_device: AsyncMock, +) -> None: + """Test sensor setup with snapshot.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_async_setup_entry_multiple_devices( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_device: MagicMock, + mock_device2: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test setting up sensor entities for multiple devices.""" + # Update the mock to return data for two devices + mock_uhoo_client.get_latest_data.return_value = [ + { + "serialNumber": "23f9239m92m3ffkkdkdd", + "deviceName": "Test Device", + "humidity": 45.5, + "temperature": 22.0, + "co": 1.5, + "co2": 450.0, + "pm25": 12.3, + "airPressure": 1013.25, + "tvoc": 150.0, + "no2": 20.0, + "ozone": 30.0, + "virusIndex": 2.0, + "moldIndex": 1.5, + "userSettings": {"temp": "c"}, + }, + { + "serialNumber": "13e2r2fi2ii2i3993822", + "deviceName": "Test Device 2", + "humidity": 50.0, + "temperature": 21.0, + "co": 1.0, + "co2": 400.0, + "pm25": 10.0, + "airPressure": 1010.0, + "tvoc": 100.0, + "no2": 15.0, + "ozone": 25.0, + "virusIndex": 1.0, + "moldIndex": 1.0, + "userSettings": {"temp": "c"}, + }, + ] + mock_uhoo_client.devices = { + "23f9239m92m3ffkkdkdd": mock_device, + "13e2r2fi2ii2i3993822": mock_device2, + } + + # Setup the integration with the updated mock data + await setup_integration(hass, mock_config_entry) + + assert len(entity_registry.entities) == 22 + + +async def test_sensor_availability_changes_with_connection_errors( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor availability changes over time with different connection errors.""" + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.test_device_carbon_dioxide") + assert state.state != STATE_UNAVAILABLE + + mock_uhoo_client.get_latest_data.side_effect = UhooError( + "The device is unavailable" + ) + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_device_carbon_dioxide") + assert state.state == STATE_UNAVAILABLE + + mock_uhoo_client.get_latest_data.side_effect = None + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_device_carbon_dioxide") + assert state.state != STATE_UNAVAILABLE + + +async def test_different_unit( + hass: HomeAssistant, + mock_uhoo_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_device: MagicMock, +) -> None: + """Test sensor interprets value correctly with different unit settings.""" + mock_device.user_settings = {"temp": "f"} + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.test_device_temperature") + assert state.state == "-5.55555555555556" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS