diff --git a/CODEOWNERS b/CODEOWNERS index b6459c82ac81c1..42b109a811f243 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1719,6 +1719,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 00000000000000..1b9a223efb52a1 --- /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 00000000000000..6d6c16ff221c10 --- /dev/null +++ b/homeassistant/components/uhoo/config_flow.py @@ -0,0 +1,102 @@ +"""Custom uhoo config flow setup.""" + +from typing import Any + +from aiodns.error import DNSError +from aiohttp.client_exceptions import ClientConnectorDNSError +from uhooapi import Client +from uhooapi.errors import 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 UhooFlowHandler(ConfigFlow, domain=DOMAIN): + """Setup Uhoo flow handlers.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors: dict = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the start of the config flow.""" + self._errors = {} + + if user_input is None: + return await self._show_config_form(user_input) + + # Set the unique ID for the config flow. + api_key = user_input[CONF_API_KEY] + if not api_key: + self._errors["base"] = "invalid_auth" + return await self._show_config_form(user_input) + + await self.async_set_unique_id(api_key) + self._async_abort_entries_match() + + valid = await self._test_credentials(api_key) + if not valid: + self._errors["base"] = "invalid_auth" + return await self._show_config_form(user_input) + + key_snippet = api_key[-5:] + return self.async_create_entry(title=f"uHoo ({key_snippet})", data=user_input) + + async def _show_config_form( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + if user_input is None: + user_input = {} + user_input[CONF_API_KEY] = "" + return await self._show_config_form(user_input) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + USER_DATA_SCHEMA, user_input or {} + ), + errors=self._errors, + ) + + async def _test_credentials(self, api_key): + """Return true if credentials is valid.""" + try: + session = async_create_clientsession(self.hass) + client = Client(api_key, session, debug=True) + await client.login() + except UnauthorizedError as err: + LOGGER.error( + f"Error: received a 401 Unauthorized error attempting to login:\n{err}" + ) + return False + except ConnectionError: + LOGGER.error("ConnectionError: cannot connect to uhoo server") + return False + except (ClientConnectorDNSError, DNSError): + LOGGER.error("ClientConnectorDNSError: cannot connect to uhoo server") + return False + else: + return True diff --git a/homeassistant/components/uhoo/const.py b/homeassistant/components/uhoo/const.py new file mode 100644 index 00000000000000..3666ab0d0b4363 --- /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 00000000000000..328a6f823917f5 --- /dev/null +++ b/homeassistant/components/uhoo/coordinator.py @@ -0,0 +1,46 @@ +"""Custom uhoo data update coordinator.""" + +import asyncio + +from aiohttp.client_exceptions import ClientConnectorDNSError +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 + self.entry = entry + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + async def _async_update_data(self) -> dict[str, Device]: + try: + await self.client.login() + if self.client.devices: + await asyncio.gather( + *[ + self.client.get_latest_data(device_id) + for device_id in self.client.devices + ] + ) + except TimeoutError as error: + raise UpdateFailed from error + except ClientConnectorDNSError as error: + raise UpdateFailed from error + 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 00000000000000..cf2d18f49e121f --- /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.3"] +} diff --git a/homeassistant/components/uhoo/quality_scale.yaml b/homeassistant/components/uhoo/quality_scale.yaml new file mode 100644 index 00000000000000..5a63545c164c5d --- /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 00000000000000..4194860c261f13 --- /dev/null +++ b/homeassistant/components/uhoo/sensor.py @@ -0,0 +1,196 @@ +"""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, + 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, + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.no2, + ), + UhooSensorEntityDescription( + key=API_OZONE, + device_class=SensorDeviceClass.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 00000000000000..bdd45f80dcf23f --- /dev/null +++ b/homeassistant/components/uhoo/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "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" + }, + "virus_index": { + "name": "Virus index" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2f0829b0756eb8..ce43b217a2c120 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -720,6 +720,7 @@ "twilio", "twinkly", "twitch", + "uhoo", "ukraine_alarm", "unifi", "unifiprotect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9ad1ad0e8279f1..d67d4852ea0398 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7185,6 +7185,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 b417ca2cfe534e..7a1a6c6fc4d729 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3080,6 +3080,9 @@ typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.uhoo +uhooapi==1.2.3 + # homeassistant.components.unifiprotect uiprotect==8.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ab5e508cd3f8b..d1516bdfc5c3bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2574,6 +2574,9 @@ typedmonarchmoney==0.4.4 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.uhoo +uhooapi==1.2.3 + # homeassistant.components.unifiprotect uiprotect==8.0.0 diff --git a/tests/components/uhoo/__init__.py b/tests/components/uhoo/__init__.py new file mode 100644 index 00000000000000..a4f8106cb020e1 --- /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_uhoo_config(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Load a mock config for uHoo.""" + 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 00000000000000..3bf17cf63e1ba7 --- /dev/null +++ b/tests/components/uhoo/conftest.py @@ -0,0 +1,184 @@ +"""Global fixtures for uHoo integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.uhoo.sensor import ( + SENSOR_TYPES, + SensorDeviceClass, + SensorStateClass, + UnitOfTemperature, +) +from homeassistant.const import CONF_API_KEY, PERCENTAGE +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_device") +def mock_device(): + """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(name="mock_uhoo_client") +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.login = AsyncMock() + client.setup_devices = AsyncMock() + client.get_devices = MagicMock() + client.get_latest_data = AsyncMock( + 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(name="mock_uhoo_config_entry") +def mock_uhoo_config_entry_fixture() -> 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(name="mock_add_entities") +def mock_add_entities(): + """Mock the add_entities callback.""" + return MagicMock(spec=AddConfigEntryEntitiesCallback) + + +@pytest.fixture(name="test_sensor_entity_descriptions") +def test_sensor_entity_descriptions() -> None: + """Test that all sensor descriptions are properly defined.""" + assert len(SENSOR_TYPES) == 11 # We have 11 sensor types + + # Check a few key sensors + humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") + assert humidity_desc.device_class == SensorDeviceClass.HUMIDITY + assert humidity_desc.native_unit_of_measurement == PERCENTAGE + assert humidity_desc.state_class == SensorStateClass.MEASUREMENT + assert callable(humidity_desc.value_fn) + + temp_desc = next(d for d in SENSOR_TYPES if d.key == "temperature") + assert temp_desc.device_class == SensorDeviceClass.TEMPERATURE + assert temp_desc.native_unit_of_measurement == UnitOfTemperature.CELSIUS + assert temp_desc.state_class == SensorStateClass.MEASUREMENT + assert callable(temp_desc.value_fn) + + # Check virus and mold sensors don't have device_class + virus_desc = next(d for d in SENSOR_TYPES if d.key == "virus_index") + mold_desc = next(d for d in SENSOR_TYPES if d.key == "mold_index") + assert virus_desc.device_class is None + assert mold_desc.device_class is None + + +@pytest.fixture(name="mock_uhoo_coordinator") +def mock_uhoo_coordinator_fixture(mock_uhoo_client): + """Mock coordinator.""" + coordinator = MagicMock() + coordinator.async_config_entry_first_refresh = AsyncMock() + coordinator.client = mock_uhoo_client + return coordinator + + +@pytest.fixture +def patch_async_get_clientsession(): + """Patch async_get_clientsession to return a mock.""" + with patch( + "homeassistant.components.uhoo.async_get_clientsession", + return_value=AsyncMock(), + ) as mock_session: + yield mock_session + + +@pytest.fixture +def patch_uhoo_data_update_coordinator(mock_uhoo_coordinator): + """Patch UhooDataUpdateCoordinator to return mock coordinator.""" + with patch( + "homeassistant.components.uhoo.UhooDataUpdateCoordinator", + return_value=mock_uhoo_coordinator, + ) as mock_coordinator_class: + yield mock_coordinator_class + + +@pytest.fixture(name="mock_setup_entry") +def mock_setup_entry_fixture(): + """Mock the setup entry.""" + with patch( + "homeassistant.components.uhoo.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +# This fixture enables loading custom integrations in all tests. +# Remove to enable selective use of this fixture +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations() -> None: + """This fixture enables loading custom integrations in all tests.""" + return + + +# This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent +# notifications. These calls would fail without this fixture since the persistent_notification +# integration is never loaded during a test. +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture(): + """Skip notification calls.""" + with ( + patch("homeassistant.components.persistent_notification.async_create"), + patch("homeassistant.components.persistent_notification.async_dismiss"), + ): + yield diff --git a/tests/components/uhoo/const.py b/tests/components/uhoo/const.py new file mode 100644 index 00000000000000..20e3cabff0c72b --- /dev/null +++ b/tests/components/uhoo/const.py @@ -0,0 +1,31 @@ +"""Constants for uhoo tests.""" + +from typing import Any + +from homeassistant.const import CONF_API_KEY + +# Mock config data to be used across multiple tests +DOMAIN = "uhoo" +MOCK_CONFIG: dict = {CONF_API_KEY: "tes1232421232"} + +MOCK_DEVICE: dict[str, Any] = { + "deviceName": "Office Room", + "serialNumber": "23f9239m92m3ffkkdkdd", +} + +MOCK_DEVICE_DATA = [ + { + "virusIndex": 3, + "moldIndex": 4, + "temperature": 28.9, + "humidity": 67.6, + "pm25": 9, + "tvoc": 1, + "co2": 771, + "co": 0, + "airPressure": 1008.2, + "ozone": 5, + "no2": 0, + "timestamp": 1762946521, + } +] diff --git a/tests/components/uhoo/snapshots/test_sensor.ambr b/tests/components/uhoo/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..8f714f01d7d62e --- /dev/null +++ b/tests/components/uhoo/snapshots/test_sensor.ambr @@ -0,0 +1,586 @@ +# 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, + '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, + '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, + '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, + '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, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nitrogen dioxide', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_no2', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'nitrogen_dioxide', + '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, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ozone', + 'platform': 'uhoo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '23f9239m92m3ffkkdkdd_ozone', + 'unit_of_measurement': 'ppb', + }) +# --- +# name: test_sensor_snapshot[sensor.test_device_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ozone', + '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, + '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, + '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, + '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, + '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, + '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': None, + '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 00000000000000..af8529e8e52833 --- /dev/null +++ b/tests/components/uhoo/test_config_flow.py @@ -0,0 +1,243 @@ +"""Test the Uhoo config flow.""" + +from aiohttp.client_exceptions import ClientConnectorDNSError +from uhooapi.errors import 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 + + +async def test_happy_flow( + hass: HomeAssistant, mock_uhoo_client, mock_setup_entry +) -> None: + """Test a complete user flow from start to finish with errors and success.""" + # Step 1: Initialize the flow ONCE and get the flow_id + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + flow_id = result["flow_id"] + + # Step 2: Test submitting an empty API key within the SAME flow + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_API_KEY: ""}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + mock_uhoo_client.login.assert_not_called() + + # Step 3: Test submitting an invalid API key within the SAME flow + mock_uhoo_client.login.side_effect = UnauthorizedError("Invalid credentials") + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_API_KEY: "invalid-api-key"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + mock_uhoo_client.login.assert_called_once() + + # Step 4: Reset mock and test successful submission within the SAME flow + mock_uhoo_client.login.reset_mock() + mock_uhoo_client.login.side_effect = None + mock_uhoo_client.login.return_value = None + + result = await hass.config_entries.flow.async_configure( + flow_id, + user_input={CONF_API_KEY: "valid-api-key-12345"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "uHoo (12345)" + assert result["data"] == {CONF_API_KEY: "valid-api-key-12345"} + + # Verify the setup was called + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + + +async def test_form_duplicate_entry( + hass: HomeAssistant, mock_uhoo_client, mock_uhoo_config_entry +) -> None: + """Test duplicate entry aborts.""" + mock_uhoo_client.login.return_value = None + mock_uhoo_config_entry.add_to_hass(hass) + + # Try to create duplicate + 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"}, # Same API key + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_client_exception(hass: HomeAssistant, mock_uhoo_client) -> None: + """Test form when client raises an expected exception.""" + # Use a ConnectionError which is caught by your config flow + mock_uhoo_client.login.side_effect = [ConnectionError("Cannot connect"), None] + + # Start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Submit API key + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "api-key"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + mock_uhoo_client.login.assert_called_once() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "api-key"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_connection_error(hass: HomeAssistant, mock_uhoo_client) -> None: + """Test DNS connection error during login.""" + # Create a ClientConnectorDNSError + mock_uhoo_client.login.side_effect = [ + ClientConnectorDNSError( + ConnectionError("Cannot connect"), OSError("DNS failure") + ), + None, # Second call succeeds + ] + + # Start the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Submit API key + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "api-key"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "api-key"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_uhoo_client, + mock_setup_entry, +) -> None: + """Test the full user flow from start to finish.""" + # Mock successful login + mock_uhoo_client.login.return_value = None + + # Step 1: Initialize the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # Step 2: Submit valid credentials + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "valid-api-key-test12345"}, + ) + + # Step 3: Verify entry creation + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "uHoo (12345)" # Last 5 chars + assert result["data"] == {CONF_API_KEY: "valid-api-key-test12345"} + assert result["result"] + + # Verify setup was called + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + + +async def test_flow_cancellation( + hass: HomeAssistant, +) -> None: + """Test user flow cancellation.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # The flow should still be in form state + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_complete_integration_flow( + hass: HomeAssistant, + mock_uhoo_client, + mock_setup_entry, +) -> None: + """Test complete integration flow from user perspective.""" + # Step 1: User starts the flow + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Step 2: User enters invalid API key + mock_uhoo_client.login.side_effect = UnauthorizedError("Invalid") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "wrong-key"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Step 3: User corrects and enters valid API key + mock_uhoo_client.login.side_effect = None + mock_uhoo_client.login.return_value = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "correct-key-67890"}, + ) + + # Step 4: Verify successful entry creation + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "correct-key-67890"} + assert result["title"] == "uHoo (67890)" # Last 5 chars + + # Verify the entry was added to hass + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data[CONF_API_KEY] == "correct-key-67890" + assert entries[0].unique_id == "correct-key-67890" + + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() diff --git a/tests/components/uhoo/test_init.py b/tests/components/uhoo/test_init.py new file mode 100644 index 00000000000000..068bb54458ecaf --- /dev/null +++ b/tests/components/uhoo/test_init.py @@ -0,0 +1,388 @@ +"""Tests for __init__.py with coordinator.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from aiodns.error import DNSError +from aiohttp.client_exceptions import ClientConnectionError +import pytest +from uhooapi.errors import UhooError, UnauthorizedError + +from homeassistant.components.uhoo import async_setup_entry, async_unload_entry +from homeassistant.components.uhoo.const import DOMAIN, PLATFORMS +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + + +def create_mock_config_entry(data=None): + """Create a mock config entry with all required attributes.""" + if data is None: + data = {"api_key": "test-api-key-123"} + + # Create a MagicMock with ConfigEntry spec + mock_entry = MagicMock(spec=ConfigEntry) + mock_entry.version = 1 + mock_entry.domain = DOMAIN + mock_entry.title = "uHoo (123)" + mock_entry.data = data + mock_entry.source = "user" + mock_entry.unique_id = data.get("api_key", "test-api-key-123") + mock_entry.entry_id = "test-entry-123" + mock_entry.minor_version = 1 + mock_entry.options = {} + mock_entry.pref_disable_new_entities = None + mock_entry.pref_disable_polling = None + mock_entry.disabled_by = None + mock_entry.reason = None + mock_entry.state = ConfigEntryState.NOT_LOADED + mock_entry.setup_lock = asyncio.Lock() + + # Set attributes that might be needed + mock_entry.runtime_data = None + mock_entry.discovery_keys = {} + mock_entry.subentries_data = {} + mock_entry.translation_key = None + mock_entry.translation_placeholders = None + + return mock_entry + + +async def test_async_setup_entry_success( + hass: HomeAssistant, + mock_uhoo_client, + mock_uhoo_coordinator, + patch_async_get_clientsession, + patch_uhoo_data_update_coordinator, +) -> None: + """Test successful setup of a config entry.""" + config_entry = create_mock_config_entry() + + # Mock the hass.config_entries methods + mock_forward_entry_setups = AsyncMock() + hass.config_entries.async_forward_entry_setups = mock_forward_entry_setups + + # Call the setup function + result = await async_setup_entry(hass, config_entry) + + # Verify the setup was successful + assert result is True + + # Verify login and setup_devices were called + mock_uhoo_client.login.assert_awaited_once() + mock_uhoo_client.setup_devices.assert_awaited_once() + + # Verify first refresh was called + mock_uhoo_coordinator.async_config_entry_first_refresh.assert_awaited_once() + + # Verify runtime data was set + assert config_entry.runtime_data == mock_uhoo_coordinator + + # Verify platforms were set up + mock_forward_entry_setups.assert_awaited_once_with(config_entry, PLATFORMS) + + +async def test_async_setup_entry_unauthorized_error_on_login( + hass: HomeAssistant, + mock_uhoo_client, + patch_async_get_clientsession, +) -> None: + """Test setup with invalid API credentials (UnauthorizedError on login).""" + config_entry = create_mock_config_entry() + + # Simulate UnauthorizedError on login + mock_uhoo_client.login.side_effect = UnauthorizedError("Invalid API key") + + # Should raise ConfigEntryError + with pytest.raises(ConfigEntryError) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "Invalid API credentials" in str(exc_info.value) + + # Verify login was attempted + mock_uhoo_client.login.assert_awaited_once() + + # Verify setup_devices was NOT called + mock_uhoo_client.setup_devices.assert_not_called() + + +async def test_async_setup_entry_unauthorized_error_on_setup_devices( + hass: HomeAssistant, + mock_uhoo_client, + patch_async_get_clientsession, +) -> None: + """Test setup with UnauthorizedError during setup_devices.""" + config_entry = create_mock_config_entry() + + # Login succeeds but setup_devices fails with UnauthorizedError + mock_uhoo_client.login.return_value = None + mock_uhoo_client.setup_devices.side_effect = UnauthorizedError("Token expired") + + # Should raise ConfigEntryError + with pytest.raises(ConfigEntryError) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "Invalid API credentials" in str(exc_info.value) + + # Verify both methods were called + mock_uhoo_client.login.assert_awaited_once() + mock_uhoo_client.setup_devices.assert_awaited_once() + + +async def test_async_setup_entry_connection_error_on_login( + hass: HomeAssistant, + mock_uhoo_client, + patch_async_get_clientsession, +) -> None: + """Test setup with ClientConnectionError during login.""" + config_entry = create_mock_config_entry() + + # Simulate ClientConnectionError on login + mock_uhoo_client.login.side_effect = ClientConnectionError("Connection failed") + + # Should raise ConfigEntryNotReady + with pytest.raises(ConfigEntryNotReady) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "Cannot connect to uHoo servers" in str(exc_info.value) + + # Verify login was attempted + mock_uhoo_client.login.assert_awaited_once() + + # Verify setup_devices was NOT called + mock_uhoo_client.setup_devices.assert_not_called() + + +async def test_async_setup_entry_dns_error_on_login( + hass: HomeAssistant, + mock_uhoo_client, + patch_async_get_clientsession, +) -> None: + """Test setup with DNSError during login.""" + config_entry = create_mock_config_entry() + + # Simulate DNSError on login + mock_uhoo_client.login.side_effect = DNSError("DNS resolution failed") + + # Should raise ConfigEntryNotReady + with pytest.raises(ConfigEntryNotReady) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "Cannot connect to uHoo servers" in str(exc_info.value) + + # Verify login was attempted + mock_uhoo_client.login.assert_awaited_once() + + # Verify setup_devices was NOT called + mock_uhoo_client.setup_devices.assert_not_called() + + +async def test_async_setup_entry_uhoo_error_on_login( + hass: HomeAssistant, + mock_uhoo_client, + patch_async_get_clientsession, +) -> None: + """Test setup with UhooError during login.""" + config_entry = create_mock_config_entry() + + # Simulate UhooError on login + mock_uhoo_client.login.side_effect = UhooError("Some uhoo error") + + # Should raise ConfigEntryNotReady + with pytest.raises(ConfigEntryNotReady) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "Some uhoo error" in str(exc_info.value) + + # Verify login was attempted + mock_uhoo_client.login.assert_awaited_once() + + # Verify setup_devices was NOT called + mock_uhoo_client.setup_devices.assert_not_called() + + +async def test_async_setup_entry_coordinator_first_refresh_fails( + hass: HomeAssistant, + mock_uhoo_client, + mock_uhoo_coordinator, + patch_async_get_clientsession, + patch_uhoo_data_update_coordinator, +) -> None: + """Test setup where coordinator's first refresh fails.""" + config_entry = create_mock_config_entry() + + # Mock the hass.config_entries methods + hass.config_entries.async_forward_entry_setups = AsyncMock() + + # Simulate successful client setup but coordinator refresh fails + mock_uhoo_coordinator.async_config_entry_first_refresh.side_effect = Exception( + "First refresh failed" + ) + + # Should propagate the exception + with pytest.raises(Exception) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "First refresh failed" in str(exc_info.value) + + # Verify client setup was completed + mock_uhoo_client.login.assert_awaited_once() + mock_uhoo_client.setup_devices.assert_awaited_once() + + +async def test_async_setup_entry_platform_setup_error( + hass: HomeAssistant, + mock_uhoo_client, + mock_uhoo_coordinator, + patch_async_get_clientsession, + patch_uhoo_data_update_coordinator, +) -> None: + """Test setup where platform setup fails.""" + config_entry = create_mock_config_entry() + + # Mock the hass.config_entries methods + mock_forward_entry_setups = AsyncMock( + side_effect=Exception("Platform setup failed") + ) + hass.config_entries.async_forward_entry_setups = mock_forward_entry_setups + + # Should propagate the exception + with pytest.raises(Exception) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "Platform setup failed" in str(exc_info.value) + + # Verify client and coordinator setup was completed + mock_uhoo_client.login.assert_awaited_once() + mock_uhoo_client.setup_devices.assert_awaited_once() + mock_uhoo_coordinator.async_config_entry_first_refresh.assert_awaited_once() + + +async def test_async_unload_entry_success( + hass: HomeAssistant, +) -> None: + """Test successful unloading of a config entry.""" + config_entry = create_mock_config_entry() + + # Mock the hass.config_entries methods + mock_unload_platforms = AsyncMock(return_value=True) + hass.config_entries.async_unload_platforms = mock_unload_platforms + + # Call the unload function + result = await async_unload_entry(hass, config_entry) + + # Verify the unload was successful + assert result is True + + # Verify async_unload_platforms was called with correct parameters + mock_unload_platforms.assert_awaited_once_with(config_entry, PLATFORMS) + + +async def test_async_unload_entry_failure( + hass: HomeAssistant, +) -> None: + """Test failed unloading of a config entry.""" + config_entry = create_mock_config_entry() + + # Mock the hass.config_entries methods + mock_unload_platforms = AsyncMock(return_value=False) + hass.config_entries.async_unload_platforms = mock_unload_platforms + + # Call the unload function + result = await async_unload_entry(hass, config_entry) + + # Verify the unload failed + assert result is False + + # Verify async_unload_platforms was called with correct parameters + mock_unload_platforms.assert_awaited_once_with(config_entry, PLATFORMS) + + +async def test_async_setup_entry_missing_api_key( + hass: HomeAssistant, + mock_uhoo_client, + patch_async_get_clientsession, +) -> None: + """Test setup when API key is missing from config entry data.""" + # Create config entry without API key + config_entry = create_mock_config_entry(data={}) + + with pytest.raises(KeyError): + await async_setup_entry(hass, config_entry) + + +async def test_async_setup_entry_coordinator_creation_fails( + hass: HomeAssistant, + mock_uhoo_client, + patch_async_get_clientsession, +) -> None: + """Test setup when coordinator creation fails.""" + config_entry = create_mock_config_entry() + + # Patch UhooDataUpdateCoordinator to fail (not using the fixture) + with patch( + "homeassistant.components.uhoo.UhooDataUpdateCoordinator", + side_effect=Exception("Coordinator creation failed"), + ): + # Should propagate the exception + with pytest.raises(Exception) as exc_info: + await async_setup_entry(hass, config_entry) + + assert "Coordinator creation failed" in str(exc_info.value) + + # Since coordinator creation fails before login, login should NOT be called + mock_uhoo_client.login.assert_not_called() + + +async def test_async_setup_entry_with_devices( + hass: HomeAssistant, + mock_uhoo_client, + mock_uhoo_coordinator, + patch_async_get_clientsession, + patch_uhoo_data_update_coordinator, +) -> None: + """Test setup when client has devices.""" + config_entry = create_mock_config_entry() + + # Mock the hass.config_entries methods + hass.config_entries.async_forward_entry_setups = AsyncMock() + + # Setup mock client with devices + mock_uhoo_client.devices = {"device1": MagicMock(), "device2": MagicMock()} + + # Call the setup function + result = await async_setup_entry(hass, config_entry) + + # Verify the setup was successful + assert result is True + + # Verify client setup was called + mock_uhoo_client.login.assert_awaited_once() + mock_uhoo_client.setup_devices.assert_awaited_once() + + +async def test_async_setup_entry_with_empty_devices( + hass: HomeAssistant, + mock_uhoo_client, + mock_uhoo_coordinator, + patch_async_get_clientsession, + patch_uhoo_data_update_coordinator, +) -> None: + """Test setup when client has no devices.""" + config_entry = create_mock_config_entry() + + # Mock the hass.config_entries methods + hass.config_entries.async_forward_entry_setups = AsyncMock() + + # Setup mock client with empty devices + mock_uhoo_client.devices = {} + + # Call the setup function + result = await async_setup_entry(hass, config_entry) + + # Verify the setup was successful + assert result is True + + # Verify client setup was called + mock_uhoo_client.login.assert_awaited_once() + mock_uhoo_client.setup_devices.assert_awaited_once() diff --git a/tests/components/uhoo/test_sensor.py b/tests/components/uhoo/test_sensor.py new file mode 100644 index 00000000000000..a2d0fced8d072a --- /dev/null +++ b/tests/components/uhoo/test_sensor.py @@ -0,0 +1,370 @@ +"""Tests for sensor.py with Uhoo sensors.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.components.uhoo.sensor import ( + DOMAIN, + MANUFACTURER, + MODEL, + SENSOR_TYPES, + UhooSensorEntity, + UnitOfTemperature, + async_setup_entry, +) +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_uhoo_config + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor_snapshot( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_uhoo_config_entry: MockConfigEntry, + mock_uhoo_client: AsyncMock, + mock_device: AsyncMock, + mock_uhoo_coordinator, +) -> None: + """Test sensor setup with snapshot.""" + # Setup coordinator with one device + await setup_uhoo_config(hass, mock_uhoo_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_uhoo_config_entry.entry_id + ) + + +async def test_async_setup_entry( + hass: HomeAssistant, + mock_uhoo_config_entry, + mock_uhoo_coordinator, + mock_add_entities, + mock_device, +) -> None: + """Test setting up sensor entities.""" + # Setup coordinator with one device + serial_number = "23f9239m92m3ffkkdkdd" + mock_uhoo_coordinator.data = {serial_number: mock_device} + mock_uhoo_config_entry.runtime_data = mock_uhoo_coordinator + + await async_setup_entry(hass, mock_uhoo_config_entry, mock_add_entities) + + # Verify that entities were added + assert mock_add_entities.called + call_args = mock_add_entities.call_args[0][0] + entities = list(call_args) # Convert generator to list + + # Should create entities for each sensor type for the device + assert len(entities) == len(SENSOR_TYPES) + + # Check that entities have the correct unique IDs + humidity_entity = next( + e for e in entities if e._attr_unique_id == f"{serial_number}_humidity" + ) + assert humidity_entity is not None + assert humidity_entity.entity_description.key == "humidity" + + +async def test_async_setup_entry_multiple_devices( + hass: HomeAssistant, + mock_uhoo_config_entry, + mock_uhoo_coordinator, + mock_add_entities, + mock_device, +) -> None: + """Test setting up sensor entities for multiple devices.""" + # Setup coordinator with two devices + device1 = mock_device + device2 = MagicMock() + device2.device_name = "Device 2" + device2.serial_number = "device2_serial" + device2.humidity = 50.0 + device2.temperature = 21.0 + device2.co = 1.0 + device2.co2 = 400.0 + device2.pm25 = 10.0 + device2.air_pressure = 1010.0 + device2.tvoc = 100.0 + device2.no2 = 15.0 + device2.ozone = 25.0 + device2.virus_index = 1.0 + device2.mold_index = 1.0 + device2.user_settings = {"temp": "c"} + + mock_uhoo_coordinator.data = { + "23f9239m92m3ffkkdkdd": device1, + "device2_serial": device2, + } + mock_uhoo_config_entry.runtime_data = mock_uhoo_coordinator + + await async_setup_entry(hass, mock_uhoo_config_entry, mock_add_entities) + + # Verify that entities were added + assert mock_add_entities.called + entities = list(mock_add_entities.call_args[0][0]) + + # Should create entities for each sensor type for each device + assert len(entities) == len(SENSOR_TYPES) * 2 + + # Check entities for both devices exist + device1_humidity = any( + e._attr_unique_id == "23f9239m92m3ffkkdkdd_humidity" for e in entities + ) + device2_humidity = any( + e._attr_unique_id == "device2_serial_humidity" for e in entities + ) + assert device1_humidity + assert device2_humidity + + +def test_uhoo_sensor_entity_init( + mock_uhoo_coordinator, + mock_device, +) -> None: + """Test UhooSensorEntity initialization.""" + serial_number = "23f9239m92m3ffkkdkdd" + mock_uhoo_coordinator.data = {serial_number: mock_device} + + # Get humidity description + humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") + + # Create entity + entity = UhooSensorEntity(humidity_desc, serial_number, mock_uhoo_coordinator) + + # Check basic properties + assert entity.entity_description == humidity_desc + assert entity._serial_number == serial_number + assert entity._attr_unique_id == f"{serial_number}_humidity" + + # Check device info + assert entity._attr_device_info["identifiers"] == {(DOMAIN, serial_number)} + assert entity._attr_device_info["name"] == "Test Device" + assert entity._attr_device_info["manufacturer"] == MANUFACTURER + assert entity._attr_device_info["model"] == MODEL + assert entity._attr_device_info["serial_number"] == serial_number + + +def test_uhoo_sensor_entity_device_property( + mock_uhoo_coordinator, + mock_device, +) -> None: + """Test the device property returns correct device.""" + serial_number = "23f9239m92m3ffkkdkdd" + mock_uhoo_coordinator.data = {serial_number: mock_device} + + humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") + entity = UhooSensorEntity(humidity_desc, serial_number, mock_uhoo_coordinator) + + # Device property should return the correct device + assert entity.device == mock_device + assert entity.device.humidity == 45.5 + + +def test_uhoo_sensor_entity_available_property( + mock_uhoo_coordinator, + mock_device, +) -> None: + """Test the available property.""" + serial_number = "23f9239m92m3ffkkdkdd" + mock_uhoo_coordinator.data = {serial_number: mock_device} + + humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") + entity = UhooSensorEntity(humidity_desc, serial_number, mock_uhoo_coordinator) + + # Mock parent's available property + with patch( + "homeassistant.helpers.update_coordinator.CoordinatorEntity.available", + new_callable=lambda: True, + ): + # Entity should be available when device is in coordinator data + assert entity.available is True + + # Remove device from coordinator data + mock_uhoo_coordinator.data = {} + assert entity.available is False + + +def test_uhoo_sensor_entity_native_value( + mock_uhoo_coordinator, + mock_device, +) -> None: + """Test the native_value property.""" + serial_number = "23f9239m92m3ffkkdkdd" + mock_uhoo_coordinator.data = {serial_number: mock_device} + + # Test humidity sensor + humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") + entity = UhooSensorEntity(humidity_desc, serial_number, mock_uhoo_coordinator) + + assert entity.native_value == 45.5 + + # Test temperature sensor + temp_desc = next(d for d in SENSOR_TYPES if d.key == "temperature") + entity = UhooSensorEntity(temp_desc, serial_number, mock_uhoo_coordinator) + + assert entity.native_value == 22.0 + + +def test_uhoo_sensor_entity_native_unit_of_measurement_celsius( + mock_uhoo_coordinator, + mock_device, +) -> None: + """Test native_unit_of_measurement for temperature in Celsius.""" + serial_number = "23f9239m92m3ffkkdkdd" + mock_device.user_settings = {"temp": "c"} + mock_uhoo_coordinator.data = {serial_number: mock_device} + + temp_desc = next(d for d in SENSOR_TYPES if d.key == "temperature") + entity = UhooSensorEntity(temp_desc, serial_number, mock_uhoo_coordinator) + + assert entity.native_unit_of_measurement == UnitOfTemperature.CELSIUS + + +def test_uhoo_sensor_entity_native_unit_of_measurement_fahrenheit( + mock_uhoo_coordinator, + mock_device, +) -> None: + """Test native_unit_of_measurement for temperature in Fahrenheit.""" + serial_number = "23f9239m92m3ffkkdkdd" + mock_device.user_settings = {"temp": "f"} + mock_uhoo_coordinator.data = {serial_number: mock_device} + + temp_desc = next(d for d in SENSOR_TYPES if d.key == "temperature") + entity = UhooSensorEntity(temp_desc, serial_number, mock_uhoo_coordinator) + + assert entity.native_unit_of_measurement == UnitOfTemperature.FAHRENHEIT + + +def test_uhoo_sensor_entity_native_unit_of_measurement_other_sensors( + mock_uhoo_coordinator, + mock_device, +) -> None: + """Test native_unit_of_measurement for non-temperature sensors.""" + serial_number = "23f9239m92m3ffkkdkdd" + mock_uhoo_coordinator.data = {serial_number: mock_device} + + # Test humidity sensor (should use default from description) + humidity_desc = next(d for d in SENSOR_TYPES if d.key == "humidity") + entity = UhooSensorEntity(humidity_desc, serial_number, mock_uhoo_coordinator) + + # For non-temperature sensors, it should return the description's unit + assert entity.native_unit_of_measurement == PERCENTAGE + + +async def test_async_setup_entry_no_devices( + hass: HomeAssistant, + mock_uhoo_config_entry, + mock_uhoo_coordinator, + mock_add_entities, +) -> None: + """Test setting up sensor entities when there are no devices.""" + mock_uhoo_coordinator.data = {} # No devices + mock_uhoo_config_entry.runtime_data = mock_uhoo_coordinator + + await async_setup_entry(hass, mock_uhoo_config_entry, mock_add_entities) + + # Should still call add_entities but with empty generator + assert mock_add_entities.called + + # Convert generator to list to check it's empty + entities = list(mock_add_entities.call_args[0][0]) + assert len(entities) == 0 + + +def test_all_sensor_types_have_value_functions() -> None: + """Test that all sensor types have valid value functions.""" + for sensor_desc in SENSOR_TYPES: + assert hasattr(sensor_desc, "value_fn") + assert callable(sensor_desc.value_fn) + + # Create a mock device to test the lambda + mock_device = MagicMock() + # Set all possible attributes to non-None values + fields = [ + "humidity", + "temperature", + "co", + "co2", + "pm25", + "air_pressure", + "tvoc", + "no2", + "ozone", + "virus_index", + "mold_index", + ] + for attr in fields: + setattr(mock_device, attr, 1.0) + + # The value function should return a float or None + result = sensor_desc.value_fn(mock_device) + assert result is None or isinstance(result, (int, float)) + + +@pytest.mark.parametrize( + ("sensor_key", "expected_device_class"), + [ + ("humidity", SensorDeviceClass.HUMIDITY), + ("temperature", SensorDeviceClass.TEMPERATURE), + ("co", SensorDeviceClass.CO), + ("co2", SensorDeviceClass.CO2), + ("pm25", SensorDeviceClass.PM25), + ("air_pressure", SensorDeviceClass.PRESSURE), + ("tvoc", SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS), + ("no2", SensorDeviceClass.NITROGEN_DIOXIDE), + ("ozone", SensorDeviceClass.OZONE), + ("virus_index", None), # No device class for virus_index + ("mold_index", None), # No device class for mold_index + ], +) +def test_sensor_device_classes(sensor_key, expected_device_class) -> None: + """Test that each sensor has the correct device class.""" + sensor_desc = next(d for d in SENSOR_TYPES if d.key == sensor_key) + assert sensor_desc.device_class == expected_device_class + + +def test_sensor_state_classes() -> None: + """Test that all sensors have MEASUREMENT state class.""" + for sensor_desc in SENSOR_TYPES: + assert sensor_desc.state_class == SensorStateClass.MEASUREMENT + + +def test_temperature_sensor_unit_conversion_logic() -> None: + """Test the logic for temperature unit conversion.""" + serial_number = "23f9239m92m3ffkkdkdd" + + # Create a mock device with Celsius setting + mock_device_c = MagicMock() + mock_device_c.device_name = "Test Device" + mock_device_c.user_settings = {"temp": "c"} + + # Create a mock device with Fahrenheit setting + mock_device_f = MagicMock() + mock_device_f.device_name = "Test Device" + mock_device_f.user_settings = {"temp": "f"} + + # Mock coordinator with Celsius setting + coordinator_c = MagicMock() + coordinator_c.data = {serial_number: mock_device_c} + + # Mock coordinator with Fahrenheit setting + coordinator_f = MagicMock() + coordinator_f.data = {serial_number: mock_device_f} + + temp_desc = next(d for d in SENSOR_TYPES if d.key == "temperature") + + # Create entities with different coordinators + entity_c = UhooSensorEntity(temp_desc, serial_number, coordinator_c) + entity_f = UhooSensorEntity(temp_desc, serial_number, coordinator_f) + + # Test the actual property calls + assert entity_c.native_unit_of_measurement == UnitOfTemperature.CELSIUS + assert entity_f.native_unit_of_measurement == UnitOfTemperature.FAHRENHEIT