Add integration for igloohome devices (#130657)

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
This commit is contained in:
Keith 2025-01-01 12:55:04 +01:00 committed by GitHub
parent 2be578a33f
commit 809629c0e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 616 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
"""Constants for the igloohome integration."""
DOMAIN = "igloohome"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -278,6 +278,7 @@ FLOWS = {
"icloud",
"idasen_desk",
"ifttt",
"igloohome",
"imap",
"imgw_pib",
"improv_ble",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.front_door_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'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': <ANY>,
'entity_id': 'sensor.front_door_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '100',
})
# ---

View File

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

View File

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