Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions homeassistant/components/uhoo/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
102 changes: 102 additions & 0 deletions homeassistant/components/uhoo/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Custom uhoo config flow setup."""

from typing import Any

from aiodns.error import DNSError
from aiohttp.client_exceptions import ClientConnectorDNSError
from uhooapi import Client
from uhooapi.errors import UnauthorizedError
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)

from .const import DOMAIN, LOGGER

USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)


class UhooFlowHandler(ConfigFlow, domain=DOMAIN):
"""Setup Uhoo flow handlers."""

VERSION = 1

def __init__(self) -> None:
"""Initialize the config flow."""
self._errors: dict = {}

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the start of the config flow."""
self._errors = {}

if user_input is None:
return await self._show_config_form(user_input)

# Set the unique ID for the config flow.
api_key = user_input[CONF_API_KEY]
if not api_key:
self._errors["base"] = "invalid_auth"
return await self._show_config_form(user_input)

await self.async_set_unique_id(api_key)
self._async_abort_entries_match()

valid = await self._test_credentials(api_key)
if not valid:
self._errors["base"] = "invalid_auth"
return await self._show_config_form(user_input)

key_snippet = api_key[-5:]
return self.async_create_entry(title=f"uHoo ({key_snippet})", data=user_input)

async def _show_config_form(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
if user_input is None:
user_input = {}
user_input[CONF_API_KEY] = ""
return await self._show_config_form(user_input)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
USER_DATA_SCHEMA, user_input or {}
),
errors=self._errors,
)

async def _test_credentials(self, api_key):
"""Return true if credentials is valid."""
try:
session = async_create_clientsession(self.hass)
client = Client(api_key, session, debug=True)
await client.login()
except UnauthorizedError as err:
LOGGER.error(
f"Error: received a 401 Unauthorized error attempting to login:\n{err}"
)
return False
except ConnectionError:
LOGGER.error("ConnectionError: cannot connect to uhoo server")
return False
except (ClientConnectorDNSError, DNSError):
LOGGER.error("ClientConnectorDNSError: cannot connect to uhoo server")
return False
else:
return True
26 changes: 26 additions & 0 deletions homeassistant/components/uhoo/const.py
Original file line number Diff line number Diff line change
@@ -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"
46 changes: 46 additions & 0 deletions homeassistant/components/uhoo/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Custom uhoo data update coordinator."""

import asyncio

from aiohttp.client_exceptions import ClientConnectorDNSError
from uhooapi import Client, Device
from uhooapi.errors import UhooError

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN, LOGGER, UPDATE_INTERVAL

type UhooConfigEntry = ConfigEntry["UhooDataUpdateCoordinator"]


class UhooDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Class to manage fetching data from the uHoo API."""

def __init__(
self, hass: HomeAssistant, client: Client, entry: UhooConfigEntry
) -> None:
"""Initialize DataUpdateCoordinator."""
self.client = client
self.entry = entry
super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)

async def _async_update_data(self) -> dict[str, Device]:
try:
await self.client.login()
if self.client.devices:
await asyncio.gather(
*[
self.client.get_latest_data(device_id)
for device_id in self.client.devices
]
)
except TimeoutError as error:
raise UpdateFailed from error
except ClientConnectorDNSError as error:
raise UpdateFailed from error
except UhooError as error:
raise UpdateFailed(f"The device is unavailable: {error}") from error
else:
return self.client.devices
10 changes: 10 additions & 0 deletions homeassistant/components/uhoo/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "uhoo",
"name": "uHoo",
"codeowners": ["@getuhoo", "@joshsmonta"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/uhooair",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["uhooapi==1.2.3"]
}
60 changes: 60 additions & 0 deletions homeassistant/components/uhoo/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading