diff --git a/.coveragerc b/.coveragerc index facb27c893c..f6e22778b55 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1078,6 +1078,9 @@ omit = homeassistant/components/rova/sensor.py homeassistant/components/rpi_camera/* homeassistant/components/rtorrent/sensor.py + homeassistant/components/ruuvi_gateway/__init__.py + homeassistant/components/ruuvi_gateway/bluetooth.py + homeassistant/components/ruuvi_gateway/coordinator.py homeassistant/components/russound_rio/media_player.py homeassistant/components/russound_rnet/media_player.py homeassistant/components/sabnzbd/__init__.py diff --git a/.strict-typing b/.strict-typing index d79922d8e7b..89cbc8bec65 100644 --- a/.strict-typing +++ b/.strict-typing @@ -248,6 +248,7 @@ homeassistant.components.rituals_perfume_genie.* homeassistant.components.roku.* homeassistant.components.rpi_power.* homeassistant.components.rtsp_to_webrtc.* +homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvitag_ble.* homeassistant.components.samsungtv.* homeassistant.components.scene.* diff --git a/CODEOWNERS b/CODEOWNERS index cb3100fdba3..40dc9b4c167 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -979,6 +979,8 @@ build.json @home-assistant/supervisor /tests/components/rtsp_to_webrtc/ @allenporter /homeassistant/components/ruckus_unleashed/ @gabe565 /tests/components/ruckus_unleashed/ @gabe565 +/homeassistant/components/ruuvi_gateway/ @akx +/tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx /tests/components/ruuvitag_ble/ @akx /homeassistant/components/sabnzbd/ @shaiu diff --git a/homeassistant/components/ruuvi_gateway/__init__.py b/homeassistant/components/ruuvi_gateway/__init__.py new file mode 100644 index 00000000000..59d37abbf7b --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/__init__.py @@ -0,0 +1,42 @@ +"""The Ruuvi Gateway integration.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from .bluetooth import async_connect_scanner +from .const import DOMAIN, SCAN_INTERVAL +from .coordinator import RuuviGatewayUpdateCoordinator +from .models import RuuviGatewayRuntimeData + +_LOGGER = logging.getLogger(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ruuvi Gateway from a config entry.""" + coordinator = RuuviGatewayUpdateCoordinator( + hass, + logger=_LOGGER, + name=entry.title, + update_interval=SCAN_INTERVAL, + host=entry.data[CONF_HOST], + token=entry.data[CONF_TOKEN], + ) + scanner, unload_scanner = async_connect_scanner(hass, entry, coordinator) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RuuviGatewayRuntimeData( + update_coordinator=coordinator, + scanner=scanner, + ) + entry.async_on_unload(unload_scanner) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, []): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py new file mode 100644 index 00000000000..f5748b8b4e9 --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -0,0 +1,103 @@ +"""Bluetooth support for Ruuvi Gateway.""" +from __future__ import annotations + +from collections.abc import Callable +import datetime +import logging + +from home_assistant_bluetooth import BluetoothServiceInfoBleak + +from homeassistant.components.bluetooth import ( + BaseHaRemoteScanner, + async_get_advertisement_callback, + async_register_scanner, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback + +from .const import OLD_ADVERTISEMENT_CUTOFF +from .coordinator import RuuviGatewayUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class RuuviGatewayScanner(BaseHaRemoteScanner): + """Scanner for Ruuvi Gateway.""" + + def __init__( + self, + hass: HomeAssistant, + scanner_id: str, + name: str, + new_info_callback: Callable[[BluetoothServiceInfoBleak], None], + *, + coordinator: RuuviGatewayUpdateCoordinator, + ) -> None: + """Initialize the scanner, using the given update coordinator as data source.""" + super().__init__( + hass, + scanner_id, + name, + new_info_callback, + connector=None, + connectable=False, + ) + self.coordinator = coordinator + + @callback + def _async_handle_new_data(self) -> None: + now = datetime.datetime.now() + for tag_data in self.coordinator.data: + if now - tag_data.datetime > OLD_ADVERTISEMENT_CUTOFF: + # Don't process data that is older than 10 minutes + continue + anno = tag_data.parse_announcement() + self._async_on_advertisement( + address=tag_data.mac, + rssi=tag_data.rssi, + local_name=anno.local_name, + service_data=anno.service_data, + service_uuids=anno.service_uuids, + manufacturer_data=anno.manufacturer_data, + tx_power=anno.tx_power, + details={}, + ) + + @callback + def start_polling(self) -> CALLBACK_TYPE: + """Start polling; return a callback to stop polling.""" + return self.coordinator.async_add_listener(self._async_handle_new_data) + + +def async_connect_scanner( + hass: HomeAssistant, + entry: ConfigEntry, + coordinator: RuuviGatewayUpdateCoordinator, +) -> tuple[RuuviGatewayScanner, CALLBACK_TYPE]: + """Connect scanner and start polling.""" + assert entry.unique_id is not None + source = str(entry.unique_id) + _LOGGER.debug( + "%s [%s]: Connecting scanner", + entry.title, + source, + ) + scanner = RuuviGatewayScanner( + hass=hass, + scanner_id=source, + name=entry.title, + new_info_callback=async_get_advertisement_callback(hass), + coordinator=coordinator, + ) + unload_callbacks = [ + async_register_scanner(hass, scanner, connectable=False), + scanner.async_setup(), + scanner.start_polling(), + ] + + @callback + def _async_unload() -> None: + for unloader in unload_callbacks: + unloader() + + return (scanner, _async_unload) diff --git a/homeassistant/components/ruuvi_gateway/config_flow.py b/homeassistant/components/ruuvi_gateway/config_flow.py new file mode 100644 index 00000000000..178c55a53e4 --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/config_flow.py @@ -0,0 +1,89 @@ +"""Config flow for Ruuvi Gateway integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import aioruuvigateway.api as gw_api +from aioruuvigateway.excs import CannotConnect, InvalidAuth + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.httpx_client import get_async_client + +from . import DOMAIN +from .schemata import CONFIG_SCHEMA, get_config_schema_with_default_host + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ruuvi Gateway.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + super().__init__() + self.config_schema = CONFIG_SCHEMA + + async def _async_validate( + self, + user_input: dict[str, Any], + ) -> tuple[FlowResult | None, dict[str, str]]: + """Validate configuration (either discovered or user input).""" + errors: dict[str, str] = {} + + try: + async with get_async_client(self.hass) as client: + resp = await gw_api.get_gateway_history_data( + client, + host=user_input[CONF_HOST], + bearer_token=user_input[CONF_TOKEN], + ) + await self.async_set_unique_id( + format_mac(resp.gw_mac), raise_on_progress=False + ) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]} + ) + info = {"title": f"Ruuvi Gateway {resp.gw_mac_suffix}"} + return ( + self.async_create_entry(title=info["title"], data=user_input), + errors, + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return (None, errors) + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + """Handle requesting or validating user input.""" + if user_input is not None: + result, errors = await self._async_validate(user_input) + else: + result, errors = None, {} + if result is not None: + return result + return self.async_show_form( + step_id="user", + data_schema=self.config_schema, + errors=(errors or None), + ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Prepare configuration for a DHCP discovered Ruuvi Gateway.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + self.config_schema = get_config_schema_with_default_host(host=discovery_info.ip) + return await self.async_step_user() diff --git a/homeassistant/components/ruuvi_gateway/const.py b/homeassistant/components/ruuvi_gateway/const.py new file mode 100644 index 00000000000..609bad9a226 --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/const.py @@ -0,0 +1,12 @@ +"""Constants for the Ruuvi Gateway integration.""" +from datetime import timedelta + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) + +DOMAIN = "ruuvi_gateway" +SCAN_INTERVAL = timedelta(seconds=5) +OLD_ADVERTISEMENT_CUTOFF = timedelta( + seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS +) diff --git a/homeassistant/components/ruuvi_gateway/coordinator.py b/homeassistant/components/ruuvi_gateway/coordinator.py new file mode 100644 index 00000000000..38bc3b0e201 --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/coordinator.py @@ -0,0 +1,49 @@ +"""Update coordinator for Ruuvi Gateway.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioruuvigateway.api import get_gateway_history_data +from aioruuvigateway.models import TagData + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +class RuuviGatewayUpdateCoordinator(DataUpdateCoordinator[list[TagData]]): + """Polls the gateway for data and returns a list of TagData objects that have changed since the last poll.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + update_interval: timedelta | None = None, + host: str, + token: str, + ) -> None: + """Initialize the coordinator using the given configuration (host, token).""" + super().__init__(hass, logger, name=name, update_interval=update_interval) + self.host = host + self.token = token + self.last_tag_datas: dict[str, TagData] = {} + + async def _async_update_data(self) -> list[TagData]: + changed_tag_datas: list[TagData] = [] + async with get_async_client(self.hass) as client: + data = await get_gateway_history_data( + client, + host=self.host, + bearer_token=self.token, + ) + for tag in data.tags: + if ( + tag.mac not in self.last_tag_datas + or self.last_tag_datas[tag.mac].data != tag.data + ): + changed_tag_datas.append(tag) + self.last_tag_datas[tag.mac] = tag + return changed_tag_datas diff --git a/homeassistant/components/ruuvi_gateway/manifest.json b/homeassistant/components/ruuvi_gateway/manifest.json new file mode 100644 index 00000000000..1a42ebf6c17 --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "ruuvi_gateway", + "name": "Ruuvi Gateway", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ruuvi_gateway", + "codeowners": ["@akx"], + "requirements": ["aioruuvigateway==0.0.2"], + "iot_class": "local_polling", + "dhcp": [ + { + "hostname": "ruuvigateway*" + } + ] +} diff --git a/homeassistant/components/ruuvi_gateway/models.py b/homeassistant/components/ruuvi_gateway/models.py new file mode 100644 index 00000000000..adb405f0bf8 --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/models.py @@ -0,0 +1,15 @@ +"""Models for Ruuvi Gateway integration.""" +from __future__ import annotations + +import dataclasses + +from .bluetooth import RuuviGatewayScanner +from .coordinator import RuuviGatewayUpdateCoordinator + + +@dataclasses.dataclass(frozen=True) +class RuuviGatewayRuntimeData: + """Runtime data for Ruuvi Gateway integration.""" + + update_coordinator: RuuviGatewayUpdateCoordinator + scanner: RuuviGatewayScanner diff --git a/homeassistant/components/ruuvi_gateway/schemata.py b/homeassistant/components/ruuvi_gateway/schemata.py new file mode 100644 index 00000000000..eec86cd129f --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/schemata.py @@ -0,0 +1,18 @@ +"""Schemata for ruuvi_gateway.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import CONF_HOST, CONF_TOKEN + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_TOKEN): str, + } +) + + +def get_config_schema_with_default_host(host: str) -> vol.Schema: + """Return a config schema with a default host.""" + return CONFIG_SCHEMA.extend({vol.Required(CONF_HOST, default=host): str}) diff --git a/homeassistant/components/ruuvi_gateway/strings.json b/homeassistant/components/ruuvi_gateway/strings.json new file mode 100644 index 00000000000..10b149c9069 --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Host (IP address or DNS name)", + "token": "Bearer token (configured during gateway setup)" + } + } + }, + "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/components/ruuvi_gateway/translations/en.json b/homeassistant/components/ruuvi_gateway/translations/en.json new file mode 100644 index 00000000000..519623e32ce --- /dev/null +++ b/homeassistant/components/ruuvi_gateway/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host (IP address or DNS name)", + "token": "Bearer token (configured during gateway setup)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ef052af02f5..47a6e65c1eb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -349,6 +349,7 @@ FLOWS = { "rpi_power", "rtsp_to_webrtc", "ruckus_unleashed", + "ruuvi_gateway", "ruuvitag_ble", "sabnzbd", "samsungtv", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index f04fb56e32a..6ad3456b254 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -400,6 +400,10 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "roomba-*", "macaddress": "204EF6*", }, + { + "domain": "ruuvi_gateway", + "hostname": "ruuvigateway*", + }, { "domain": "samsungtv", "registered_devices": True, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 18122a89452..ba718159b36 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4569,6 +4569,12 @@ } } }, + "ruuvi_gateway": { + "name": "Ruuvi Gateway", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "ruuvitag_ble": { "name": "RuuviTag BLE", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index e16e6534ec9..defd7330630 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2234,6 +2234,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ruuvi_gateway.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ruuvitag_ble.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0cba27a9eef..72d05c54968 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,6 +260,9 @@ aiorecollect==1.0.8 # homeassistant.components.ridwell aioridwell==2022.11.0 +# homeassistant.components.ruuvi_gateway +aioruuvigateway==0.0.2 + # homeassistant.components.senseme aiosenseme==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2ed77336d5..1f76afc9e50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,6 +235,9 @@ aiorecollect==1.0.8 # homeassistant.components.ridwell aioridwell==2022.11.0 +# homeassistant.components.ruuvi_gateway +aioruuvigateway==0.0.2 + # homeassistant.components.senseme aiosenseme==0.6.1 diff --git a/tests/components/ruuvi_gateway/__init__.py b/tests/components/ruuvi_gateway/__init__.py new file mode 100644 index 00000000000..219eb09f774 --- /dev/null +++ b/tests/components/ruuvi_gateway/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ruuvi Gateway integration.""" diff --git a/tests/components/ruuvi_gateway/consts.py b/tests/components/ruuvi_gateway/consts.py new file mode 100644 index 00000000000..bd544fb2098 --- /dev/null +++ b/tests/components/ruuvi_gateway/consts.py @@ -0,0 +1,12 @@ +"""Constants for ruuvi_gateway tests.""" +from __future__ import annotations + +ASYNC_SETUP_ENTRY = "homeassistant.components.ruuvi_gateway.async_setup_entry" +GET_GATEWAY_HISTORY_DATA = "aioruuvigateway.api.get_gateway_history_data" +EXPECTED_TITLE = "Ruuvi Gateway EE:FF" +BASE_DATA = { + "host": "1.1.1.1", + "token": "toktok", +} +GATEWAY_MAC = "AA:BB:CC:DD:EE:FF" +GATEWAY_MAC_LOWER = GATEWAY_MAC.lower() diff --git a/tests/components/ruuvi_gateway/test_config_flow.py b/tests/components/ruuvi_gateway/test_config_flow.py new file mode 100644 index 00000000000..4f7e1ae116e --- /dev/null +++ b/tests/components/ruuvi_gateway/test_config_flow.py @@ -0,0 +1,154 @@ +"""Test the Ruuvi Gateway config flow.""" +from unittest.mock import patch + +from aioruuvigateway.excs import CannotConnect, InvalidAuth +import pytest + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.components.ruuvi_gateway.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .consts import ( + BASE_DATA, + EXPECTED_TITLE, + GATEWAY_MAC_LOWER, + GET_GATEWAY_HISTORY_DATA, +) +from .utils import patch_gateway_ok, patch_setup_entry_ok + +DHCP_IP = "1.2.3.4" +DHCP_DATA = {**BASE_DATA, "host": DHCP_IP} + + +@pytest.mark.parametrize( + "init_data, init_context, entry", + [ + ( + None, + {"source": config_entries.SOURCE_USER}, + BASE_DATA, + ), + ( + dhcp.DhcpServiceInfo( + hostname="RuuviGateway1234", + ip=DHCP_IP, + macaddress="12:34:56:78:90:ab", + ), + {"source": config_entries.SOURCE_DHCP}, + DHCP_DATA, + ), + ], + ids=["user", "dhcp"], +) +async def test_ok_setup(hass: HomeAssistant, init_data, init_context, entry) -> None: + """Test we get the form.""" + init_result = await hass.config_entries.flow.async_init( + DOMAIN, + data=init_data, + context=init_context, + ) + assert init_result["type"] == FlowResultType.FORM + assert init_result["step_id"] == config_entries.SOURCE_USER + assert init_result["errors"] is None + + # Check that we can finalize setup + with patch_gateway_ok(), patch_setup_entry_ok() as mock_setup_entry: + config_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + entry, + ) + await hass.async_block_till_done() + assert config_result["type"] == FlowResultType.CREATE_ENTRY + assert config_result["title"] == EXPECTED_TITLE + assert config_result["data"] == entry + assert config_result["context"]["unique_id"] == GATEWAY_MAC_LOWER + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(GET_GATEWAY_HISTORY_DATA, side_effect=InvalidAuth): + config_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + BASE_DATA, + ) + + assert config_result["type"] == FlowResultType.FORM + assert config_result["errors"] == {"base": "invalid_auth"} + + # Check that we still can finalize setup + with patch_gateway_ok(), patch_setup_entry_ok() as mock_setup_entry: + config_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + BASE_DATA, + ) + await hass.async_block_till_done() + assert config_result["type"] == FlowResultType.CREATE_ENTRY + assert config_result["title"] == EXPECTED_TITLE + assert config_result["data"] == BASE_DATA + assert config_result["context"]["unique_id"] == GATEWAY_MAC_LOWER + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(GET_GATEWAY_HISTORY_DATA, side_effect=CannotConnect): + config_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + BASE_DATA, + ) + + assert config_result["type"] == FlowResultType.FORM + assert config_result["errors"] == {"base": "cannot_connect"} + + # Check that we still can finalize setup + with patch_gateway_ok(), patch_setup_entry_ok() as mock_setup_entry: + config_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + BASE_DATA, + ) + await hass.async_block_till_done() + assert config_result["type"] == FlowResultType.CREATE_ENTRY + assert config_result["title"] == EXPECTED_TITLE + assert config_result["data"] == BASE_DATA + assert config_result["context"]["unique_id"] == GATEWAY_MAC_LOWER + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unexpected(hass: HomeAssistant) -> None: + """Test we handle unexpected errors.""" + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(GET_GATEWAY_HISTORY_DATA, side_effect=MemoryError): + config_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + BASE_DATA, + ) + + assert config_result["type"] == FlowResultType.FORM + assert config_result["errors"] == {"base": "unknown"} + + # Check that we still can finalize setup + with patch_gateway_ok(), patch_setup_entry_ok() as mock_setup_entry: + config_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + BASE_DATA, + ) + await hass.async_block_till_done() + assert config_result["type"] == FlowResultType.CREATE_ENTRY + assert config_result["title"] == EXPECTED_TITLE + assert config_result["data"] == BASE_DATA + assert config_result["context"]["unique_id"] == GATEWAY_MAC_LOWER + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ruuvi_gateway/utils.py b/tests/components/ruuvi_gateway/utils.py new file mode 100644 index 00000000000..d3181ca8f5f --- /dev/null +++ b/tests/components/ruuvi_gateway/utils.py @@ -0,0 +1,30 @@ +"""Utilities for ruuvi_gateway tests.""" +from __future__ import annotations + +import time +from unittest.mock import _patch, patch + +from aioruuvigateway.models import HistoryResponse + +from tests.components.ruuvi_gateway.consts import ( + ASYNC_SETUP_ENTRY, + GATEWAY_MAC, + GET_GATEWAY_HISTORY_DATA, +) + + +def patch_gateway_ok() -> _patch: + """Patch gateway function to return valid data.""" + return patch( + GET_GATEWAY_HISTORY_DATA, + return_value=HistoryResponse( + timestamp=int(time.time()), + gw_mac=GATEWAY_MAC, + tags=[], + ), + ) + + +def patch_setup_entry_ok() -> _patch: + """Patch setup entry to return True.""" + return patch(ASYNC_SETUP_ENTRY, return_value=True)