Add shelly ble scanner support (#82007)

This commit is contained in:
J. Nick Koston 2022-11-15 12:34:45 -06:00 committed by GitHub
parent 7932864e00
commit 435fc23737
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 694 additions and 49 deletions

View file

@ -1013,8 +1013,8 @@ build.json @home-assistant/supervisor
/tests/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10 /tests/components/sharkiq/ @JeffResc @funkybunch @AritroSaha10
/homeassistant/components/shell_command/ @home-assistant/core /homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core /tests/components/shell_command/ @home-assistant/core
/homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 /homeassistant/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco
/tests/components/shelly/ @balloob @bieniu @thecode @chemelli74 /tests/components/shelly/ @balloob @bieniu @thecode @chemelli74 @bdraco
/homeassistant/components/shodan/ @fabaff /homeassistant/components/shodan/ @fabaff
/homeassistant/components/sia/ @eavanvalkenburg /homeassistant/components/sia/ @eavanvalkenburg
/tests/components/sia/ @eavanvalkenburg /tests/components/sia/ @eavanvalkenburg

View file

@ -6,7 +6,7 @@ from typing import Any, Final
import aioshelly import aioshelly
from aioshelly.block_device import BlockDevice from aioshelly.block_device import BlockDevice
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from aioshelly.rpc_device import RpcDevice from aioshelly.rpc_device import RpcDevice, UpdateType
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -252,7 +252,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo
hass.config_entries.async_setup_platforms(entry, platforms) hass.config_entries.async_setup_platforms(entry, platforms)
@callback @callback
def _async_device_online(_: Any) -> None: def _async_device_online(_: Any, update_type: UpdateType) -> None:
LOGGER.debug("Device %s is online, resuming setup", entry.title) LOGGER.debug("Device %s is online, resuming setup", entry.title)
shelly_entry_data.device = None shelly_entry_data.device = None

View file

@ -0,0 +1,66 @@
"""Bluetooth support for shelly."""
from __future__ import annotations
from typing import TYPE_CHECKING
from aioshelly.ble import async_start_scanner
from aioshelly.ble.const import (
BLE_SCAN_RESULT_EVENT,
BLE_SCAN_RESULT_VERSION,
DEFAULT_DURATION_MS,
DEFAULT_INTERVAL_MS,
DEFAULT_WINDOW_MS,
)
from homeassistant.components.bluetooth import (
HaBluetoothConnector,
async_get_advertisement_callback,
async_register_scanner,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from homeassistant.helpers.device_registry import format_mac
from ..const import BLEScannerMode
from .scanner import ShellyBLEScanner
if TYPE_CHECKING:
from ..coordinator import ShellyRpcCoordinator
async def async_connect_scanner(
hass: HomeAssistant,
coordinator: ShellyRpcCoordinator,
scanner_mode: BLEScannerMode,
) -> CALLBACK_TYPE:
"""Connect scanner."""
device = coordinator.device
source = format_mac(coordinator.mac).upper()
new_info_callback = async_get_advertisement_callback(hass)
connector = HaBluetoothConnector(
# no active connections to shelly yet
client=None, # type: ignore[arg-type]
source=source,
can_connect=lambda: False,
)
scanner = ShellyBLEScanner(hass, source, new_info_callback, connector, False)
unload_callbacks = [
async_register_scanner(hass, scanner, False),
scanner.async_setup(),
coordinator.async_subscribe_events(scanner.async_on_event),
]
await async_start_scanner(
device=device,
active=scanner_mode == BLEScannerMode.ACTIVE,
event_type=BLE_SCAN_RESULT_EVENT,
data_version=BLE_SCAN_RESULT_VERSION,
interval_ms=DEFAULT_INTERVAL_MS,
window_ms=DEFAULT_WINDOW_MS,
duration_ms=DEFAULT_DURATION_MS,
)
@hass_callback
def _async_unload() -> None:
for callback in unload_callbacks:
callback()
return _async_unload

View file

@ -0,0 +1,47 @@
"""Bluetooth scanner for shelly."""
from __future__ import annotations
import logging
from typing import Any
from aioshelly.ble import parse_ble_scan_result_event
from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION
from homeassistant.components.bluetooth import BaseHaRemoteScanner
from homeassistant.core import callback
_LOGGER = logging.getLogger(__name__)
class ShellyBLEScanner(BaseHaRemoteScanner):
"""Scanner for shelly."""
@callback
def async_on_event(self, event: dict[str, Any]) -> None:
"""Process an event from the shelly and ignore if its not a ble.scan_result."""
if event.get("event") != BLE_SCAN_RESULT_EVENT:
return
data = event["data"]
if data[0] != BLE_SCAN_RESULT_VERSION:
_LOGGER.warning("Unsupported BLE scan result version: %s", data[0])
return
try:
address, rssi, parsed = parse_ble_scan_result_event(data)
except Exception as err: # pylint: disable=broad-except
# Broad exception catch because we have no
# control over the data that is coming in.
_LOGGER.error("Failed to parse BLE event: %s", err, exc_info=True)
return
self._async_on_advertisement(
address,
rssi,
parsed.local_name,
parsed.service_uuids,
parsed.service_data,
parsed.manufacturer_data,
parsed.tx_power,
)

View file

@ -12,16 +12,25 @@ from aioshelly.exceptions import (
InvalidAuthError, InvalidAuthError,
) )
from aioshelly.rpc_device import RpcDevice from aioshelly.rpc_device import RpcDevice
from awesomeversion import AwesomeVersion
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client, selector
from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER from .const import (
BLE_MIN_VERSION,
CONF_BLE_SCANNER_MODE,
CONF_SLEEP_PERIOD,
DOMAIN,
LOGGER,
BLEScannerMode,
)
from .coordinator import get_entry_data
from .utils import ( from .utils import (
get_block_device_name, get_block_device_name,
get_block_device_sleep_period, get_block_device_sleep_period,
@ -37,6 +46,13 @@ from .utils import (
HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str})
BLE_SCANNER_OPTIONS = [
selector.SelectOptionDict(value=BLEScannerMode.DISABLED, label="Disabled"),
selector.SelectOptionDict(value=BLEScannerMode.ACTIVE, label="Active"),
selector.SelectOptionDict(value=BLEScannerMode.PASSIVE, label="Passive"),
]
async def validate_input( async def validate_input(
hass: HomeAssistant, hass: HomeAssistant,
host: str, host: str,
@ -310,3 +326,59 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await aioshelly.common.get_info( return await aioshelly.common.get_info(
aiohttp_client.async_get_clientsession(self.hass), host aiohttp_client.async_get_clientsession(self.hass), host
) )
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
@classmethod
@callback
def async_supports_options_flow(
cls, config_entry: config_entries.ConfigEntry
) -> bool:
"""Return options flow support for this handler."""
return config_entry.data.get("gen") == 2
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle the option flow for shelly."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle options flow."""
if user_input is not None:
entry_data = get_entry_data(self.hass)[self.config_entry.entry_id]
if user_input[CONF_BLE_SCANNER_MODE] != BLEScannerMode.DISABLED and (
not entry_data.rpc
or AwesomeVersion(entry_data.rpc.device.version) < BLE_MIN_VERSION
):
return self.async_abort(
reason="ble_unsupported",
description_placeholders={"ble_min_version": BLE_MIN_VERSION},
)
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_BLE_SCANNER_MODE,
default=self.config_entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
),
): selector.SelectSelector(
selector.SelectSelectorConfig(options=BLE_SCANNER_OPTIONS),
),
}
),
)

View file

@ -5,6 +5,10 @@ from logging import Logger, getLogger
import re import re
from typing import Final from typing import Final
from awesomeversion import AwesomeVersion
from homeassistant.backports.enum import StrEnum
DOMAIN: Final = "shelly" DOMAIN: Final = "shelly"
LOGGER: Logger = getLogger(__package__) LOGGER: Logger = getLogger(__package__)
@ -156,3 +160,15 @@ UPTIME_DEVIATION: Final = 5
ENTRY_RELOAD_COOLDOWN = 60 ENTRY_RELOAD_COOLDOWN = 60
SHELLY_GAS_MODELS = ["SHGS-1"] SHELLY_GAS_MODELS = ["SHGS-1"]
BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2")
CONF_BLE_SCANNER_MODE = "ble_scanner_mode"
class BLEScannerMode(StrEnum):
"""BLE scanner mode."""
DISABLED = "disabled"
ACTIVE = "active"
PASSIVE = "passive"

View file

@ -1,6 +1,7 @@
"""Coordinators for the Shelly integration.""" """Coordinators for the Shelly integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
@ -9,7 +10,8 @@ from typing import Any, cast
import aioshelly import aioshelly
from aioshelly.block_device import BlockDevice from aioshelly.block_device import BlockDevice
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from aioshelly.rpc_device import RpcDevice from aioshelly.rpc_device import RpcDevice, UpdateType
from awesomeversion import AwesomeVersion
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP
@ -18,12 +20,15 @@ from homeassistant.helpers import device_registry
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .bluetooth import async_connect_scanner
from .const import ( from .const import (
ATTR_CHANNEL, ATTR_CHANNEL,
ATTR_CLICK_TYPE, ATTR_CLICK_TYPE,
ATTR_DEVICE, ATTR_DEVICE,
ATTR_GENERATION, ATTR_GENERATION,
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION,
BLE_MIN_VERSION,
CONF_BLE_SCANNER_MODE,
CONF_SLEEP_PERIOD, CONF_SLEEP_PERIOD,
DATA_CONFIG_ENTRY, DATA_CONFIG_ENTRY,
DOMAIN, DOMAIN,
@ -40,6 +45,7 @@ from .const import (
SHBTN_MODELS, SHBTN_MODELS,
SLEEP_PERIOD_MULTIPLIER, SLEEP_PERIOD_MULTIPLIER,
UPDATE_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER,
BLEScannerMode,
) )
from .utils import ( from .utils import (
device_update_info, device_update_info,
@ -336,7 +342,10 @@ class ShellyRpcCoordinator(DataUpdateCoordinator):
) )
self.entry = entry self.entry = entry
self.device = device self.device = device
self.connected = False
self._disconnected_callbacks: list[CALLBACK_TYPE] = []
self._connection_lock = asyncio.Lock()
self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] self._event_listeners: list[Callable[[dict[str, Any]], None]] = []
self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer( self._debounced_reload: Debouncer[Coroutine[Any, Any, None]] = Debouncer(
hass, hass,
@ -346,16 +355,14 @@ class ShellyRpcCoordinator(DataUpdateCoordinator):
function=self._async_reload_entry, function=self._async_reload_entry,
) )
entry.async_on_unload(self._debounced_reload.async_cancel) entry.async_on_unload(self._debounced_reload.async_cancel)
self._last_event: dict[str, Any] | None = None
self._last_status: dict[str, Any] | None = None
entry.async_on_unload( entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop)
) )
entry.async_on_unload(entry.add_update_listener(self._async_update_listener))
async def _async_reload_entry(self) -> None: async def _async_reload_entry(self) -> None:
"""Reload entry.""" """Reload entry."""
self._debounced_reload.async_cancel()
LOGGER.debug("Reloading entry %s", self.name) LOGGER.debug("Reloading entry %s", self.name)
await self.hass.config_entries.async_reload(self.entry.entry_id) await self.hass.config_entries.async_reload(self.entry.entry_id)
@ -390,12 +397,19 @@ class ShellyRpcCoordinator(DataUpdateCoordinator):
return _unsubscribe return _unsubscribe
async def _async_update_listener(
self, hass: HomeAssistant, entry: ConfigEntry
) -> None:
"""Reconfigure on update."""
async with self._connection_lock:
if self.connected:
self._async_run_disconnected_events()
await self._async_run_connected_events()
@callback @callback
def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: def _async_device_event_handler(self, event_data: dict[str, Any]) -> None:
"""Handle device events.""" """Handle device events."""
self.update_sleep_period()
events: list[dict[str, Any]] = event_data["events"] events: list[dict[str, Any]] = event_data["events"]
for event in events: for event in events:
event_type = event.get("event") event_type = event.get("event")
if event_type is None: if event_type is None:
@ -405,6 +419,7 @@ class ShellyRpcCoordinator(DataUpdateCoordinator):
event_callback(event) event_callback(event)
if event_type == "config_changed": if event_type == "config_changed":
self.update_sleep_period()
LOGGER.info( LOGGER.info(
"Config for %s changed, reloading entry in %s seconds", "Config for %s changed, reloading entry in %s seconds",
self.name, self.name,
@ -460,21 +475,71 @@ class ShellyRpcCoordinator(DataUpdateCoordinator):
"""Firmware version of the device.""" """Firmware version of the device."""
return self.device.firmware_version if self.device.initialized else "" return self.device.firmware_version if self.device.initialized else ""
@callback async def _async_disconnected(self) -> None:
def _async_handle_update(self, device_: RpcDevice) -> None: """Handle device disconnected."""
"""Handle device update.""" async with self._connection_lock:
device = self.device if not self.connected: # Already disconnected
if not device.initialized: return
return self.connected = False
event = device.event self._async_run_disconnected_events()
status = device.status
if event and event != self._last_event: @callback
self._last_event = event def _async_run_disconnected_events(self) -> None:
"""Run disconnected events.
This will be executed on disconnect or when the config entry
is updated.
"""
for disconnected_callback in self._disconnected_callbacks:
disconnected_callback()
self._disconnected_callbacks.clear()
async def _async_connected(self) -> None:
"""Handle device connected."""
async with self._connection_lock:
if self.connected: # Already connected
return
self.connected = True
await self._async_run_connected_events()
async def _async_run_connected_events(self) -> None:
"""Run connected events.
This will be executed on connect or when the config entry
is updated.
"""
await self._async_connect_ble_scanner()
async def _async_connect_ble_scanner(self) -> None:
"""Connect BLE scanner."""
ble_scanner_mode = self.entry.options.get(
CONF_BLE_SCANNER_MODE, BLEScannerMode.DISABLED
)
if ble_scanner_mode == BLEScannerMode.DISABLED:
return
if AwesomeVersion(self.device.version) < BLE_MIN_VERSION:
LOGGER.error(
"BLE not supported on device %s with firmware %s; upgrade to %s",
self.name,
self.device.version,
BLE_MIN_VERSION,
)
return
self._disconnected_callbacks.append(
await async_connect_scanner(self.hass, self, ble_scanner_mode)
)
@callback
def _async_handle_update(self, device_: RpcDevice, update_type: UpdateType) -> None:
"""Handle device update."""
if update_type is UpdateType.INITIALIZED:
self.hass.async_create_task(self._async_connected())
elif update_type is UpdateType.DISCONNECTED:
self.hass.async_create_task(self._async_disconnected())
elif update_type is UpdateType.STATUS:
self.async_set_updated_data(self.device)
elif update_type is UpdateType.EVENT and (event := self.device.event):
self._async_device_event_handler(event) self._async_device_event_handler(event)
if status and status != self._last_status:
self._last_status = status
self.async_set_updated_data(device)
def async_setup(self) -> None: def async_setup(self) -> None:
"""Set up the coordinator.""" """Set up the coordinator."""
@ -491,10 +556,14 @@ class ShellyRpcCoordinator(DataUpdateCoordinator):
) )
self.device_id = entry.id self.device_id = entry.id
self.device.subscribe_updates(self._async_handle_update) self.device.subscribe_updates(self._async_handle_update)
if self.device.initialized:
# If we are already initialized, we are connected
self.hass.async_create_task(self._async_connected())
async def shutdown(self) -> None: async def shutdown(self) -> None:
"""Shutdown the coordinator.""" """Shutdown the coordinator."""
await self.device.shutdown() await self.device.shutdown()
await self._async_disconnected()
async def _handle_ha_stop(self, _event: Event) -> None: async def _handle_ha_stop(self, _event: Event) -> None:
"""Handle Home Assistant stopping.""" """Handle Home Assistant stopping."""

View file

@ -3,15 +3,15 @@
"name": "Shelly", "name": "Shelly",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/shelly", "documentation": "https://www.home-assistant.io/integrations/shelly",
"requirements": ["aioshelly==4.1.2"], "requirements": ["aioshelly==5.0.0"],
"dependencies": ["http"], "dependencies": ["bluetooth", "http"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_http._tcp.local.", "type": "_http._tcp.local.",
"name": "shelly*" "name": "shelly*"
} }
], ],
"codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74"], "codeowners": ["@balloob", "@bieniu", "@thecode", "@chemelli74", "@bdraco"],
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aioshelly"], "loggers": ["aioshelly"],
"integration_type": "device" "integration_type": "device"

View file

@ -58,5 +58,18 @@
"double_push": "{subtype} double push", "double_push": "{subtype} double push",
"long_push": "{subtype} long push" "long_push": "{subtype} long push"
} }
},
"options": {
"step": {
"init": {
"description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices.",
"data": {
"ble_scanner_mode": "Bluetooth scanner mode"
}
}
},
"abort": {
"ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer."
}
} }
} }

View file

@ -58,5 +58,18 @@
"single_push": "{subtype} single push", "single_push": "{subtype} single push",
"triple": "{subtype} triple clicked" "triple": "{subtype} triple clicked"
} }
},
"options": {
"abort": {
"ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer."
},
"step": {
"init": {
"data": {
"ble_scanner_mode": "Bluetooth scanner mode"
},
"description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices."
}
}
} }
} }

View file

@ -261,7 +261,7 @@ aiosenseme==0.6.1
aiosenz==1.0.0 aiosenz==1.0.0
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==4.1.2 aioshelly==5.0.0
# homeassistant.components.skybell # homeassistant.components.skybell
aioskybell==22.7.0 aioskybell==22.7.0

View file

@ -236,7 +236,7 @@ aiosenseme==0.6.1
aiosenz==1.0.0 aiosenz==1.0.0
# homeassistant.components.shelly # homeassistant.components.shelly
aioshelly==4.1.2 aioshelly==5.0.0
# homeassistant.components.skybell # homeassistant.components.skybell
aioskybell==22.7.0 aioskybell==22.7.0

View file

@ -1,4 +1,6 @@
"""Tests for the Shelly integration.""" """Tests for the Shelly integration."""
from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from typing import Any from typing import Any
from unittest.mock import Mock from unittest.mock import Mock
@ -15,7 +17,11 @@ MOCK_MAC = "123456789ABC"
async def init_integration( async def init_integration(
hass: HomeAssistant, gen: int, model="SHSW-25", sleep_period=0 hass: HomeAssistant,
gen: int,
model="SHSW-25",
sleep_period=0,
options: dict[str, Any] | None = None,
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up the Shelly integration in Home Assistant.""" """Set up the Shelly integration in Home Assistant."""
data = { data = {
@ -25,7 +31,9 @@ async def init_integration(
"gen": gen, "gen": gen,
} }
entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=MOCK_MAC) entry = MockConfigEntry(
domain=DOMAIN, data=data, unique_id=MOCK_MAC, options=options
)
entry.add_to_hass(hass) entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
@ -45,3 +53,13 @@ def mutate_rpc_device_status(
new_status = deepcopy(mock_rpc_device.status) new_status = deepcopy(mock_rpc_device.status)
new_status[top_level_key][key] = value new_status[top_level_key][key] = value
monkeypatch.setattr(mock_rpc_device, "status", new_status) monkeypatch.setattr(mock_rpc_device, "status", new_status)
def inject_rpc_device_event(
monkeypatch: pytest.MonkeyPatch,
mock_rpc_device: Mock,
event: dict[str, dict[str, Any]],
) -> None:
"""Inject event for rpc device."""
monkeypatch.setattr(mock_rpc_device, "event", event)
mock_rpc_device.mock_event()

View file

@ -0,0 +1,2 @@
"""Bluetooth tests for Shelly integration."""
from __future__ import annotations

View file

@ -0,0 +1,133 @@
"""Test the shelly bluetooth scanner."""
from __future__ import annotations
from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT
from homeassistant.components import bluetooth
from homeassistant.components.shelly.const import CONF_BLE_SCANNER_MODE, BLEScannerMode
from .. import init_integration, inject_rpc_device_event
async def test_scanner(hass, mock_rpc_device, monkeypatch):
"""Test injecting data into the scanner."""
await init_integration(
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}
)
assert mock_rpc_device.initialized is True
inject_rpc_device_event(
monkeypatch,
mock_rpc_device,
{
"events": [
{
"component": "script:1",
"data": [
1,
"aa:bb:cc:dd:ee:ff",
-62,
"AgEGCf9ZANH7O3TIkA==",
"EQcbxdWlAgC4n+YRTSIADaLLBhYADUgQYQ==",
],
"event": BLE_SCAN_RESULT_EVENT,
"id": 1,
"ts": 1668522399.2,
}
],
"ts": 1668522399.2,
},
)
ble_device = bluetooth.async_ble_device_from_address(
hass, "AA:BB:CC:DD:EE:FF", connectable=False
)
assert ble_device is not None
ble_device = bluetooth.async_ble_device_from_address(
hass, "AA:BB:CC:DD:EE:FF", connectable=True
)
assert ble_device is None
async def test_scanner_ignores_non_ble_events(hass, mock_rpc_device, monkeypatch):
"""Test injecting non ble data into the scanner."""
await init_integration(
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}
)
assert mock_rpc_device.initialized is True
inject_rpc_device_event(
monkeypatch,
mock_rpc_device,
{
"events": [
{
"component": "script:1",
"data": [],
"event": "not_ble_scan_result",
"id": 1,
"ts": 1668522399.2,
}
],
"ts": 1668522399.2,
},
)
async def test_scanner_ignores_wrong_version_and_logs(
hass, mock_rpc_device, monkeypatch, caplog
):
"""Test injecting wrong version of ble data into the scanner."""
await init_integration(
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}
)
assert mock_rpc_device.initialized is True
inject_rpc_device_event(
monkeypatch,
mock_rpc_device,
{
"events": [
{
"component": "script:1",
"data": [
0,
"aa:bb:cc:dd:ee:ff",
-62,
"AgEGCf9ZANH7O3TIkA==",
"EQcbxdWlAgC4n+YRTSIADaLLBhYADUgQYQ==",
],
"event": BLE_SCAN_RESULT_EVENT,
"id": 1,
"ts": 1668522399.2,
}
],
"ts": 1668522399.2,
},
)
assert "Unsupported BLE scan result version: 0" in caplog.text
async def test_scanner_warns_on_corrupt_event(
hass, mock_rpc_device, monkeypatch, caplog
):
"""Test injecting garbage ble data into the scanner."""
await init_integration(
hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE}
)
assert mock_rpc_device.initialized is True
inject_rpc_device_event(
monkeypatch,
mock_rpc_device,
{
"events": [
{
"component": "script:1",
"data": [
1,
],
"event": BLE_SCAN_RESULT_EVENT,
"id": 1,
"ts": 1668522399.2,
}
],
"ts": 1668522399.2,
},
)
assert "Failed to parse BLE event" in caplog.text

View file

@ -1,8 +1,10 @@
"""Test configuration for Shelly.""" """Test configuration for Shelly."""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from aioshelly.block_device import BlockDevice from aioshelly.block_device import BlockDevice
from aioshelly.rpc_device import RpcDevice from aioshelly.rpc_device import RpcDevice, UpdateType
import pytest import pytest
from homeassistant.components.shelly.const import ( from homeassistant.components.shelly.const import (
@ -194,6 +196,7 @@ async def mock_block_device():
blocks=MOCK_BLOCKS, blocks=MOCK_BLOCKS,
settings=MOCK_SETTINGS, settings=MOCK_SETTINGS,
shelly=MOCK_SHELLY_COAP, shelly=MOCK_SHELLY_COAP,
version="0.10.0",
status=MOCK_STATUS_COAP, status=MOCK_STATUS_COAP,
firmware_version="some fw string", firmware_version="some fw string",
initialized=True, initialized=True,
@ -204,25 +207,62 @@ async def mock_block_device():
yield block_device_mock.return_value yield block_device_mock.return_value
@pytest.fixture def _mock_rpc_device(version: str | None = None):
async def mock_rpc_device():
"""Mock rpc (Gen2, Websocket) device.""" """Mock rpc (Gen2, Websocket) device."""
return Mock(
spec=RpcDevice,
config=MOCK_CONFIG,
event={},
shelly=MOCK_SHELLY_RPC,
version=version or "0.12.0",
status=MOCK_STATUS_RPC,
firmware_version="some fw string",
initialized=True,
)
@pytest.fixture
async def mock_pre_ble_rpc_device():
"""Mock rpc (Gen2, Websocket) device pre BLE."""
with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock:
def update(): def update():
rpc_device_mock.return_value.subscribe_updates.call_args[0][0]({}) rpc_device_mock.return_value.subscribe_updates.call_args[0][0](
{}, UpdateType.STATUS
device = Mock( )
spec=RpcDevice,
config=MOCK_CONFIG,
event={},
shelly=MOCK_SHELLY_RPC,
status=MOCK_STATUS_RPC,
firmware_version="some fw string",
initialized=True,
)
device = _mock_rpc_device("0.11.0")
rpc_device_mock.return_value = device rpc_device_mock.return_value = device
rpc_device_mock.return_value.mock_update = Mock(side_effect=update) rpc_device_mock.return_value.mock_update = Mock(side_effect=update)
yield rpc_device_mock.return_value yield rpc_device_mock.return_value
@pytest.fixture
async def mock_rpc_device():
"""Mock rpc (Gen2, Websocket) device with BLE support."""
with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock, patch(
"homeassistant.components.shelly.bluetooth.async_start_scanner"
):
def update():
rpc_device_mock.return_value.subscribe_updates.call_args[0][0](
{}, UpdateType.STATUS
)
def event():
rpc_device_mock.return_value.subscribe_updates.call_args[0][0](
{}, UpdateType.EVENT
)
device = _mock_rpc_device("0.12.0")
rpc_device_mock.return_value = device
rpc_device_mock.return_value.mock_update = Mock(side_effect=update)
rpc_device_mock.return_value.mock_event = Mock(side_effect=event)
yield rpc_device_mock.return_value
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""

View file

@ -1,4 +1,6 @@
"""Tests for Shelly button platform.""" """Tests for Shelly button platform."""
from __future__ import annotations
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant

View file

@ -10,8 +10,15 @@ import pytest
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.shelly.const import (
CONF_BLE_SCANNER_MODE,
DOMAIN,
BLEScannerMode,
)
from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.setup import async_setup_component
from . import init_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -880,3 +887,149 @@ async def test_reauth_get_info_error(hass, error):
assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "reauth_unsuccessful" assert result["reason"] == "reauth_unsuccessful"
async def test_options_flow_disabled_gen_1(hass, mock_block_device, hass_ws_client):
"""Test options are disabled for gen1 devices."""
await async_setup_component(hass, "config", {})
entry = await init_integration(hass, 1)
ws_client = await hass_ws_client(hass)
await ws_client.send_json(
{
"id": 5,
"type": "config_entries/get",
"domain": "shelly",
}
)
response = await ws_client.receive_json()
assert response["result"][0]["supports_options"] is False
await hass.config_entries.async_unload(entry.entry_id)
async def test_options_flow_enabled_gen_2(hass, mock_rpc_device, hass_ws_client):
"""Test options are enabled for gen2 devices."""
await async_setup_component(hass, "config", {})
entry = await init_integration(hass, 2)
ws_client = await hass_ws_client(hass)
await ws_client.send_json(
{
"id": 5,
"type": "config_entries/get",
"domain": "shelly",
}
)
response = await ws_client.receive_json()
assert response["result"][0]["supports_options"] is True
await hass.config_entries.async_unload(entry.entry_id)
async def test_options_flow_ble(hass, mock_rpc_device):
"""Test setting ble options for gen2 devices."""
entry = await init_integration(hass, 2)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] is None
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] is None
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.ACTIVE
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] is None
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.PASSIVE
await hass.config_entries.async_unload(entry.entry_id)
async def test_options_flow_pre_ble_device(hass, mock_pre_ble_rpc_device):
"""Test setting ble options for gen2 devices with pre ble firmware."""
entry = await init_integration(hass, 2)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] is None
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] is None
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "ble_unsupported"
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] is None
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE,
},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.FlowResultType.ABORT
assert result["reason"] == "ble_unsupported"
await hass.config_entries.async_unload(entry.entry_id)

View file

@ -1,4 +1,5 @@
"""Test cases for the Shelly component.""" """Test cases for the Shelly component."""
from __future__ import annotations
from unittest.mock import AsyncMock from unittest.mock import AsyncMock