Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,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
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions homeassistant/components/ruuvi_gateway/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
akx marked this conversation as resolved.
Outdated
"""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
103 changes: 103 additions & 0 deletions homeassistant/components/ruuvi_gateway/bluetooth.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
akx marked this conversation as resolved.
Outdated
"""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)
89 changes: 89 additions & 0 deletions homeassistant/components/ruuvi_gateway/config_flow.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
akx marked this conversation as resolved.
Outdated

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)
Comment thread
bdraco marked this conversation as resolved.
Outdated
Comment thread
akx marked this conversation as resolved.
return await self.async_step_user()
12 changes: 12 additions & 0 deletions homeassistant/components/ruuvi_gateway/const.py
Original file line number Diff line number Diff line change
@@ -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
)
49 changes: 49 additions & 0 deletions homeassistant/components/ruuvi_gateway/coordinator.py
Original file line number Diff line number Diff line change
@@ -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] = {}
Comment thread
akx marked this conversation as resolved.
Outdated

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
14 changes: 14 additions & 0 deletions homeassistant/components/ruuvi_gateway/manifest.json
Original file line number Diff line number Diff line change
@@ -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*"
}
]
}
15 changes: 15 additions & 0 deletions homeassistant/components/ruuvi_gateway/models.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions homeassistant/components/ruuvi_gateway/schemata.py
Original file line number Diff line number Diff line change
@@ -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})
20 changes: 20 additions & 0 deletions homeassistant/components/ruuvi_gateway/strings.json
Original file line number Diff line number Diff line change
@@ -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%]"
}
}
}
Loading