Add RGBW/RGBWW support to WiZ (#66196)

This commit is contained in:
J. Nick Koston 2022-02-10 09:08:33 -06:00 committed by GitHub
parent 51e14cebe3
commit 0fb2c78b6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 281 additions and 120 deletions

View File

@ -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()

View File

@ -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"]
}

View File

@ -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)}"

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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
)

View File

@ -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