mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 04:05:20 +01:00
Uhoo integration (#158887)
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
46
homeassistant/components/uhoo/__init__.py
Normal file
46
homeassistant/components/uhoo/__init__.py
Normal 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)
|
||||
67
homeassistant/components/uhoo/config_flow.py
Normal file
67
homeassistant/components/uhoo/config_flow.py
Normal 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,
|
||||
)
|
||||
26
homeassistant/components/uhoo/const.py
Normal file
26
homeassistant/components/uhoo/const.py
Normal 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"
|
||||
40
homeassistant/components/uhoo/coordinator.py
Normal file
40
homeassistant/components/uhoo/coordinator.py
Normal 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
|
||||
10
homeassistant/components/uhoo/manifest.json
Normal file
10
homeassistant/components/uhoo/manifest.json
Normal 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"]
|
||||
}
|
||||
60
homeassistant/components/uhoo/quality_scale.yaml
Normal file
60
homeassistant/components/uhoo/quality_scale.yaml
Normal 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
|
||||
197
homeassistant/components/uhoo/sensor.py
Normal file
197
homeassistant/components/uhoo/sensor.py
Normal 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
|
||||
43
homeassistant/components/uhoo/strings.json
Normal file
43
homeassistant/components/uhoo/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -723,6 +723,7 @@ FLOWS = {
|
||||
"twilio",
|
||||
"twinkly",
|
||||
"twitch",
|
||||
"uhoo",
|
||||
"ukraine_alarm",
|
||||
"unifi",
|
||||
"unifiprotect",
|
||||
|
||||
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
13
tests/components/uhoo/__init__.py
Normal file
13
tests/components/uhoo/__init__.py
Normal 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()
|
||||
111
tests/components/uhoo/conftest.py
Normal file
111
tests/components/uhoo/conftest.py
Normal 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
|
||||
595
tests/components/uhoo/snapshots/test_sensor.ambr
Normal file
595
tests/components/uhoo/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
96
tests/components/uhoo/test_config_flow.py
Normal file
96
tests/components/uhoo/test_config_flow.py
Normal 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
|
||||
64
tests/components/uhoo/test_init.py
Normal file
64
tests/components/uhoo/test_init.py
Normal 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
|
||||
138
tests/components/uhoo/test_sensor.py
Normal file
138
tests/components/uhoo/test_sensor.py
Normal 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
|
||||
Reference in New Issue
Block a user