diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index 5ac86fccede..cd5ee7cef83 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -26,6 +26,9 @@ class WizNumberEntityDescription(NumberEntityDescription): required_feature: str set_value_fn: Callable[[wizlight, int], Coroutine[None, None, None]] value_fn: Callable[[wizlight], int | None] + # Optional fallback, checked against runtime state when required_feature is + # not advertised by the bulb type. + supported_fn: Callable[[wizlight], bool] | None = None async def _async_set_speed(device: wizlight, speed: int) -> None: @@ -57,11 +60,30 @@ NUMBERS: tuple[WizNumberEntityDescription, ...] = ( value_fn=lambda device: cast(int | None, device.state.get_ratio()), set_value_fn=_async_set_ratio, required_feature="dual_head", + # Some ratio-based dual-head lights do not advertise this feature. + supported_fn=lambda device: ( + device.state is not None and device.state.get_ratio() is not None + ), entity_category=EntityCategory.CONFIG, ), ) +def _supports_number_description( + device: wizlight, description: WizNumberEntityDescription +) -> bool: + """Return whether the device supports a number description. + + When the bulb type does not advertise the required feature, ``supported_fn`` + is evaluated as a fallback. It inspects the current runtime state (e.g. + whether a ratio is present), so the result depends on the device state at + call time. + """ + return getattr(device.bulbtype.features, description.required_feature, False) or ( + description.supported_fn is not None and description.supported_fn(device) + ) + + async def async_setup_entry( hass: HomeAssistant, entry: WizConfigEntry, @@ -71,9 +93,7 @@ async def async_setup_entry( async_add_entities( WizSpeedNumber(entry.runtime_data, entry.title, description) for description in NUMBERS - if getattr( - entry.runtime_data.bulb.bulbtype.features, description.required_feature - ) + if _supports_number_description(entry.runtime_data.bulb, description) ) diff --git a/tests/components/wiz/test_number.py b/tests/components/wiz/test_number.py index 6bbbdd559cc..e09073a4cad 100644 --- a/tests/components/wiz/test_number.py +++ b/tests/components/wiz/test_number.py @@ -1,10 +1,13 @@ """Tests for the number platform.""" +from pywizlight import PilotParser + from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) +from homeassistant.components.wiz.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -12,11 +15,27 @@ from homeassistant.helpers import entity_registry as er from . import ( FAKE_DUAL_HEAD_RGBWW_BULB, FAKE_MAC, + FAKE_TURNABLE_BULB, + _mocked_wizlight, async_push_update, async_setup_integration, ) +async def test_ratio_not_created_without_ratio( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the ratio entity is not created for lights that report no ratio.""" + await async_setup_integration(hass, bulb_type=FAKE_TURNABLE_BULB) + + assert ( + entity_registry.async_get_entity_id( + NUMBER_DOMAIN, DOMAIN, f"{FAKE_MAC}_dual_head_ratio" + ) + is None + ) + + async def test_speed_operation( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -47,10 +66,10 @@ async def test_ratio_operation( """Test changing a dual head ratio.""" bulb, _ = await async_setup_integration(hass, bulb_type=FAKE_DUAL_HEAD_RGBWW_BULB) await async_push_update(hass, bulb, {"mac": FAKE_MAC}) - entity_id = "number.mock_title_dual_head_ratio" - assert ( - entity_registry.async_get(entity_id).unique_id == f"{FAKE_MAC}_dual_head_ratio" + entity_id = entity_registry.async_get_entity_id( + NUMBER_DOMAIN, DOMAIN, f"{FAKE_MAC}_dual_head_ratio" ) + assert entity_id is not None assert hass.states.get(entity_id).state == STATE_UNAVAILABLE await async_push_update(hass, bulb, {"mac": FAKE_MAC, "ratio": 50}) @@ -65,3 +84,36 @@ async def test_ratio_operation( bulb.set_ratio.assert_called_with(30) await async_push_update(hass, bulb, {"mac": FAKE_MAC, "ratio": 30}) assert hass.states.get(entity_id).state == "30.0" + + +async def test_ratio_operation_without_dual_head_feature( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test changing a ratio reported by a light with an unadvertised dual head feature.""" + bulb = _mocked_wizlight(None, None, FAKE_TURNABLE_BULB) + bulb.state = None + + async def _update_state() -> PilotParser: + bulb.state = PilotParser({"mac": FAKE_MAC, "ratio": 50}) + return bulb.state + + bulb.updateState.side_effect = _update_state + + await async_setup_integration(hass, wizlight=bulb) + + bulb.updateState.assert_called_once() + entity_id = entity_registry.async_get_entity_id( + NUMBER_DOMAIN, DOMAIN, f"{FAKE_MAC}_dual_head_ratio" + ) + assert entity_id is not None + assert hass.states.get(entity_id).state == "50.0" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 30}, + blocking=True, + ) + bulb.set_ratio.assert_called_with(30) + await async_push_update(hass, bulb, {"mac": FAKE_MAC, "ratio": 30}) + assert hass.states.get(entity_id).state == "30.0"