diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 9a4444c523e..40dc4cf70d1 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -4,13 +4,15 @@ from datetime import timedelta import logging from typing import Any -from pywizlight import wizlight +from pywizlight import PilotParser, wizlight +from pywizlight.bulb import PIR_SOURCE from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,6 +21,7 @@ from .const import ( DISCOVER_SCAN_TIMEOUT, DISCOVERY_INTERVAL, DOMAIN, + SIGNAL_WIZ_PIR, WIZ_CONNECT_EXCEPTIONS, WIZ_EXCEPTIONS, ) @@ -27,7 +30,7 @@ from .models import WizData _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.LIGHT, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.LIGHT, Platform.SWITCH] REQUEST_REFRESH_DELAY = 0.35 @@ -76,7 +79,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) - await bulb.start_push(lambda _: coordinator.async_set_updated_data(None)) + @callback + def _async_push_update(state: PilotParser) -> None: + """Receive a push update.""" + _LOGGER.debug("%s: Got push update: %s", bulb.mac, state.pilotResult) + coordinator.async_set_updated_data(None) + if state.get_source() == PIR_SOURCE: + async_dispatcher_send(hass, SIGNAL_WIZ_PIR.format(bulb.mac)) + + await bulb.start_push(_async_push_update) bulb.set_discovery_callback(lambda bulb: async_trigger_discovery(hass, [bulb])) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py new file mode 100644 index 00000000000..1ecb3125215 --- /dev/null +++ b/homeassistant/components/wiz/binary_sensor.py @@ -0,0 +1,81 @@ +"""WiZ integration binary sensor platform.""" +from __future__ import annotations + +from collections.abc import Callable + +from pywizlight.bulb import PIR_SOURCE + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, SIGNAL_WIZ_PIR +from .entity import WizEntity +from .models import WizData + +OCCUPANCY_UNIQUE_ID = "{}_occupancy" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the WiZ binary sensor platform.""" + wiz_data: WizData = hass.data[DOMAIN][entry.entry_id] + mac = wiz_data.bulb.mac + + if er.async_get(hass).async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, OCCUPANCY_UNIQUE_ID.format(mac) + ): + async_add_entities([WizOccupancyEntity(wiz_data, entry.title)]) + return + + cancel_dispatcher: Callable[[], None] | None = None + + @callback + def _async_add_occupancy_sensor() -> None: + nonlocal cancel_dispatcher + assert cancel_dispatcher is not None + cancel_dispatcher() + cancel_dispatcher = None + async_add_entities([WizOccupancyEntity(wiz_data, entry.title)]) + + cancel_dispatcher = async_dispatcher_connect( + hass, SIGNAL_WIZ_PIR.format(mac), _async_add_occupancy_sensor + ) + + @callback + def _async_cancel_dispatcher() -> None: + nonlocal cancel_dispatcher + if cancel_dispatcher is not None: + cancel_dispatcher() + cancel_dispatcher = None + + entry.async_on_unload(_async_cancel_dispatcher) + + +class WizOccupancyEntity(WizEntity, BinarySensorEntity): + """Representation of WiZ Occupancy sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY + + def __init__(self, wiz_data: WizData, name: str) -> None: + """Initialize an WiZ device.""" + super().__init__(wiz_data, name) + self._attr_unique_id = OCCUPANCY_UNIQUE_ID.format(self._device.mac) + self._attr_name = f"{name} Occupancy" + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + if self._device.state.get_source() == PIR_SOURCE: + self._attr_is_on = self._device.status diff --git a/homeassistant/components/wiz/const.py b/homeassistant/components/wiz/const.py index d1b3a0f6251..1aeb2ada580 100644 --- a/homeassistant/components/wiz/const.py +++ b/homeassistant/components/wiz/const.py @@ -21,3 +21,5 @@ WIZ_EXCEPTIONS = ( ConnectionRefusedError, ) WIZ_CONNECT_EXCEPTIONS = (WizLightNotKnownBulb, *WIZ_EXCEPTIONS) + +SIGNAL_WIZ_PIR = "wiz_pir_{}" diff --git a/homeassistant/components/wiz/entity.py b/homeassistant/components/wiz/entity.py index 1ddaced401f..82f19a61002 100644 --- a/homeassistant/components/wiz/entity.py +++ b/homeassistant/components/wiz/entity.py @@ -1,23 +1,24 @@ """WiZ integration entities.""" from __future__ import annotations +from abc import abstractmethod from typing import Any from pywizlight.bulblibrary import BulbType from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, ToggleEntity +from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .models import WizData -class WizToggleEntity(CoordinatorEntity, ToggleEntity): - """Representation of WiZ toggle entity.""" +class WizEntity(CoordinatorEntity, Entity): + """Representation of WiZ entity.""" def __init__(self, wiz_data: WizData, name: str) -> None: - """Initialize an WiZ device.""" + """Initialize a WiZ entity.""" super().__init__(wiz_data.coordinator) self._device = wiz_data.bulb bulb_type: BulbType = self._device.bulbtype @@ -41,6 +42,15 @@ class WizToggleEntity(CoordinatorEntity, ToggleEntity): self._async_update_attrs() super()._handle_coordinator_update() + @callback + @abstractmethod + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + + +class WizToggleEntity(WizEntity, ToggleEntity): + """Representation of WiZ toggle entity.""" + @callback def _async_update_attrs(self) -> None: """Handle updating _attr values.""" diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index 57650ede272..931dd5ec18c 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -13,6 +13,7 @@ from pywizlight.discovery import DiscoveredBulb from homeassistant.components.wiz.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -110,6 +111,15 @@ FAKE_DIMMABLE_BULB = BulbType( white_channels=1, white_to_color_ratio=80, ) +FAKE_TURNABLE_BULB = BulbType( + bulb_type=BulbClass.TW, + name="ESP01_TW_03", + features=FEATURE_MAP[BulbClass.TW], + kelvin_range=KelvinRange(2700, 6500), + fw_version="1.0.0", + white_channels=1, + white_to_color_ratio=80, +) FAKE_SOCKET = BulbType( bulb_type=BulbClass.SOCKET, name="ESP01_SOCKET_03", @@ -144,16 +154,18 @@ async def setup_integration( def _mocked_wizlight(device, extended_white_range, bulb_type) -> wizlight: - bulb = MagicMock(auto_spec=wizlight) + bulb = MagicMock(auto_spec=wizlight, name="Mocked wizlight") async def _save_setup_callback(callback: Callable) -> None: - bulb.data_receive_callback = callback + bulb.push_callback = callback bulb.getBulbConfig = AsyncMock(return_value=device or FAKE_BULB_CONFIG) bulb.getExtendedWhiteRange = AsyncMock( return_value=extended_white_range or FAKE_EXTENDED_WHITE_RANGE ) bulb.getMac = AsyncMock(return_value=FAKE_MAC) + bulb.turn_on = AsyncMock() + bulb.turn_off = AsyncMock() bulb.updateState = AsyncMock(return_value=FAKE_STATE) bulb.getSupportedScenes = AsyncMock(return_value=list(SCENES)) bulb.start_push = AsyncMock(side_effect=_save_setup_callback) @@ -169,8 +181,8 @@ def _mocked_wizlight(device, extended_white_range, bulb_type) -> wizlight: def _patch_wizlight(device=None, extended_white_range=None, bulb_type=None): @contextmanager def _patcher(): - bulb = _mocked_wizlight(device, extended_white_range, bulb_type) - with patch("homeassistant.components.wiz.wizlight", return_value=bulb,), patch( + bulb = device or _mocked_wizlight(device, extended_white_range, bulb_type) + with patch("homeassistant.components.wiz.wizlight", return_value=bulb), patch( "homeassistant.components.wiz.config_flow.wizlight", return_value=bulb, ): @@ -189,3 +201,28 @@ def _patch_discovery(): yield return _patcher() + + +async def async_setup_integration( + hass, device=None, extended_white_range=None, bulb_type=None +): + """Set up the integration with a mock device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_MAC, + data={CONF_HOST: FAKE_IP}, + ) + entry.add_to_hass(hass) + bulb = _mocked_wizlight(device, extended_white_range, bulb_type) + with _patch_discovery(), _patch_wizlight(device=bulb): + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + return bulb, entry + + +async def async_push_update(hass, device, params): + """Push an update to the device.""" + device.state = PilotParser(params) + device.status = params["state"] + device.push_callback(device.state) + await hass.async_block_till_done() diff --git a/tests/components/wiz/test_binary_sensor.py b/tests/components/wiz/test_binary_sensor.py new file mode 100644 index 00000000000..adfef066e16 --- /dev/null +++ b/tests/components/wiz/test_binary_sensor.py @@ -0,0 +1,83 @@ +"""Tests for WiZ binary_sensor platform.""" + +from homeassistant import config_entries +from homeassistant.components import wiz +from homeassistant.components.wiz.binary_sensor import OCCUPANCY_UNIQUE_ID +from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import ( + FAKE_IP, + FAKE_MAC, + _mocked_wizlight, + _patch_discovery, + _patch_wizlight, + async_push_update, + async_setup_integration, +) + +from tests.common import MockConfigEntry + + +async def test_binary_sensor_created_from_push_updates(hass: HomeAssistant) -> None: + """Test a binary sensor created from push updates.""" + bulb, _ = await async_setup_integration(hass) + + await async_push_update(hass, bulb, {"mac": FAKE_MAC, "src": "pir", "state": True}) + + entity_id = "binary_sensor.mock_title_occupancy" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == f"{FAKE_MAC}_occupancy" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await async_push_update(hass, bulb, {"mac": FAKE_MAC, "src": "pir", "state": False}) + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +async def test_binary_sensor_restored_from_registry(hass: HomeAssistant) -> None: + """Test a binary sensor restored from registry with state unknown.""" + entry = MockConfigEntry( + domain=wiz.DOMAIN, + unique_id=FAKE_MAC, + data={CONF_HOST: FAKE_IP}, + ) + entry.add_to_hass(hass) + bulb = _mocked_wizlight(None, None, None) + + entity_registry = er.async_get(hass) + reg_ent = entity_registry.async_get_or_create( + Platform.BINARY_SENSOR, wiz.DOMAIN, OCCUPANCY_UNIQUE_ID.format(bulb.mac) + ) + entity_id = reg_ent.entity_id + + with _patch_discovery(), _patch_wizlight(device=bulb): + await async_setup_component(hass, wiz.DOMAIN, {wiz.DOMAIN: {}}) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + await async_push_update(hass, bulb, {"mac": FAKE_MAC, "src": "pir", "state": True}) + + assert entity_registry.async_get(entity_id).unique_id == f"{FAKE_MAC}_occupancy" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == config_entries.ConfigEntryState.NOT_LOADED + + +async def test_binary_sensor_never_created_no_error_on_unload( + hass: HomeAssistant, +) -> None: + """Test a binary sensor does not error on unload.""" + _, entry = await async_setup_integration(hass) + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index dc4bd4de329..f87f1b75437 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -12,7 +12,6 @@ from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM from . import ( - FAKE_BULB_CONFIG, FAKE_DIMMABLE_BULB, FAKE_EXTENDED_WHITE_RANGE, FAKE_IP, @@ -20,7 +19,6 @@ from . import ( FAKE_RGBW_BULB, FAKE_RGBWW_BULB, FAKE_SOCKET, - FAKE_SOCKET_CONFIG, TEST_CONNECTION, TEST_SYSTEM_INFO, _patch_discovery, @@ -184,12 +182,11 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): @pytest.mark.parametrize( - "source, data, device, bulb_type, extended_white_range, name", + "source, data, bulb_type, extended_white_range, name", [ ( config_entries.SOURCE_DHCP, DHCP_DISCOVERY, - FAKE_BULB_CONFIG, FAKE_DIMMABLE_BULB, FAKE_EXTENDED_WHITE_RANGE, "WiZ Dimmable White ABCABC", @@ -197,7 +194,6 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ( config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY, - FAKE_BULB_CONFIG, FAKE_DIMMABLE_BULB, FAKE_EXTENDED_WHITE_RANGE, "WiZ Dimmable White ABCABC", @@ -205,7 +201,6 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ( config_entries.SOURCE_DHCP, DHCP_DISCOVERY, - FAKE_BULB_CONFIG, FAKE_RGBW_BULB, FAKE_EXTENDED_WHITE_RANGE, "WiZ RGBW Tunable ABCABC", @@ -213,7 +208,6 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ( config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY, - FAKE_BULB_CONFIG, FAKE_RGBW_BULB, FAKE_EXTENDED_WHITE_RANGE, "WiZ RGBW Tunable ABCABC", @@ -221,7 +215,6 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ( config_entries.SOURCE_DHCP, DHCP_DISCOVERY, - FAKE_BULB_CONFIG, FAKE_RGBWW_BULB, FAKE_EXTENDED_WHITE_RANGE, "WiZ RGBWW Tunable ABCABC", @@ -229,7 +222,6 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ( config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY, - FAKE_BULB_CONFIG, FAKE_RGBWW_BULB, FAKE_EXTENDED_WHITE_RANGE, "WiZ RGBWW Tunable ABCABC", @@ -237,7 +229,6 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ( config_entries.SOURCE_DHCP, DHCP_DISCOVERY, - FAKE_SOCKET_CONFIG, FAKE_SOCKET, None, "WiZ Socket ABCABC", @@ -245,7 +236,6 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ( config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY, - FAKE_SOCKET_CONFIG, FAKE_SOCKET, None, "WiZ Socket ABCABC", @@ -253,11 +243,11 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): ], ) async def test_discovered_by_dhcp_or_integration_discovery( - hass, source, data, device, bulb_type, extended_white_range, name + hass, source, data, bulb_type, extended_white_range, name ): """Test we can configure when discovered from dhcp or discovery.""" with _patch_wizlight( - device=device, extended_white_range=extended_white_range, bulb_type=bulb_type + device=None, extended_white_range=extended_white_range, bulb_type=bulb_type ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data @@ -268,7 +258,7 @@ async def test_discovered_by_dhcp_or_integration_discovery( assert result["step_id"] == "discovery_confirm" with _patch_wizlight( - device=device, extended_white_range=extended_white_range, bulb_type=bulb_type + device=None, extended_white_range=extended_white_range, bulb_type=bulb_type ), patch( "homeassistant.components.wiz.async_setup_entry", return_value=True, @@ -423,7 +413,7 @@ async def test_setup_via_discovery_cannot_connect(hass): async def test_discovery_with_firmware_update(hass): """Test we check the device again between first discovery and config entry creation.""" with _patch_wizlight( - device=FAKE_BULB_CONFIG, + device=None, extended_white_range=FAKE_EXTENDED_WHITE_RANGE, bulb_type=FAKE_RGBW_BULB, ): @@ -447,7 +437,7 @@ async def test_discovery_with_firmware_update(hass): ) as mock_setup_entry, patch( "homeassistant.components.wiz.async_setup", return_value=True ) as mock_setup, _patch_wizlight( - device=FAKE_BULB_CONFIG, + device=None, extended_white_range=FAKE_EXTENDED_WHITE_RANGE, bulb_type=FAKE_RGBWW_BULB, ):