diff --git a/.coveragerc b/.coveragerc index e4d9a242604..939a6e092b0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/.strict-typing b/.strict-typing index e47bb51f173..1322adf99e1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 529925b90f6..08310e49520 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py new file mode 100644 index 00000000000..8349f781cf8 --- /dev/null +++ b/homeassistant/components/snooz/__init__.py @@ -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 diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py new file mode 100644 index 00000000000..48f9370e403 --- /dev/null +++ b/homeassistant/components/snooz/config_flow.py @@ -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) + ) diff --git a/homeassistant/components/snooz/const.py b/homeassistant/components/snooz/const.py new file mode 100644 index 00000000000..9ce16b80e05 --- /dev/null +++ b/homeassistant/components/snooz/const.py @@ -0,0 +1,6 @@ +"""Constants for the Snooz component.""" + +from homeassistant.const import Platform + +DOMAIN = "snooz" +PLATFORMS: list[Platform] = [Platform.FAN] diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py new file mode 100644 index 00000000000..8ad2372924b --- /dev/null +++ b/homeassistant/components/snooz/fan.py @@ -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}" + ) diff --git a/homeassistant/components/snooz/manifest.json b/homeassistant/components/snooz/manifest.json new file mode 100644 index 00000000000..1384767e8b8 --- /dev/null +++ b/homeassistant/components/snooz/manifest.json @@ -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" +} diff --git a/homeassistant/components/snooz/models.py b/homeassistant/components/snooz/models.py new file mode 100644 index 00000000000..d1c49fe9dc6 --- /dev/null +++ b/homeassistant/components/snooz/models.py @@ -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 diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json new file mode 100644 index 00000000000..2f957f87072 --- /dev/null +++ b/homeassistant/components/snooz/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/components/snooz/translations/en.json b/homeassistant/components/snooz/translations/en.json new file mode 100644 index 00000000000..a536a87be5b --- /dev/null +++ b/homeassistant/components/snooz/translations/en.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index b24d9e1986e..afb35e40e83 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -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", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 98f1d3ab7f8..b56e712ae27 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -356,6 +356,7 @@ FLOWS = { "smarttub", "smhi", "sms", + "snooz", "solaredge", "solarlog", "solax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1973933d735..00a3848f8c4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3964,6 +3964,11 @@ "iot_class": "local_polling", "name": "SNMP" }, + "snooz": { + "config_flow": true, + "iot_class": "local_push", + "name": "Snooz" + }, "solaredge": { "name": "SolarEdge", "integrations": { diff --git a/mypy.ini b/mypy.ini index b96efc4b8c3..63e35c3076e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index a0e26209ab9..62ad00ed40b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baee4fb2692..a92a76db6fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py new file mode 100644 index 00000000000..d5802642c37 --- /dev/null +++ b/tests/components/snooz/__init__.py @@ -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 diff --git a/tests/components/snooz/conftest.py b/tests/components/snooz/conftest.py new file mode 100644 index 00000000000..f99dcfeba72 --- /dev/null +++ b/tests/components/snooz/conftest.py @@ -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) diff --git a/tests/components/snooz/test_config.py b/tests/components/snooz/test_config.py new file mode 100644 index 00000000000..e8848aa48e0 --- /dev/null +++ b/tests/components/snooz/test_config.py @@ -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 diff --git a/tests/components/snooz/test_config_flow.py b/tests/components/snooz/test_config_flow.py new file mode 100644 index 00000000000..65076bf2e03 --- /dev/null +++ b/tests/components/snooz/test_config_flow.py @@ -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 diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py new file mode 100644 index 00000000000..30528336e2d --- /dev/null +++ b/tests/components/snooz/test_fan.py @@ -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 + )