Uhoo integration (#158887)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Joshua Monta
2026-01-28 04:08:41 +08:00
committed by GitHub
parent 1bb4c9d213
commit 70e84526cc
19 changed files with 1521 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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%]"
}
}
}
}

View File

@@ -723,6 +723,7 @@ FLOWS = {
"twilio",
"twinkly",
"twitch",
"uhoo",
"ukraine_alarm",
"unifi",
"unifiprotect",

View File

@@ -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",

3
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Carbon dioxide',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CO2: 'carbon_dioxide'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppm',
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_carbon_dioxide',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '450.0',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_carbon_monoxide-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Carbon monoxide',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CO: 'carbon_monoxide'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppm',
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_carbon_monoxide',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_humidity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_device_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Humidity',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '45.5',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_mold_index-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_mold_index',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_nitrogen_dioxide-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppb',
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_nitrogen_dioxide',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20.0',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_ozone-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_device_ozone',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppb',
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_ozone',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '30.0',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_pm2_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'PM2.5',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM25: 'pm25'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'μg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_pm2_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '12.3',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_pressure-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_device_pressure',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Pressure',
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.PRESSURE: 'pressure'>,
'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': <UnitOfPressure.HPA: 'hPa'>,
})
# ---
# name: test_sensor_snapshot[sensor.test_device_pressure-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pressure',
'friendly_name': 'Test Device Pressure',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPressure.HPA: 'hPa'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_pressure',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1013.25',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_device_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_snapshot[sensor.test_device_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Test Device Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '22.0',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_virus_index-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_virus_index',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2.0',
})
# ---
# name: test_sensor_snapshot[sensor.test_device_volatile_organic_compounds-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Volatile organic compounds',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: 'volatile_organic_compounds'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppb',
}),
'context': <ANY>,
'entity_id': 'sensor.test_device_volatile_organic_compounds',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '150.0',
})
# ---

View File

@@ -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

View File

@@ -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

View File

@@ -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