diff --git a/CODEOWNERS b/CODEOWNERS index 80026e95a97..d531e1ccebd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -686,6 +686,8 @@ build.json @home-assistant/supervisor /tests/components/icloud/ @Quentame @nzapponi /homeassistant/components/idasen_desk/ @abmantis /tests/components/idasen_desk/ @abmantis +/homeassistant/components/igloohome/ @keithle888 +/tests/components/igloohome/ @keithle888 /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte /homeassistant/components/image/ @home-assistant/core diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py new file mode 100644 index 00000000000..5e5e21452cf --- /dev/null +++ b/homeassistant/components/igloohome/__init__.py @@ -0,0 +1,61 @@ +"""The igloohome integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from aiohttp import ClientError +from igloohome_api import ( + Api as IgloohomeApi, + ApiException, + Auth as IgloohomeAuth, + AuthException, + GetDeviceInfoResponse, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +@dataclass +class IgloohomeRuntimeData: + """Holding class for runtime data.""" + + api: IgloohomeApi + devices: list[GetDeviceInfoResponse] + + +type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData] + + +async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool: + """Set up igloohome from a config entry.""" + + authentication = IgloohomeAuth( + session=async_get_clientsession(hass), + client_id=entry.data[CONF_CLIENT_ID], + client_secret=entry.data[CONF_CLIENT_SECRET], + ) + + api = IgloohomeApi(auth=authentication) + try: + devices = (await api.get_devices()).payload + except AuthException as e: + raise ConfigEntryError from e + except (ApiException, ClientError) as e: + raise ConfigEntryNotReady from e + + entry.runtime_data = IgloohomeRuntimeData(api, devices) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/igloohome/config_flow.py b/homeassistant/components/igloohome/config_flow.py new file mode 100644 index 00000000000..a1d84900a03 --- /dev/null +++ b/homeassistant/components/igloohome/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for igloohome integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientError +from igloohome_api import Auth as IgloohomeAuth, AuthException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_CLIENT_ID): str, + vol.Required(CONF_CLIENT_SECRET): str, + } +) + + +class IgloohomeConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for igloohome.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the config flow step.""" + + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_CLIENT_ID: user_input[CONF_CLIENT_ID], + } + ) + auth = IgloohomeAuth( + session=async_get_clientsession(self.hass), + client_id=user_input[CONF_CLIENT_ID], + client_secret=user_input[CONF_CLIENT_SECRET], + ) + try: + await auth.async_get_access_token() + except AuthException: + errors["base"] = "invalid_auth" + except ClientError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Client Credentials", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/igloohome/const.py b/homeassistant/components/igloohome/const.py new file mode 100644 index 00000000000..379c3bfbc1a --- /dev/null +++ b/homeassistant/components/igloohome/const.py @@ -0,0 +1,3 @@ +"""Constants for the igloohome integration.""" + +DOMAIN = "igloohome" diff --git a/homeassistant/components/igloohome/entity.py b/homeassistant/components/igloohome/entity.py new file mode 100644 index 00000000000..151cfbb3d2a --- /dev/null +++ b/homeassistant/components/igloohome/entity.py @@ -0,0 +1,32 @@ +"""Implementation of a base entity that belongs to all igloohome devices.""" + +from igloohome_api import Api as IgloohomeApi, GetDeviceInfoResponse + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class IgloohomeBaseEntity(Entity): + """A base entity that is a part of all igloohome devices.""" + + _attr_has_entity_name = True + + def __init__( + self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi, unique_key: str + ) -> None: + """Initialize the base device class.""" + self.api = api + self.api_device_info = api_device_info + # Register the entity as part of a device. + self._attr_device_info = dr.DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, api_device_info.deviceId) + }, + name=api_device_info.deviceName, + model=api_device_info.type, + ) + # Set the unique ID of the entity. + self._attr_unique_id = f"{unique_key}_{api_device_info.deviceId}" diff --git a/homeassistant/components/igloohome/manifest.json b/homeassistant/components/igloohome/manifest.json new file mode 100644 index 00000000000..28e287db2ab --- /dev/null +++ b/homeassistant/components/igloohome/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "igloohome", + "name": "igloohome", + "codeowners": ["@keithle888"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/igloohome", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["igloohome-api==0.0.6"] +} diff --git a/homeassistant/components/igloohome/quality_scale.yaml b/homeassistant/components/igloohome/quality_scale.yaml new file mode 100644 index 00000000000..432777cb729 --- /dev/null +++ b/homeassistant/components/igloohome/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration does not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + config-entry-unloading: done + action-exceptions: + status: exempt + comment: | + Integration has no actions and is a read-only platform. + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No issues requiring a repair at the moment. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/igloohome/sensor.py b/homeassistant/components/igloohome/sensor.py new file mode 100644 index 00000000000..7f25798e454 --- /dev/null +++ b/homeassistant/components/igloohome/sensor.py @@ -0,0 +1,68 @@ +"""Implementation of the sensor platform.""" + +from datetime import timedelta +import logging + +from aiohttp import ClientError +from igloohome_api import Api as IgloohomeApi, ApiException, GetDeviceInfoResponse + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IgloohomeConfigEntry +from .entity import IgloohomeBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +SCAN_INTERVAL = timedelta(hours=1) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IgloohomeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities.""" + + async_add_entities( + ( + IgloohomeBatteryEntity( + api_device_info=device, + api=entry.runtime_data.api, + ) + for device in entry.runtime_data.devices + if device.batteryLevel is not None + ), + update_before_add=True, + ) + + +class IgloohomeBatteryEntity(IgloohomeBaseEntity, SensorEntity): + """Implementation of a device that has a battery.""" + + _attr_native_unit_of_measurement = "%" + _attr_device_class = SensorDeviceClass.BATTERY + + def __init__( + self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi + ) -> None: + """Initialize the class.""" + super().__init__( + api_device_info=api_device_info, + api=api, + unique_key="battery", + ) + + async def async_update(self) -> None: + """Update the battery level.""" + try: + response = await self.api.get_device_info( + deviceId=self.api_device_info.deviceId + ) + except (ApiException, ClientError): + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = response.batteryLevel diff --git a/homeassistant/components/igloohome/strings.json b/homeassistant/components/igloohome/strings.json new file mode 100644 index 00000000000..463964c58ed --- /dev/null +++ b/homeassistant/components/igloohome/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "Copy & paste your [API access credentials](https://access.igloocompany.co/api-access) to give Home Assistant access to your account.", + "data": { + "client_id": "Client ID", + "client_secret": "Client secret" + }, + "data_description": { + "client_id": "Client ID provided by your iglooaccess account.", + "client_secret": "Client Secret provided by your iglooaccess 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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 731978c0459..d33198cb023 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -278,6 +278,7 @@ FLOWS = { "icloud", "idasen_desk", "ifttt", + "igloohome", "imap", "imgw_pib", "improv_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d894327cb4b..5f0fdc0618f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2789,6 +2789,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "igloohome": { + "name": "igloohome", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ign_sismologia": { "name": "IGN Sismolog\u00eda", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index e04d43b962f..30127795e9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1192,6 +1192,9 @@ ifaddr==0.2.0 # homeassistant.components.iglo iglo==1.2.7 +# homeassistant.components.igloohome +igloohome-api==0.0.6 + # homeassistant.components.ihc ihcsdk==2.8.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa548f9f4b4..dc84da16f7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1009,6 +1009,9 @@ idasen-ha==2.6.3 # homeassistant.components.network ifaddr==0.2.0 +# homeassistant.components.igloohome +igloohome-api==0.0.6 + # homeassistant.components.imgw_pib imgw_pib==1.0.7 diff --git a/tests/components/igloohome/__init__.py b/tests/components/igloohome/__init__.py new file mode 100644 index 00000000000..835414b2825 --- /dev/null +++ b/tests/components/igloohome/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the igloohome integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the igloohome integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/igloohome/conftest.py b/tests/components/igloohome/conftest.py new file mode 100644 index 00000000000..d630f5af7cb --- /dev/null +++ b/tests/components/igloohome/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the igloohome tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from igloohome_api import GetDeviceInfoResponse, GetDevicesResponse +import pytest + +from homeassistant.components.igloohome.const import DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +GET_DEVICE_INFO_RESPONSE_LOCK = GetDeviceInfoResponse( + id="123456", + type="Lock", + deviceId="OE1X123cbb11", + deviceName="Front Door", + pairedAt="2024-11-09T11:19:25+00:00", + homeId=[], + linkedDevices=[], + batteryLevel=100, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.igloohome.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def mock_auth() -> Generator[AsyncMock]: + """Set up the mock usages of the igloohome_api.Auth class. Defaults to always successfully operate.""" + with patch( + "homeassistant.components.igloohome.config_flow.IgloohomeAuth.async_get_access_token", + return_value="mock_access_token", + ) as mock_auth: + yield mock_auth + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Client Credentials", + domain=DOMAIN, + version=1, + data={CONF_CLIENT_ID: "client-id", CONF_CLIENT_SECRET: "client-secret"}, + ) + + +@pytest.fixture(autouse=True) +def mock_api() -> Generator[AsyncMock]: + """Set up the Api module. Defaults to always returning a single lock.""" + with ( + patch( + "homeassistant.components.igloohome.IgloohomeApi", + autospec=True, + ) as api_mock, + ): + api = api_mock.return_value + api.get_devices.return_value = GetDevicesResponse( + nextCursor="", + payload=[GET_DEVICE_INFO_RESPONSE_LOCK], + ) + api.get_device_info.return_value = GET_DEVICE_INFO_RESPONSE_LOCK + yield api diff --git a/tests/components/igloohome/snapshots/test_sensor.ambr b/tests/components/igloohome/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f65baa484a0 --- /dev/null +++ b/tests/components/igloohome/snapshots/test_sensor.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_sensors[sensor.front_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'igloohome', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_OE1X123cbb11', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.front_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Front Door Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/igloohome/test_config_flow.py b/tests/components/igloohome/test_config_flow.py new file mode 100644 index 00000000000..1f7656d88d6 --- /dev/null +++ b/tests/components/igloohome/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the igloohome config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from igloohome_api import AuthException +import pytest + +from homeassistant import config_entries +from homeassistant.components.igloohome.const import DOMAIN +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +FORM_USER_INPUT = { + CONF_CLIENT_ID: "client-id", + CONF_CLIENT_SECRET: "client-secret", +} + + +async def test_form_valid_input( + hass: HomeAssistant, + mock_setup_entry: Generator[AsyncMock], + mock_auth: Generator[AsyncMock], +) -> None: + """Test that the form correct reacts to valid input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Client Credentials" + assert result["data"] == FORM_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "result_error"), + [(AuthException(), "invalid_auth"), (ClientError(), "cannot_connect")], +) +async def test_form_invalid_input( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_auth: Generator[AsyncMock], + exception: Exception, + result_error: str, +) -> None: + """Tests where we handle errors in the config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_auth.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": result_error} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + mock_auth.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Client Credentials" + assert result["data"] == FORM_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_abort_on_matching_entry( + hass: HomeAssistant, + mock_config_entry: Generator[AsyncMock], + mock_auth: Generator[AsyncMock], +) -> None: + """Tests where we handle errors in the config flow.""" + # Create first config flow. + await setup_integration(hass, mock_config_entry) + + # Attempt another config flow with the same client credentials + # and ensure that FlowResultType.ABORT is returned. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + FORM_USER_INPUT, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/igloohome/test_sensor.py b/tests/components/igloohome/test_sensor.py new file mode 100644 index 00000000000..bfc60574450 --- /dev/null +++ b/tests/components/igloohome/test_sensor.py @@ -0,0 +1,26 @@ +"""Test sensors for igloohome integration.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the igloohome sensors.""" + with patch("homeassistant.components.igloohome.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)