diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 2c4485c5b72..d0473c4f5c3 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -1,22 +1,22 @@ """WiZ integration light platform.""" from __future__ import annotations -import logging from typing import Any from pywizlight import PilotBuilder from pywizlight.bulblibrary import BulbClass, BulbType, Features -from pywizlight.rgbcw import convertHSfromRGBCW from pywizlight.scenes import get_id_from_scene_name from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, - ATTR_HS_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, - COLOR_MODE_HS, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, SUPPORT_EFFECT, LightEntity, ) @@ -32,7 +32,30 @@ from .const import DOMAIN from .entity import WizToggleEntity from .models import WizData -_LOGGER = logging.getLogger(__name__) + +def _async_pilot_builder(**kwargs: Any) -> PilotBuilder: + """Create the PilotBuilder for turn on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + + if ATTR_RGBWW_COLOR in kwargs: + return PilotBuilder(brightness=brightness, rgbww=kwargs[ATTR_RGBWW_COLOR]) + + if ATTR_RGBW_COLOR in kwargs: + return PilotBuilder(brightness=brightness, rgbw=kwargs[ATTR_RGBW_COLOR]) + + if ATTR_COLOR_TEMP in kwargs: + return PilotBuilder( + brightness=brightness, + colortemp=color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]), + ) + + if ATTR_EFFECT in kwargs: + scene_id = get_id_from_scene_name(kwargs[ATTR_EFFECT]) + if scene_id == 1000: # rhythm + return PilotBuilder() + return PilotBuilder(brightness=brightness, scene=scene_id) + + return PilotBuilder(brightness=brightness) async def async_setup_entry( @@ -56,7 +79,10 @@ class WizBulbEntity(WizToggleEntity, LightEntity): features: Features = bulb_type.features color_modes = set() if features.color: - color_modes.add(COLOR_MODE_HS) + if bulb_type.white_channels == 2: + color_modes.add(COLOR_MODE_RGBWW) + else: + color_modes.add(COLOR_MODE_RGBW) if features.color_tmp: color_modes.add(COLOR_MODE_COLOR_TEMP) if not color_modes and features.brightness: @@ -82,54 +108,26 @@ class WizBulbEntity(WizToggleEntity, LightEntity): assert color_modes is not None if (brightness := state.get_brightness()) is not None: self._attr_brightness = max(0, min(255, brightness)) - if COLOR_MODE_COLOR_TEMP in color_modes and state.get_colortemp() is not None: - self._attr_color_mode = COLOR_MODE_COLOR_TEMP - if color_temp := state.get_colortemp(): - self._attr_color_temp = color_temperature_kelvin_to_mired(color_temp) - elif ( - COLOR_MODE_HS in color_modes - and (rgb := state.get_rgb()) is not None - and rgb[0] is not None + if ( + COLOR_MODE_COLOR_TEMP in color_modes + and (color_temp := state.get_colortemp()) is not None ): - if (warm_white := state.get_warm_white()) is not None: - self._attr_hs_color = convertHSfromRGBCW(rgb, warm_white) - self._attr_color_mode = COLOR_MODE_HS + self._attr_color_mode = COLOR_MODE_COLOR_TEMP + self._attr_color_temp = color_temperature_kelvin_to_mired(color_temp) + elif ( + COLOR_MODE_RGBWW in color_modes and (rgbww := state.get_rgbww()) is not None + ): + self._attr_rgbww_color = rgbww + self._attr_color_mode = COLOR_MODE_RGBWW + elif COLOR_MODE_RGBW in color_modes and (rgbw := state.get_rgbw()) is not None: + self._attr_rgbw_color = rgbw + self._attr_color_mode = COLOR_MODE_RGBW else: self._attr_color_mode = COLOR_MODE_BRIGHTNESS self._attr_effect = state.get_scene() super()._async_update_attrs() - @callback - def _async_pilot_builder(self, **kwargs: Any) -> PilotBuilder: - """Create the PilotBuilder for turn on.""" - brightness = kwargs.get(ATTR_BRIGHTNESS) - - if ATTR_HS_COLOR in kwargs: - return PilotBuilder( - hucolor=(kwargs[ATTR_HS_COLOR][0], kwargs[ATTR_HS_COLOR][1]), - brightness=brightness, - ) - - color_temp = None - if ATTR_COLOR_TEMP in kwargs: - color_temp = color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) - - scene_id = None - if ATTR_EFFECT in kwargs: - scene_id = get_id_from_scene_name(kwargs[ATTR_EFFECT]) - if scene_id == 1000: # rhythm - return PilotBuilder() - - _LOGGER.debug( - "[wizlight %s] Pilot will be sent with brightness=%s, color_temp=%s, scene_id=%s", - self._device.ip, - brightness, - color_temp, - scene_id, - ) - return PilotBuilder(brightness=brightness, colortemp=color_temp, scene=scene_id) - async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" - await self._device.turn_on(self._async_pilot_builder(**kwargs)) + await self._device.turn_on(_async_pilot_builder(**kwargs)) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 47a4f343d4f..07b306f1121 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -8,7 +8,7 @@ ], "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/wiz", - "requirements": ["pywizlight==0.5.3"], + "requirements": ["pywizlight==0.5.5"], "iot_class": "local_push", "codeowners": ["@sbidy"] } diff --git a/homeassistant/components/wiz/utils.py b/homeassistant/components/wiz/utils.py index 42be5758130..278989b5b2b 100644 --- a/homeassistant/components/wiz/utils.py +++ b/homeassistant/components/wiz/utils.py @@ -2,6 +2,7 @@ from __future__ import annotations from pywizlight import BulbType +from pywizlight.bulblibrary import BulbClass from .const import DEFAULT_NAME @@ -13,4 +14,11 @@ def _short_mac(mac: str) -> str: def name_from_bulb_type_and_mac(bulb_type: BulbType, mac: str) -> str: """Generate a name from bulb_type and mac.""" - return f"{DEFAULT_NAME} {bulb_type.bulb_type.value} {_short_mac(mac)}" + if bulb_type.bulb_type == BulbClass.RGB: + if bulb_type.white_channels == 2: + description = "RGBWW Tunable" + else: + description = "RGBW Tunable" + else: + description = bulb_type.bulb_type.value + return f"{DEFAULT_NAME} {description} {_short_mac(mac)}" diff --git a/requirements_all.txt b/requirements_all.txt index 5376c7a5f11..09e983c66f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2057,7 +2057,7 @@ pywemo==0.7.0 pywilight==0.0.70 # homeassistant.components.wiz -pywizlight==0.5.3 +pywizlight==0.5.5 # homeassistant.components.xeoma pyxeoma==1.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae3fe7eb5c4..78826760d0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1282,7 +1282,7 @@ pywemo==0.7.0 pywilight==0.0.70 # homeassistant.components.wiz -pywizlight==0.5.3 +pywizlight==0.5.5 # homeassistant.components.zerproc pyzerproc==0.4.8 diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index c2f67982b84..57650ede272 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -1,13 +1,63 @@ """Tests for the WiZ Platform integration.""" +from contextlib import contextmanager +from copy import deepcopy import json +from typing import Callable +from unittest.mock import AsyncMock, MagicMock, patch + +from pywizlight import SCENES, BulbType, PilotParser, wizlight +from pywizlight.bulblibrary import FEATURE_MAP, BulbClass, KelvinRange +from pywizlight.discovery import DiscoveredBulb from homeassistant.components.wiz.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.helpers.typing import HomeAssistantType from tests.common import MockConfigEntry +FAKE_STATE = PilotParser( + { + "mac": "a8bb50818e7c", + "rssi": -55, + "src": "hb", + "mqttCd": 0, + "ts": 1644425347, + "state": True, + "sceneId": 0, + "r": 0, + "g": 0, + "b": 255, + "c": 0, + "w": 0, + "dimming": 100, + } +) +FAKE_IP = "1.1.1.1" +FAKE_MAC = "ABCABCABCABC" +FAKE_BULB_CONFIG = { + "method": "getSystemConfig", + "env": "pro", + "result": { + "mac": FAKE_MAC, + "homeId": 653906, + "roomId": 989983, + "moduleName": "ESP_0711_STR", + "fwVersion": "1.21.0", + "groupId": 0, + "drvConf": [20, 2], + "ewf": [255, 0, 255, 255, 0, 0, 0], + "ewfHex": "ff00ffff000000", + "ping": 0, + }, +} +FAKE_SOCKET_CONFIG = deepcopy(FAKE_BULB_CONFIG) +FAKE_SOCKET_CONFIG["result"]["moduleName"] = "ESP10_SOCKET_06" +FAKE_EXTENDED_WHITE_RANGE = [2200, 2700, 6500, 6500] +TEST_SYSTEM_INFO = {"id": FAKE_MAC, "name": "Test Bulb"} +TEST_CONNECTION = {CONF_HOST: "1.1.1.1"} +TEST_NO_IP = {CONF_HOST: "this is no IP input"} + FAKE_BULB_CONFIG = json.loads( '{"method":"getSystemConfig","env":"pro","result":\ {"mac":"ABCABCABCABC",\ @@ -33,10 +83,42 @@ REAL_BULB_CONFIG = json.loads( "ewfHex":"ff00ffff000000",\ "ping":0}}' ) - -TEST_SYSTEM_INFO = {"id": "ABCABCABCABC", "name": "Test Bulb"} - -TEST_CONNECTION = {CONF_IP_ADDRESS: "1.1.1.1", CONF_NAME: "Test Bulb"} +FAKE_RGBWW_BULB = BulbType( + bulb_type=BulbClass.RGB, + name="ESP01_SHRGB_03", + features=FEATURE_MAP[BulbClass.RGB], + kelvin_range=KelvinRange(2700, 6500), + fw_version="1.0.0", + white_channels=2, + white_to_color_ratio=80, +) +FAKE_RGBW_BULB = BulbType( + bulb_type=BulbClass.RGB, + name="ESP01_SHRGB_03", + features=FEATURE_MAP[BulbClass.RGB], + kelvin_range=KelvinRange(2700, 6500), + fw_version="1.0.0", + white_channels=1, + white_to_color_ratio=80, +) +FAKE_DIMMABLE_BULB = BulbType( + bulb_type=BulbClass.DW, + name="ESP01_DW_03", + features=FEATURE_MAP[BulbClass.DW], + 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", + features=FEATURE_MAP[BulbClass.SOCKET], + kelvin_range=KelvinRange(2700, 6500), + fw_version="1.0.0", + white_channels=2, + white_to_color_ratio=80, +) async def setup_integration( @@ -48,7 +130,7 @@ async def setup_integration( domain=DOMAIN, unique_id=TEST_SYSTEM_INFO["id"], data={ - CONF_IP_ADDRESS: "127.0.0.1", + CONF_HOST: "127.0.0.1", CONF_NAME: TEST_SYSTEM_INFO["name"], }, ) @@ -59,3 +141,51 @@ async def setup_integration( await hass.async_block_till_done() return entry + + +def _mocked_wizlight(device, extended_white_range, bulb_type) -> wizlight: + bulb = MagicMock(auto_spec=wizlight) + + async def _save_setup_callback(callback: Callable) -> None: + bulb.data_receive_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.updateState = AsyncMock(return_value=FAKE_STATE) + bulb.getSupportedScenes = AsyncMock(return_value=list(SCENES)) + bulb.start_push = AsyncMock(side_effect=_save_setup_callback) + bulb.async_close = AsyncMock() + bulb.state = FAKE_STATE + bulb.mac = FAKE_MAC + bulb.bulbtype = bulb_type or FAKE_DIMMABLE_BULB + bulb.get_bulbtype = AsyncMock(return_value=bulb_type or FAKE_DIMMABLE_BULB) + + return bulb + + +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( + "homeassistant.components.wiz.config_flow.wizlight", + return_value=bulb, + ): + yield + + return _patcher() + + +def _patch_discovery(): + @contextmanager + def _patcher(): + with patch( + "homeassistant.components.wiz.discovery.find_wizlights", + return_value=[DiscoveredBulb(FAKE_IP, FAKE_MAC)], + ): + yield + + return _patcher() diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index 28645e53e14..465bc3e3d27 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -1,10 +1,7 @@ """Test the WiZ Platform config flow.""" -from contextlib import contextmanager -from copy import deepcopy from unittest.mock import patch import pytest -from pywizlight.discovery import DiscoveredBulb from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError from homeassistant import config_entries @@ -14,34 +11,24 @@ from homeassistant.components.wiz.const import DOMAIN 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, + FAKE_MAC, + FAKE_RGBW_BULB, + FAKE_RGBWW_BULB, + FAKE_SOCKET, + FAKE_SOCKET_CONFIG, + TEST_CONNECTION, + TEST_SYSTEM_INFO, + _patch_discovery, + _patch_wizlight, +) + from tests.common import MockConfigEntry -FAKE_IP = "1.1.1.1" -FAKE_MAC = "ABCABCABCABC" -FAKE_BULB_CONFIG = { - "method": "getSystemConfig", - "env": "pro", - "result": { - "mac": FAKE_MAC, - "homeId": 653906, - "roomId": 989983, - "moduleName": "ESP_0711_STR", - "fwVersion": "1.21.0", - "groupId": 0, - "drvConf": [20, 2], - "ewf": [255, 0, 255, 255, 0, 0, 0], - "ewfHex": "ff00ffff000000", - "ping": 0, - }, -} -FAKE_SOCKET_CONFIG = deepcopy(FAKE_BULB_CONFIG) -FAKE_SOCKET_CONFIG["result"]["moduleName"] = "ESP10_SOCKET_06" -FAKE_EXTENDED_WHITE_RANGE = [2200, 2700, 6500, 6500] -TEST_SYSTEM_INFO = {"id": FAKE_MAC, "name": "Test Bulb"} -TEST_CONNECTION = {CONF_HOST: "1.1.1.1"} -TEST_NO_IP = {CONF_HOST: "this is no IP input"} - - DHCP_DISCOVERY = dhcp.DhcpServiceInfo( hostname="wiz_abcabc", ip=FAKE_IP, @@ -55,36 +42,6 @@ INTEGRATION_DISCOVERY = { } -def _patch_wizlight(device=None, extended_white_range=None): - @contextmanager - def _patcher(): - with patch( - "homeassistant.components.wiz.wizlight.getBulbConfig", - return_value=device or FAKE_BULB_CONFIG, - ), patch( - "homeassistant.components.wiz.wizlight.getExtendedWhiteRange", - return_value=extended_white_range or FAKE_EXTENDED_WHITE_RANGE, - ), patch( - "homeassistant.components.wiz.wizlight.getMac", - return_value=FAKE_MAC, - ): - yield - - return _patcher() - - -def _patch_discovery(): - @contextmanager - def _patcher(): - with patch( - "homeassistant.components.wiz.discovery.find_wizlights", - return_value=[DiscoveredBulb(FAKE_IP, FAKE_MAC)], - ): - yield - - return _patcher() - - async def test_form(hass): """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -227,12 +184,13 @@ async def test_discovered_by_dhcp_connection_fails(hass, source, data): @pytest.mark.parametrize( - "source, data, device, extended_white_range, name", + "source, data, device, 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", ), @@ -240,13 +198,47 @@ 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", ), + ( + config_entries.SOURCE_DHCP, + DHCP_DISCOVERY, + FAKE_BULB_CONFIG, + FAKE_RGBW_BULB, + FAKE_EXTENDED_WHITE_RANGE, + "WiZ RGBW Tunable ABCABC", + ), + ( + config_entries.SOURCE_INTEGRATION_DISCOVERY, + INTEGRATION_DISCOVERY, + FAKE_BULB_CONFIG, + FAKE_RGBW_BULB, + FAKE_EXTENDED_WHITE_RANGE, + "WiZ RGBW Tunable ABCABC", + ), + ( + config_entries.SOURCE_DHCP, + DHCP_DISCOVERY, + FAKE_BULB_CONFIG, + FAKE_RGBWW_BULB, + FAKE_EXTENDED_WHITE_RANGE, + "WiZ RGBWW Tunable ABCABC", + ), + ( + config_entries.SOURCE_INTEGRATION_DISCOVERY, + INTEGRATION_DISCOVERY, + FAKE_BULB_CONFIG, + FAKE_RGBWW_BULB, + FAKE_EXTENDED_WHITE_RANGE, + "WiZ RGBWW Tunable ABCABC", + ), ( config_entries.SOURCE_DHCP, DHCP_DISCOVERY, FAKE_SOCKET_CONFIG, + FAKE_SOCKET, None, "WiZ Socket ABCABC", ), @@ -254,16 +246,19 @@ 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", ), ], ) async def test_discovered_by_dhcp_or_integration_discovery( - hass, source, data, device, extended_white_range, name + hass, source, data, device, 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): + with _patch_wizlight( + device=device, extended_white_range=extended_white_range, bulb_type=bulb_type + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": source}, data=data ) diff --git a/tests/components/wiz/test_light.py b/tests/components/wiz/test_light.py new file mode 100644 index 00000000000..16d7c6a0a5d --- /dev/null +++ b/tests/components/wiz/test_light.py @@ -0,0 +1,30 @@ +"""Tests for light platform.""" + +from homeassistant.components import wiz +from homeassistant.const import CONF_HOST, STATE_ON +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, _patch_discovery, _patch_wizlight + +from tests.common import MockConfigEntry + + +async def test_light_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + entry = MockConfigEntry( + domain=wiz.DOMAIN, + unique_id=FAKE_MAC, + data={CONF_HOST: FAKE_IP}, + ) + entry.add_to_hass(hass) + with _patch_discovery(), _patch_wizlight(): + await async_setup_component(hass, wiz.DOMAIN, {wiz.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.mock_title" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == FAKE_MAC + state = hass.states.get(entity_id) + assert state.state == STATE_ON