diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index 49681392331c..30d7c5efcb02 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -96,6 +96,8 @@ class LutronLight(LutronDevice, LightEntity): if ATTR_TRANSITION in kwargs: args["fade_time_seconds"] = kwargs[ATTR_TRANSITION] self._lutron_device.set_level(**args) + # Publish now rather than waiting for the controller to report back. + self._publish_level() @override def turn_off(self, **kwargs: Any) -> None: @@ -104,6 +106,18 @@ class LutronLight(LutronDevice, LightEntity): if ATTR_TRANSITION in kwargs: args["fade_time_seconds"] = kwargs[ATTR_TRANSITION] self._lutron_device.set_level(**args) + self._publish_level() + + def _publish_level(self) -> None: + """Publish the device's current level without waiting for a report. + + Reads `last_level()` like `_update_callback` does, so a report that + lands around the same time cannot be overwritten by a separately held + assumed value -- whichever reached the library last is what gets + published. + """ + self._update_attrs() + self.schedule_update_ha_state() @property @override diff --git a/tests/components/lutron/conftest.py b/tests/components/lutron/conftest.py index 1aa71ba59b4d..bdac43e7e8b0 100644 --- a/tests/components/lutron/conftest.py +++ b/tests/components/lutron/conftest.py @@ -48,6 +48,13 @@ def mock_lutron() -> Generator[MagicMock]: light.is_dimmable = True light.type = "LIGHT" light.last_level.return_value = 0 + + # pylutron's Output.set_level() stores the new level, which last_level() + # then returns. Mirror that so the mock behaves like the library. + def _set_level(new_level, fade_time_seconds=None): + light.last_level.return_value = new_level + + light.set_level.side_effect = _set_level area.outputs.append(light) # Mock a switch diff --git a/tests/components/lutron/test_light.py b/tests/components/lutron/test_light.py index 8999004090a9..6830a9134005 100644 --- a/tests/components/lutron/test_light.py +++ b/tests/components/lutron/test_light.py @@ -226,3 +226,79 @@ async def test_light_brightness_restore( ) # HA level 127 -> Lutron level ~49.8 light.set_level.assert_called_with(new_level=pytest.approx(50.0, abs=0.5)) + + +async def test_light_state_published_without_waiting_for_a_report( + hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """State follows the command without waiting for the controller to report. + + The subscription still delivers the authoritative value later, but it can + be slow, and on a HomeWorks QS system driven by keypad scenes it never + arrives for zone levels at all. + """ + mock_config_entry.add_to_hass(hass) + + light = mock_lutron.areas[0].outputs[0] + light.last_level.return_value = 0 + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.test_area_test_light" + assert hass.states.get(entity_id).state == STATE_OFF + + # No callback is fired here: nothing reports the change back. + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_BRIGHTNESS] == 128 + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF + + +async def test_a_later_report_wins_over_the_commanded_value( + hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """The controller remains authoritative when it does report.""" + mock_config_entry.add_to_hass(hass) + + light = mock_lutron.areas[0].outputs[0] + light.last_level.return_value = 0 + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.test_area_test_light" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(entity_id).attributes[ATTR_BRIGHTNESS] == 128 + + # The controller says the zone actually landed on 100%. + light.last_level.return_value = 100 + callback = light.subscribe.call_args[0][0] + callback(light, None, None, None) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).attributes[ATTR_BRIGHTNESS] == 255