Add support for Snooz BLE devices (#78790)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Austin Brunkhorst 2022-10-10 16:14:27 -07:00 committed by GitHub
parent 4281384d2a
commit 7d097d18b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1257 additions and 0 deletions

View File

@ -1163,6 +1163,7 @@ omit =
homeassistant/components/smtp/notify.py
homeassistant/components/snapcast/*
homeassistant/components/snmp/*
homeassistant/components/snooz/__init__.py
homeassistant/components/solaredge/__init__.py
homeassistant/components/solaredge/coordinator.py
homeassistant/components/solaredge/sensor.py

View File

@ -240,6 +240,7 @@ homeassistant.components.skybell.*
homeassistant.components.slack.*
homeassistant.components.sleepiq.*
homeassistant.components.smhi.*
homeassistant.components.snooz.*
homeassistant.components.sonarr.*
homeassistant.components.ssdp.*
homeassistant.components.statistics.*

View File

@ -1041,6 +1041,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/smhi/ @gjohansson-ST
/tests/components/smhi/ @gjohansson-ST
/homeassistant/components/sms/ @ocalvo
/homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck
/tests/components/solaredge/ @frenck
/homeassistant/components/solaredge_local/ @drobtravels @scheric

View File

@ -0,0 +1,62 @@
"""The Snooz component."""
from __future__ import annotations
import logging
from pysnooz.device import SnoozDevice
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
from .models import SnoozConfigurationData
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Snooz device from a config entry."""
address: str = entry.data[CONF_ADDRESS]
token: str = entry.data[CONF_TOKEN]
# transitions info logs are verbose. Only enable warnings
logging.getLogger("transitions.core").setLevel(logging.WARNING)
if not (ble_device := async_ble_device_from_address(hass, address)):
raise ConfigEntryNotReady(
f"Could not find Snooz with address {address}. Try power cycling the device"
)
device = SnoozDevice(ble_device, token)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SnoozConfigurationData(
ble_device, device, entry.title
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id]
if entry.title != data.title:
await hass.config_entries.async_reload(entry.entry_id)
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, PLATFORMS):
data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id]
# also called by fan entities, but do it here too for good measure
await data.device.async_disconnect()
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.config_entries.async_entries(DOMAIN):
hass.data.pop(DOMAIN)
return unload_ok

View File

@ -0,0 +1,206 @@
"""Config flow for Snooz component."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Any
from pysnooz.advertisement import SnoozAdvertisementData
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfo,
async_discovered_service_info,
async_process_advertisements,
)
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
# number of seconds to wait for a device to be put in pairing mode
WAIT_FOR_PAIRING_TIMEOUT = 30
@dataclass
class DiscoveredSnooz:
"""Represents a discovered Snooz device."""
info: BluetoothServiceInfo
device: SnoozAdvertisementData
class SnoozConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Snooz."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery: DiscoveredSnooz | None = None
self._discovered_devices: dict[str, DiscoveredSnooz] = {}
self._pairing_task: asyncio.Task | None = None
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> FlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
device = SnoozAdvertisementData()
if not device.supported(discovery_info):
return self.async_abort(reason="not_supported")
self._discovery = DiscoveredSnooz(discovery_info, device)
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self._discovery is not None
if user_input is not None:
if not self._discovery.device.is_pairing:
return await self.async_step_wait_for_pairing_mode()
return self._create_snooz_entry(self._discovery)
self._set_confirm_only()
assert self._discovery.device.display_name
placeholders = {"name": self._discovery.device.display_name}
self.context["title_placeholders"] = placeholders
return self.async_show_form(
step_id="bluetooth_confirm", description_placeholders=placeholders
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the user step to pick discovered device."""
if user_input is not None:
name = user_input[CONF_NAME]
discovered = self._discovered_devices.get(name)
assert discovered is not None
self._discovery = discovered
if not discovered.device.is_pairing:
return await self.async_step_wait_for_pairing_mode()
address = discovered.info.address
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self._create_snooz_entry(discovered)
configured_addresses = self._async_current_ids()
for info in async_discovered_service_info(self.hass):
address = info.address
if address in configured_addresses:
continue
device = SnoozAdvertisementData()
if device.supported(info):
assert device.display_name
self._discovered_devices[device.display_name] = DiscoveredSnooz(
info, device
)
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_NAME): vol.In(
[
d.device.display_name
for d in self._discovered_devices.values()
]
)
}
),
)
async def async_step_wait_for_pairing_mode(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Wait for device to enter pairing mode."""
if not self._pairing_task:
self._pairing_task = self.hass.async_create_task(
self._async_wait_for_pairing_mode()
)
return self.async_show_progress(
step_id="wait_for_pairing_mode",
progress_action="wait_for_pairing_mode",
)
try:
await self._pairing_task
except asyncio.TimeoutError:
self._pairing_task = None
return self.async_show_progress_done(next_step_id="pairing_timeout")
self._pairing_task = None
return self.async_show_progress_done(next_step_id="pairing_complete")
async def async_step_pairing_complete(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Create a configuration entry for a device that entered pairing mode."""
assert self._discovery
await self.async_set_unique_id(
self._discovery.info.address, raise_on_progress=False
)
self._abort_if_unique_id_configured()
return self._create_snooz_entry(self._discovery)
async def async_step_pairing_timeout(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Inform the user that the device never entered pairing mode."""
if user_input is not None:
return await self.async_step_wait_for_pairing_mode()
self._set_confirm_only()
return self.async_show_form(step_id="pairing_timeout")
def _create_snooz_entry(self, discovery: DiscoveredSnooz) -> FlowResult:
assert discovery.device.display_name
return self.async_create_entry(
title=discovery.device.display_name,
data={
CONF_ADDRESS: discovery.info.address,
CONF_TOKEN: discovery.device.pairing_token,
},
)
async def _async_wait_for_pairing_mode(self) -> None:
"""Process advertisements until pairing mode is detected."""
assert self._discovery
device = self._discovery.device
def is_device_in_pairing_mode(
service_info: BluetoothServiceInfo,
) -> bool:
return device.supported(service_info) and device.is_pairing
try:
await async_process_advertisements(
self.hass,
is_device_in_pairing_mode,
{"address": self._discovery.info.address},
BluetoothScanningMode.ACTIVE,
WAIT_FOR_PAIRING_TIMEOUT,
)
finally:
self.hass.async_create_task(
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
)

View File

@ -0,0 +1,6 @@
"""Constants for the Snooz component."""
from homeassistant.const import Platform
DOMAIN = "snooz"
PLATFORMS: list[Platform] = [Platform.FAN]

View File

@ -0,0 +1,119 @@
"""Fan representation of a Snooz device."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from pysnooz.api import UnknownSnoozState
from pysnooz.commands import (
SnoozCommandData,
SnoozCommandResultStatus,
set_volume,
turn_off,
turn_on,
)
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DOMAIN
from .models import SnoozConfigurationData
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up Snooz device from a config entry."""
data: SnoozConfigurationData = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SnoozFan(data)])
class SnoozFan(FanEntity, RestoreEntity):
"""Fan representation of a Snooz device."""
def __init__(self, data: SnoozConfigurationData) -> None:
"""Initialize a Snooz fan entity."""
self._device = data.device
self._attr_name = data.title
self._attr_unique_id = data.device.address
self._attr_supported_features = FanEntityFeature.SET_SPEED
self._attr_should_poll = False
self._is_on: bool | None = None
self._percentage: int | None = None
@callback
def _async_write_state_changed(self) -> None:
# cache state for restore entity
if not self.assumed_state:
self._is_on = self._device.state.on
self._percentage = self._device.state.volume
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Restore state and subscribe to device events."""
await super().async_added_to_hass()
if last_state := await self.async_get_last_state():
if last_state.state in (STATE_ON, STATE_OFF):
self._is_on = last_state.state == STATE_ON
else:
self._is_on = None
self._percentage = last_state.attributes.get(ATTR_PERCENTAGE)
self.async_on_remove(self._async_subscribe_to_device_change())
@callback
def _async_subscribe_to_device_change(self) -> Callable[[], None]:
return self._device.subscribe_to_state_change(self._async_write_state_changed)
@property
def percentage(self) -> int | None:
"""Volume level of the device."""
return self._percentage if self.assumed_state else self._device.state.volume
@property
def is_on(self) -> bool | None:
"""Power state of the device."""
return self._is_on if self.assumed_state else self._device.state.on
@property
def assumed_state(self) -> bool:
"""Return True if unable to access real state of the entity."""
return not self._device.is_connected or self._device.state is UnknownSnoozState
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the device."""
await self._async_execute_command(turn_on(percentage))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the device."""
await self._async_execute_command(turn_off())
async def async_set_percentage(self, percentage: int) -> None:
"""Set the volume of the device. A value of 0 will turn off the device."""
await self._async_execute_command(
set_volume(percentage) if percentage > 0 else turn_off()
)
async def _async_execute_command(self, command: SnoozCommandData) -> None:
result = await self._device.async_execute_command(command)
if result.status == SnoozCommandResultStatus.SUCCESSFUL:
self._async_write_state_changed()
elif result.status != SnoozCommandResultStatus.CANCELLED:
raise HomeAssistantError(
f"Command {command} failed with status {result.status.name} after {result.duration}"
)

View File

@ -0,0 +1,18 @@
{
"domain": "snooz",
"name": "Snooz",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/snooz",
"requirements": ["pysnooz==0.8.2"],
"dependencies": ["bluetooth"],
"codeowners": ["@AustinBrunkhorst"],
"bluetooth": [
{
"local_name": "Snooz*"
},
{
"service_uuid": "729f0608-496a-47fe-a124-3a62aaa3fbc0"
}
],
"iot_class": "local_push"
}

View File

@ -0,0 +1,15 @@
"""Data models for the Snooz component."""
from dataclasses import dataclass
from bleak.backends.device import BLEDevice
from pysnooz.device import SnoozDevice
@dataclass
class SnoozConfigurationData:
"""Configuration data for Snooz."""
ble_device: BLEDevice
device: SnoozDevice
title: str

View File

@ -0,0 +1,27 @@
{
"config": {
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
"step": {
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:component::bluetooth::config::step::user::data::address%]"
}
},
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"pairing_timeout": {
"description": "The device did not enter pairing mode. Click Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in."
}
},
"progress": {
"wait_for_pairing_mode": "To complete setup, put this device in pairing mode.\n\n### How to enter pairing mode\n1. Force quit SNOOZ mobile apps.\n2. Press and hold the power button on the device. Release when the lights start blinking (approximately 5 seconds)."
},
"abort": {
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}

View File

@ -0,0 +1,27 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured",
"already_in_progress": "Configuration flow is already in progress",
"no_devices_found": "No devices found on the network"
},
"flow_title": "{name}",
"progress": {
"wait_for_pairing_mode": "To complete setup, put this device in pairing mode.\n\n### How to enter pairing mode\n1. Force quit SNOOZ mobile apps.\n2. Press and hold the power button on the device. Release when the lights start blinking (approximately 5 seconds)."
},
"step": {
"bluetooth_confirm": {
"description": "Do you want to setup {name}?"
},
"pairing_timeout": {
"description": "The device did not enter pairing mode. Click Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in."
},
"user": {
"data": {
"address": "Device"
},
"description": "Choose a device to setup"
}
}
}
}

View File

@ -269,6 +269,14 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
"local_name": "SensorPush*",
"connectable": False,
},
{
"domain": "snooz",
"local_name": "Snooz*",
},
{
"domain": "snooz",
"service_uuid": "729f0608-496a-47fe-a124-3a62aaa3fbc0",
},
{
"domain": "switchbot",
"service_data_uuid": "00000d00-0000-1000-8000-00805f9b34fb",

View File

@ -356,6 +356,7 @@ FLOWS = {
"smarttub",
"smhi",
"sms",
"snooz",
"solaredge",
"solarlog",
"solax",

View File

@ -3964,6 +3964,11 @@
"iot_class": "local_polling",
"name": "SNMP"
},
"snooz": {
"config_flow": true,
"iot_class": "local_push",
"name": "Snooz"
},
"solaredge": {
"name": "SolarEdge",
"integrations": {

View File

@ -2152,6 +2152,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.snooz.*]
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.sonarr.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -1910,6 +1910,9 @@ pysml==0.0.8
# homeassistant.components.snmp
pysnmplib==5.0.15
# homeassistant.components.snooz
pysnooz==0.8.2
# homeassistant.components.soma
pysoma==0.0.10

View File

@ -1345,6 +1345,9 @@ pysmartthings==0.7.6
# homeassistant.components.snmp
pysnmplib==5.0.15
# homeassistant.components.snooz
pysnooz==0.8.2
# homeassistant.components.soma
pysoma==0.0.10

View File

@ -0,0 +1,105 @@
"""Tests for the Snooz component."""
from __future__ import annotations
from dataclasses import dataclass
from unittest.mock import patch
from bleak import BLEDevice
from pysnooz.commands import SnoozCommandData
from pysnooz.testing import MockSnoozDevice
from homeassistant.components.snooz.const import DOMAIN
from homeassistant.const import CONF_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from tests.common import MockConfigEntry
TEST_ADDRESS = "00:00:00:00:AB:CD"
TEST_SNOOZ_LOCAL_NAME = "Snooz-ABCD"
TEST_SNOOZ_DISPLAY_NAME = "Snooz ABCD"
TEST_PAIRING_TOKEN = "deadbeef"
NOT_SNOOZ_SERVICE_INFO = BluetoothServiceInfo(
name="Definitely not snooz",
address=TEST_ADDRESS,
rssi=-63,
manufacturer_data={3234: b"\x00\x01"},
service_data={},
service_uuids=[],
source="local",
)
SNOOZ_SERVICE_INFO_PAIRING = BluetoothServiceInfo(
name=TEST_SNOOZ_LOCAL_NAME,
address=TEST_ADDRESS,
rssi=-63,
manufacturer_data={65552: bytes([4]) + bytes.fromhex(TEST_PAIRING_TOKEN)},
service_uuids=[
"80c37f00-cc16-11e4-8830-0800200c9a66",
"90759319-1668-44da-9ef3-492d593bd1e5",
],
service_data={},
source="local",
)
SNOOZ_SERVICE_INFO_NOT_PAIRING = BluetoothServiceInfo(
name=TEST_SNOOZ_LOCAL_NAME,
address=TEST_ADDRESS,
rssi=-63,
manufacturer_data={65552: bytes([4]) + bytes([0] * 8)},
service_uuids=[
"80c37f00-cc16-11e4-8830-0800200c9a66",
"90759319-1668-44da-9ef3-492d593bd1e5",
],
service_data={},
source="local",
)
@dataclass
class SnoozFixture:
"""Snooz test fixture."""
entry: MockConfigEntry
device: MockSnoozDevice
async def create_mock_snooz(
connected: bool = True,
initial_state: SnoozCommandData = SnoozCommandData(on=False, volume=0),
) -> MockSnoozDevice:
"""Create a mock device."""
ble_device = SNOOZ_SERVICE_INFO_NOT_PAIRING
device = MockSnoozDevice(ble_device, initial_state=initial_state)
# execute a command to initiate the connection
if connected is True:
await device.async_execute_command(initial_state)
return device
async def create_mock_snooz_config_entry(
hass: HomeAssistant, device: MockSnoozDevice
) -> MockConfigEntry:
"""Create a mock config entry."""
with patch(
"homeassistant.components.snooz.SnoozDevice", return_value=device
), patch(
"homeassistant.components.snooz.async_ble_device_from_address",
return_value=BLEDevice(device.address, device.name),
):
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_ADDRESS,
data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,23 @@
"""Snooz test fixtures and configuration."""
from __future__ import annotations
import pytest
from homeassistant.core import HomeAssistant
from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth):
"""Auto mock bluetooth."""
@pytest.fixture()
async def mock_connected_snooz(hass: HomeAssistant):
"""Mock a Snooz configuration entry and device."""
device = await create_mock_snooz()
entry = await create_mock_snooz_config_entry(hass, device)
yield SnoozFixture(entry, device)

View File

@ -0,0 +1,26 @@
"""Test Snooz configuration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from . import SnoozFixture
async def test_removing_entry_cleans_up_connections(
hass: HomeAssistant, mock_connected_snooz: SnoozFixture
):
"""Tests setup and removal of a config entry, ensuring connections are cleaned up."""
await hass.config_entries.async_remove(mock_connected_snooz.entry.entry_id)
await hass.async_block_till_done()
assert not mock_connected_snooz.device.is_connected
async def test_reloading_entry_cleans_up_connections(
hass: HomeAssistant, mock_connected_snooz: SnoozFixture
):
"""Test reloading an entry disconnects any existing connections."""
await hass.config_entries.async_reload(mock_connected_snooz.entry.entry_id)
await hass.async_block_till_done()
assert not mock_connected_snooz.device.is_connected

View File

@ -0,0 +1,325 @@
"""Test the Snooz config flow."""
from __future__ import annotations
import asyncio
from asyncio import Event
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.snooz import DOMAIN
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import (
NOT_SNOOZ_SERVICE_INFO,
SNOOZ_SERVICE_INFO_NOT_PAIRING,
SNOOZ_SERVICE_INFO_PAIRING,
TEST_ADDRESS,
TEST_PAIRING_TOKEN,
TEST_SNOOZ_DISPLAY_NAME,
)
from tests.common import MockConfigEntry
async def test_async_step_bluetooth_valid_device(hass: HomeAssistant):
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=SNOOZ_SERVICE_INFO_PAIRING,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
await _test_setup_entry(hass, result["flow_id"])
async def test_async_step_bluetooth_waits_to_pair(hass: HomeAssistant):
"""Test discovery via bluetooth with a device that's not in pairing mode, but enters pairing mode to complete setup."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=SNOOZ_SERVICE_INFO_NOT_PAIRING,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
await _test_pairs(hass, result["flow_id"])
async def test_async_step_bluetooth_retries_pairing(hass: HomeAssistant):
"""Test discovery via bluetooth with a device that's not in pairing mode, times out waiting, but eventually complete setup."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=SNOOZ_SERVICE_INFO_NOT_PAIRING,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
retry_id = await _test_pairs_timeout(hass, result["flow_id"])
await _test_pairs(hass, retry_id)
async def test_async_step_bluetooth_not_snooz(hass: HomeAssistant):
"""Test discovery via bluetooth not Snooz."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=NOT_SNOOZ_SERVICE_INFO,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "not_supported"
async def test_async_step_user_no_devices_found(hass: HomeAssistant):
"""Test setup from service info cache with no devices found."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_async_step_user_with_found_devices(hass: HomeAssistant):
"""Test setup from service info cache with devices found."""
with patch(
"homeassistant.components.snooz.config_flow.async_discovered_service_info",
return_value=[SNOOZ_SERVICE_INFO_PAIRING],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert result["data_schema"]
# ensure discovered devices are listed as options
assert result["data_schema"].schema["name"].container == [TEST_SNOOZ_DISPLAY_NAME]
await _test_setup_entry(
hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME}
)
async def test_async_step_user_with_found_devices_waits_to_pair(hass: HomeAssistant):
"""Test setup from service info cache with devices found that require pairing mode."""
with patch(
"homeassistant.components.snooz.config_flow.async_discovered_service_info",
return_value=[SNOOZ_SERVICE_INFO_NOT_PAIRING],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
await _test_pairs(hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME})
async def test_async_step_user_with_found_devices_retries_pairing(hass: HomeAssistant):
"""Test setup from service info cache with devices found that require pairing mode, times out, then completes."""
with patch(
"homeassistant.components.snooz.config_flow.async_discovered_service_info",
return_value=[SNOOZ_SERVICE_INFO_NOT_PAIRING],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
user_input = {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME}
retry_id = await _test_pairs_timeout(hass, result["flow_id"], user_input)
await _test_pairs(hass, retry_id, user_input)
async def test_async_step_user_device_added_between_steps(hass: HomeAssistant):
"""Test the device gets added via another flow between steps."""
with patch(
"homeassistant.components.snooz.config_flow.async_discovered_service_info",
return_value=[SNOOZ_SERVICE_INFO_PAIRING],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_ADDRESS,
data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN},
)
entry.add_to_hass(hass)
with patch("homeassistant.components.snooz.async_setup_entry", return_value=True):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME},
)
assert result2["type"] == FlowResultType.ABORT
assert result2["reason"] == "already_configured"
async def test_async_step_user_with_found_devices_already_setup(hass: HomeAssistant):
"""Test setup from service info cache with devices found."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_ADDRESS,
data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN},
)
entry.add_to_hass(hass)
with patch(
"homeassistant.components.snooz.config_flow.async_discovered_service_info",
return_value=[SNOOZ_SERVICE_INFO_PAIRING],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_async_step_bluetooth_devices_already_setup(hass: HomeAssistant):
"""Test we can't start a flow if there is already a config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_ADDRESS,
data={CONF_NAME: TEST_SNOOZ_DISPLAY_NAME, CONF_TOKEN: TEST_PAIRING_TOKEN},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=SNOOZ_SERVICE_INFO_PAIRING,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant):
"""Test we can't start a flow for the same device twice."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=SNOOZ_SERVICE_INFO_PAIRING,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=SNOOZ_SERVICE_INFO_PAIRING,
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_in_progress"
async def test_async_step_user_takes_precedence_over_discovery(hass: HomeAssistant):
"""Test manual setup takes precedence over discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=SNOOZ_SERVICE_INFO_PAIRING,
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
with patch(
"homeassistant.components.snooz.config_flow.async_discovered_service_info",
return_value=[SNOOZ_SERVICE_INFO_PAIRING],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] == FlowResultType.FORM
await _test_setup_entry(
hass, result["flow_id"], {CONF_NAME: TEST_SNOOZ_DISPLAY_NAME}
)
# Verify the original one was aborted
assert not hass.config_entries.flow.async_progress()
async def _test_pairs(
hass: HomeAssistant, flow_id: str, user_input: dict | None = None
) -> None:
pairing_mode_entered = Event()
async def _async_process_advertisements(
_hass, _callback, _matcher, _mode, _timeout
):
await pairing_mode_entered.wait()
service_info = SNOOZ_SERVICE_INFO_PAIRING
assert _callback(service_info)
return service_info
with patch(
"homeassistant.components.snooz.config_flow.async_process_advertisements",
_async_process_advertisements,
):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input=user_input or {},
)
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "wait_for_pairing_mode"
pairing_mode_entered.set()
await hass.async_block_till_done()
await _test_setup_entry(hass, result["flow_id"], user_input)
async def _test_pairs_timeout(
hass: HomeAssistant, flow_id: str, user_input: dict | None = None
) -> str:
with patch(
"homeassistant.components.snooz.config_flow.async_process_advertisements",
side_effect=asyncio.TimeoutError(),
):
result = await hass.config_entries.flow.async_configure(
flow_id, user_input=user_input or {}
)
assert result["type"] == FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "wait_for_pairing_mode"
await hass.async_block_till_done()
result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result2["type"] == FlowResultType.FORM
assert result2["step_id"] == "pairing_timeout"
return result2["flow_id"]
async def _test_setup_entry(
hass: HomeAssistant, flow_id: str, user_input: dict | None = None
) -> None:
with patch("homeassistant.components.snooz.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_configure(
flow_id,
user_input=user_input or {},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_ADDRESS: TEST_ADDRESS,
CONF_TOKEN: TEST_PAIRING_TOKEN,
}
assert result["result"].unique_id == TEST_ADDRESS

View File

@ -0,0 +1,264 @@
"""Test Snooz fan entity."""
from __future__ import annotations
from datetime import timedelta
from unittest.mock import Mock
from pysnooz.api import SnoozDeviceState, UnknownSnoozState
from pysnooz.commands import SnoozCommandResult, SnoozCommandResultStatus
from pysnooz.testing import MockSnoozDevice
import pytest
from homeassistant.components import fan
from homeassistant.components.snooz.const import DOMAIN
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry
from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry
async def test_turn_on(hass: HomeAssistant, snooz_fan_entity_id: str):
"""Test turning on the device."""
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [snooz_fan_entity_id]},
blocking=True,
)
state = hass.states.get(snooz_fan_entity_id)
assert state.state == STATE_ON
assert ATTR_ASSUMED_STATE not in state.attributes
@pytest.mark.parametrize("percentage", [1, 22, 50, 99, 100])
async def test_turn_on_with_percentage(
hass: HomeAssistant, snooz_fan_entity_id: str, percentage: int
):
"""Test turning on the device with a percentage."""
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: percentage},
blocking=True,
)
state = hass.states.get(snooz_fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == percentage
assert ATTR_ASSUMED_STATE not in state.attributes
@pytest.mark.parametrize("percentage", [1, 22, 50, 99, 100])
async def test_set_percentage(
hass: HomeAssistant, snooz_fan_entity_id: str, percentage: int
):
"""Test setting the fan percentage."""
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: percentage},
blocking=True,
)
state = hass.states.get(snooz_fan_entity_id)
assert state.attributes[fan.ATTR_PERCENTAGE] == percentage
assert ATTR_ASSUMED_STATE not in state.attributes
async def test_set_0_percentage_turns_off(
hass: HomeAssistant, snooz_fan_entity_id: str
):
"""Test turning off the device by setting the percentage/volume to 0."""
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: 66},
blocking=True,
)
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: [snooz_fan_entity_id], fan.ATTR_PERCENTAGE: 0},
blocking=True,
)
state = hass.states.get(snooz_fan_entity_id)
assert state.state == STATE_OFF
# doesn't overwrite percentage when turning off
assert state.attributes[fan.ATTR_PERCENTAGE] == 66
assert ATTR_ASSUMED_STATE not in state.attributes
async def test_turn_off(hass: HomeAssistant, snooz_fan_entity_id: str):
"""Test turning off the device."""
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: [snooz_fan_entity_id]},
blocking=True,
)
state = hass.states.get(snooz_fan_entity_id)
assert state.state == STATE_OFF
assert ATTR_ASSUMED_STATE not in state.attributes
async def test_push_events(
hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str
):
"""Test state update events from snooz device."""
mock_connected_snooz.device.trigger_state(SnoozDeviceState(False, 64))
state = hass.states.get(snooz_fan_entity_id)
assert ATTR_ASSUMED_STATE not in state.attributes
assert state.state == STATE_OFF
assert state.attributes[fan.ATTR_PERCENTAGE] == 64
mock_connected_snooz.device.trigger_state(SnoozDeviceState(True, 12))
state = hass.states.get(snooz_fan_entity_id)
assert ATTR_ASSUMED_STATE not in state.attributes
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 12
mock_connected_snooz.device.trigger_disconnect()
state = hass.states.get(snooz_fan_entity_id)
assert state.attributes[ATTR_ASSUMED_STATE] is True
async def test_restore_state(hass: HomeAssistant):
"""Tests restoring entity state."""
device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState)
entry = await create_mock_snooz_config_entry(hass, device)
entity_id = get_fan_entity_id(hass, device)
# call service to store state
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [entity_id], fan.ATTR_PERCENTAGE: 33},
blocking=True,
)
# unload entry
await hass.config_entries.async_unload(entry.entry_id)
state = hass.states.get(entity_id)
assert state.state == STATE_UNAVAILABLE
# reload entry
await create_mock_snooz_config_entry(hass, device)
# should match last known state
state = hass.states.get(entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 33
assert state.attributes[ATTR_ASSUMED_STATE] is True
async def test_restore_unknown_state(hass: HomeAssistant):
"""Tests restoring entity state that was unknown."""
device = await create_mock_snooz(connected=False, initial_state=UnknownSnoozState)
entry = await create_mock_snooz_config_entry(hass, device)
entity_id = get_fan_entity_id(hass, device)
# unload entry
await hass.config_entries.async_unload(entry.entry_id)
state = hass.states.get(entity_id)
assert state.state == STATE_UNAVAILABLE
# reload entry
await create_mock_snooz_config_entry(hass, device)
# should match last known state
state = hass.states.get(entity_id)
assert state.state == STATE_UNKNOWN
async def test_command_results(
hass: HomeAssistant, mock_connected_snooz: SnoozFixture, snooz_fan_entity_id: str
):
"""Test device command results."""
mock_execute = Mock(spec=mock_connected_snooz.device.async_execute_command)
mock_connected_snooz.device.async_execute_command = mock_execute
mock_execute.return_value = SnoozCommandResult(
SnoozCommandResultStatus.SUCCESSFUL, timedelta()
)
mock_connected_snooz.device.state = SnoozDeviceState(on=True, volume=56)
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [snooz_fan_entity_id]},
blocking=True,
)
state = hass.states.get(snooz_fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 56
mock_execute.return_value = SnoozCommandResult(
SnoozCommandResultStatus.CANCELLED, timedelta()
)
mock_connected_snooz.device.state = SnoozDeviceState(on=False, volume=15)
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [snooz_fan_entity_id]},
blocking=True,
)
# the device state shouldn't be written when cancelled
state = hass.states.get(snooz_fan_entity_id)
assert state.state == STATE_ON
assert state.attributes[fan.ATTR_PERCENTAGE] == 56
mock_execute.return_value = SnoozCommandResult(
SnoozCommandResultStatus.UNEXPECTED_ERROR, timedelta()
)
with pytest.raises(HomeAssistantError) as failure:
await hass.services.async_call(
fan.DOMAIN,
fan.SERVICE_TURN_ON,
{ATTR_ENTITY_ID: [snooz_fan_entity_id]},
blocking=True,
)
assert failure.match("failed with status")
@pytest.fixture(name="snooz_fan_entity_id")
async def fixture_snooz_fan_entity_id(
hass: HomeAssistant, mock_connected_snooz: SnoozFixture
) -> str:
"""Mock a Snooz fan entity and config entry."""
yield get_fan_entity_id(hass, mock_connected_snooz.device)
def get_fan_entity_id(hass: HomeAssistant, device: MockSnoozDevice) -> str:
"""Get the entity ID for a mock device."""
return entity_registry.async_get(hass).async_get_entity_id(
Platform.FAN, DOMAIN, device.address
)