mirror of https://github.com/home-assistant/core
Add support for Snooz BLE devices (#78790)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
4281384d2a
commit
7d097d18b0
|
@ -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
|
||||
|
|
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
)
|
|
@ -0,0 +1,6 @@
|
|||
"""Constants for the Snooz component."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "snooz"
|
||||
PLATFORMS: list[Platform] = [Platform.FAN]
|
|
@ -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}"
|
||||
)
|
|
@ -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"
|
||||
}
|
|
@ -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
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -356,6 +356,7 @@ FLOWS = {
|
|||
"smarttub",
|
||||
"smhi",
|
||||
"sms",
|
||||
"snooz",
|
||||
"solaredge",
|
||||
"solarlog",
|
||||
"solax",
|
||||
|
|
|
@ -3964,6 +3964,11 @@
|
|||
"iot_class": "local_polling",
|
||||
"name": "SNMP"
|
||||
},
|
||||
"snooz": {
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"name": "Snooz"
|
||||
},
|
||||
"solaredge": {
|
||||
"name": "SolarEdge",
|
||||
"integrations": {
|
||||
|
|
10
mypy.ini
10
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
)
|
Loading…
Reference in New Issue