Ruuvi Gateway integration (#84853)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/85086/head
Aarni Koskela 2023-01-03 22:19:43 +02:00 committed by GitHub
parent a75bad3a83
commit 38f183a683
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 612 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -349,6 +349,7 @@ FLOWS = {
"rpi_power",
"rtsp_to_webrtc",
"ruckus_unleashed",
"ruuvi_gateway",
"ruuvitag_ble",
"sabnzbd",
"samsungtv",

View File

@ -400,6 +400,10 @@ DHCP: list[dict[str, str | bool]] = [
"hostname": "roomba-*",
"macaddress": "204EF6*",
},
{
"domain": "ruuvi_gateway",
"hostname": "ruuvigateway*",
},
{
"domain": "samsungtv",
"registered_devices": True,

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Tests for the Ruuvi Gateway integration."""

View File

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

View File

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

View File

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