diff --git a/homeassistant/components/modern_forms/entity.py b/homeassistant/components/modern_forms/entity.py index 84002deed3b4..77c43e655d1f 100644 --- a/homeassistant/components/modern_forms/entity.py +++ b/homeassistant/components/modern_forms/entity.py @@ -8,6 +8,22 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ModernFormsDataUpdateCoordinator +_NAME_SEPARATORS = " -_" + + +def strip_device_name_prefix(device_name: str, name: str) -> str | None: + """Strip a leading device-name prefix so has_entity_name doesn't duplicate it. + + Returns None (rather than a name identical to the device name) when + the fixture name adds nothing beyond the device name. + """ + if not device_name or not name.lower().startswith(device_name.lower()): + return name + rest = name[len(device_name) :] + if rest and rest[0] not in _NAME_SEPARATORS: + return name + return rest.lstrip(_NAME_SEPARATORS) or None + class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]): """Defines a Modern Forms device entity.""" diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 4418368c877a..04707bd459a3 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -3,6 +3,7 @@ from typing import Any, override from aiomodernforms.const import LIGHT_POWER_OFF, LIGHT_POWER_ON +from aiomodernforms.models import Light import voluptuous as vol from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity @@ -24,7 +25,7 @@ from .const import ( SERVICE_SET_LIGHT_SLEEP_TIMER, ) from .coordinator import ModernFormsConfigEntry, ModernFormsDataUpdateCoordinator -from .entity import ModernFormsDeviceEntity +from .entity import ModernFormsDeviceEntity, strip_device_name_prefix BRIGHTNESS_RANGE = (1, 255) @@ -61,11 +62,12 @@ async def async_setup_entry( ) async_add_entities( - [ - ModernFormsLightEntity( - entry_id=config_entry.entry_id, coordinator=coordinator - ) - ] + ModernFormsLightEntity( + entry_id=config_entry.entry_id, + coordinator=coordinator, + light_address=light.address, + ) + for light in coordinator.data.state.light_fixtures ) @@ -77,49 +79,79 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): _attr_translation_key = "light" def __init__( - self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator + self, + entry_id: str, + coordinator: ModernFormsDataUpdateCoordinator, + light_address: int | None, ) -> None: """Initialize Modern Forms light.""" - super().__init__( - entry_id=entry_id, - coordinator=coordinator, - ) - self._attr_unique_id = f"{self.coordinator.data.info.mac_address}" + super().__init__(entry_id=entry_id, coordinator=coordinator) + self._address = light_address + mac_address = self.coordinator.data.info.mac_address + + if light_address is None: + self._attr_unique_id = mac_address + else: + # Real Gen4 fixtures are named by the user, so the device-name + # prefix strip below is per-device data rather than static. + self._attr_unique_id = f"{mac_address}_{light_address}" + fixture_name = next( + light.name + for light in coordinator.data.state.light_fixtures + if light.address == light_address + ) + self._attr_name = strip_device_name_prefix( + self.coordinator.data.info.device_name, fixture_name + ) + + @property + def _light(self) -> Light | None: + """Return this entity's current fixture data, if it still exists.""" + for light in self.coordinator.data.state.light_fixtures: + if light.address == self._address: + return light + return None + + @property + @override + def available(self) -> bool: + """Return True if the fixture this entity represents still exists.""" + return super().available and self._light is not None @property @override def brightness(self) -> int | None: """Return the brightness of this light between 1..255.""" + if self._light is None: + return None return round( - percentage_to_ranged_value( - BRIGHTNESS_RANGE, self.coordinator.data.state.light_brightness - ) + percentage_to_ranged_value(BRIGHTNESS_RANGE, self._light.brightness) ) @property @override def is_on(self) -> bool: """Return the state of the light.""" - return bool(self.coordinator.data.state.light_on) + return self._light is not None and bool(self._light.on) @modernforms_exception_handler @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - await self.coordinator.modern_forms.light(on=LIGHT_POWER_OFF) + await self._async_control_light(on=LIGHT_POWER_OFF) @modernforms_exception_handler @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - data = {OPT_ON: LIGHT_POWER_ON} + data: dict[str, Any] = {OPT_ON: LIGHT_POWER_ON} if ATTR_BRIGHTNESS in kwargs: data[OPT_BRIGHTNESS] = ranged_value_to_percentage( BRIGHTNESS_RANGE, kwargs[ATTR_BRIGHTNESS] ) - await self.coordinator.modern_forms.light(**data) + await self._async_control_light(**data) @modernforms_exception_handler async def async_set_light_sleep_timer( @@ -127,11 +159,18 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): sleep_time: int, ) -> None: """Set a Modern Forms light sleep timer.""" - await self.coordinator.modern_forms.light(sleep=sleep_time * 60) + await self._async_control_light(sleep=sleep_time * 60) @modernforms_exception_handler async def async_clear_light_sleep_timer( self, ) -> None: """Clear a Modern Forms light sleep timer.""" - await self.coordinator.modern_forms.light(sleep=CLEAR_TIMER) + await self._async_control_light(sleep=CLEAR_TIMER) + + async def _async_control_light(self, **kwargs: Any) -> None: + """Send a control command to this entity's fixture.""" + if self._address is None: + await self.coordinator.modern_forms.light(**kwargs) + else: + await self.coordinator.modern_forms.light_fixture(self._address, **kwargs) diff --git a/tests/components/modern_forms/__init__.py b/tests/components/modern_forms/__init__.py index 0ff574b6ac60..771ee3211d3b 100644 --- a/tests/components/modern_forms/__init__.py +++ b/tests/components/modern_forms/__init__.py @@ -6,12 +6,17 @@ import json from typing import Any from aiomodernforms.const import COMMAND_QUERY_STATIC_DATA +from yarl import URL from homeassistant.components.modern_forms.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_MAC, CONTENT_TYPE_JSON from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_load_fixture, + async_load_json_object_fixture, +) from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @@ -120,3 +125,52 @@ async def init_integration( await hass.async_block_till_done() return entry + + +async def modern_forms_gen4_call_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] +) -> AiohttpClientMockResponse: + """Route Gen4 /device and /fixture requests to their fixtures.""" + fixture = ( + "device_gen4.json" if url.path.endswith("/device") else "fixture_gen4.json" + ) + return AiohttpClientMockResponse( + method=method, + url=url, + json=await async_load_json_object_fixture(hass, fixture, DOMAIN), + ) + + +async def init_integration_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + skip_setup: bool = False, + mock_type: Callable[ + [HomeAssistant, str, URL, dict[str, Any]], + Coroutine[Any, Any, AiohttpClientMockResponse], + ] = modern_forms_gen4_call_mock, +) -> MockConfigEntry: + """Set up the Modern Forms integration against a mock Gen4 device.""" + aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=404) + aioclient_mock.post( + "http://192.168.1.123:80/device", + side_effect=partial(mock_type, hass), + ) + aioclient_mock.post( + "http://192.168.1.123:80/fixture", + side_effect=partial(mock_type, hass), + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.123", CONF_MAC: "AA:BB:CC:00:11:22"}, + unique_id="AA:BB:CC:00:11:22", + ) + + entry.add_to_hass(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/modern_forms/fixtures/device_gen4.json b/tests/components/modern_forms/fixtures/device_gen4.json new file mode 100644 index 000000000000..d6b45e831643 --- /dev/null +++ b/tests/components/modern_forms/fixtures/device_gen4.json @@ -0,0 +1,8 @@ +{ + "systemType": "FAN_G4", + "deviceName": "ModernFormsFan", + "iotmVer": "01.03.0025", + "scmVer": "01.03.3008", + "owner": "someone@somewhere.com", + "staMac": "AA:BB:CC:00:11:22" +} diff --git a/tests/components/modern_forms/fixtures/fixture_gen4.json b/tests/components/modern_forms/fixtures/fixture_gen4.json new file mode 100644 index 000000000000..365981796dae --- /dev/null +++ b/tests/components/modern_forms/fixtures/fixture_gen4.json @@ -0,0 +1,31 @@ +{ + "fixture": [ + { + "addr": 1, + "type": 13, + "name": "Fan", + "detail": { "model": "2603-56" }, + "state": { + "status": true, + "fanSpeed": 3, + "fanDirection": false, + "wind": false, + "windSpeed": 3 + } + }, + { + "addr": 2, + "type": 0, + "name": "ModernFormsFan Uplight", + "detail": { "minColorTemp": 2700, "maxColorTemp": 5000 }, + "state": { "status": true, "level": 8000, "mixColorTemp": 3000 } + }, + { + "addr": 3, + "type": 0, + "name": "ModernFormsFan Downlight", + "detail": { "minColorTemp": 2700, "maxColorTemp": 5000 }, + "state": { "status": false, "level": 5000, "mixColorTemp": 4000 } + } + ] +} diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index 051a35275274..25c431c2867f 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -77,3 +77,91 @@ }), }) # --- +# name: test_entry_diagnostics_gen4 + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '192.168.1.123', + 'mac': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'modern_forms', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': 'Mock Title', + 'unique_id': 'AA:BB:CC:00:11:22', + 'version': 1, + }), + 'device': dict({ + 'info': dict({ + 'brand': None, + 'client_id': '', + 'date_code': '', + 'device_name': 'ModernFormsFan', + 'fan_motor_type': '', + 'fan_type': '2603-56', + 'federated_identity': '', + 'firmware_url': '', + 'firmware_version': '01.03.0025', + 'light_type': 'gen4', + 'mac_address': '**REDACTED**', + 'main_mcu_firmware_version': '01.03.3008', + 'owner': '**REDACTED**', + 'product_sku': '', + 'production_lot_number': '', + }), + 'status': dict({ + 'adaptive_learning_enabled': False, + 'away_mode_enabled': False, + 'decommission': False, + 'factory_reset': False, + 'fan_direction': 'forward', + 'fan_on': True, + 'fan_sleep_timer': 0, + 'fan_speed': 3, + 'fan_timer': None, + 'light_brightness': 80, + 'light_color_temp_kelvin': 3000, + 'light_fixtures': list([ + dict({ + 'address': 2, + 'brightness': 80, + 'color_temp_kelvin': 3000, + 'fixture_type': 0, + 'max_color_temp_kelvin': 5000, + 'min_color_temp_kelvin': 2700, + 'name': '**REDACTED**', + 'on': True, + }), + dict({ + 'address': 3, + 'brightness': 50, + 'color_temp_kelvin': 4000, + 'fixture_type': 0, + 'max_color_temp_kelvin': 5000, + 'min_color_temp_kelvin': 2700, + 'name': '**REDACTED**', + 'on': False, + }), + ]), + 'light_on': True, + 'light_sleep_timer': 0, + 'light_timer': None, + 'reset_rf_pair_list': False, + 'rf_pair_mode_active': False, + 'schedule': '', + 'user_data': '', + 'wind': False, + 'wind_speed': 3, + }), + }), + }) +# --- diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 7e63574d99aa..f3353fea9190 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Modern Forms config flow.""" +from functools import partial from ipaddress import ip_address from unittest.mock import MagicMock, patch @@ -13,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from . import init_integration +from . import init_integration, modern_forms_gen4_call_mock from tests.common import async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker @@ -233,3 +234,36 @@ async def test_zeroconf_with_mac_device_exists_abort( assert result.get("type") is FlowResultType.ABORT assert result.get("reason") == "already_configured" + + +async def test_full_user_flow_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the user flow successfully adds a Gen4 fan.""" + aioclient_mock.post("http://192.168.1.123:80/mf", text="", status=404) + aioclient_mock.post( + "http://192.168.1.123:80/device", + side_effect=partial(modern_forms_gen4_call_mock, hass), + ) + aioclient_mock.post( + "http://192.168.1.123:80/fixture", + side_effect=partial(modern_forms_gen4_call_mock, hass), + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with patch( + "homeassistant.components.modern_forms.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "192.168.1.123"} + ) + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "ModernFormsFan" + assert result2["data"][CONF_HOST] == "192.168.1.123" + assert result2["data"][CONF_MAC] == "AA:BB:CC:00:11:22" diff --git a/tests/components/modern_forms/test_diagnostics.py b/tests/components/modern_forms/test_diagnostics.py index 10a4c8385fab..c0d79d8bcab7 100644 --- a/tests/components/modern_forms/test_diagnostics.py +++ b/tests/components/modern_forms/test_diagnostics.py @@ -5,7 +5,7 @@ from syrupy.filters import props from homeassistant.core import HomeAssistant -from . import init_integration +from . import init_integration, init_integration_gen4 from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker @@ -24,3 +24,21 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) + + +async def test_entry_diagnostics_gen4( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test Gen4 fixture names are redacted from diagnostics.""" + entry = await init_integration_gen4(hass, aioclient_mock) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + fixture_names = [ + fixture["name"] for fixture in result["device"]["status"]["light_fixtures"] + ] + assert fixture_names == ["**REDACTED**", "**REDACTED**"] + assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) diff --git a/tests/components/modern_forms/test_light.py b/tests/components/modern_forms/test_light.py index 03ab3af607c9..f7e4b5e98013 100644 --- a/tests/components/modern_forms/test_light.py +++ b/tests/components/modern_forms/test_light.py @@ -1,9 +1,11 @@ """Tests for the Modern Forms light platform.""" +from typing import Any from unittest.mock import patch from aiomodernforms import ModernFormsConnectionError import pytest +from yarl import URL from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.components.modern_forms.const import ( @@ -17,6 +19,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) @@ -24,9 +27,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import init_integration +from . import init_integration, init_integration_gen4, modern_forms_gen4_call_mock -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import async_load_json_object_fixture +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse async def test_light_state( @@ -164,3 +168,186 @@ async def test_light_connection_error( state = hass.states.get("light.modernformsfan_light") assert state.state == STATE_UNAVAILABLE + + +async def test_light_state_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a multi-fixture Gen4 fan creates one light entity per fixture.""" + await init_integration_gen4(hass, aioclient_mock) + + state = hass.states.get("light.modernformsfan_uplight") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan Uplight" + assert state.state == STATE_ON + assert state.attributes.get(ATTR_BRIGHTNESS) == 204 + + entry = entity_registry.async_get("light.modernformsfan_uplight") + assert entry + assert entry.unique_id == "AA:BB:CC:00:11:22_2" + + state = hass.states.get("light.modernformsfan_downlight") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan Downlight" + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_BRIGHTNESS) is None + + entry = entity_registry.async_get("light.modernformsfan_downlight") + assert entry + assert entry.unique_id == "AA:BB:CC:00:11:22_3" + + +async def test_light_name_requires_word_boundary_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a fixture name isn't stripped without a real device-name boundary.""" + + async def partial_word_name_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Serve the normal Gen4 fixtures, with the uplight renamed.""" + if not url.path.endswith("/fixture"): + return await modern_forms_gen4_call_mock(hass, method, url, data) + payload = await async_load_json_object_fixture( + hass, "fixture_gen4.json", DOMAIN + ) + for fixture in payload["fixture"]: + if fixture["addr"] == 2: + fixture["name"] = "ModernFormsFancy Light" + return AiohttpClientMockResponse(method=method, url=url, json=payload) + + await init_integration_gen4(hass, aioclient_mock, mock_type=partial_word_name_mock) + + entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, "AA:BB:CC:00:11:22_2" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "ModernFormsFan ModernFormsFancy Light" + ) + + +async def test_light_name_falls_back_to_device_name_gen4( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a fixture named exactly like the device falls back to that name alone.""" + + async def exact_match_name_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Serve the normal Gen4 fixtures, with the uplight renamed.""" + if not url.path.endswith("/fixture"): + return await modern_forms_gen4_call_mock(hass, method, url, data) + payload = await async_load_json_object_fixture( + hass, "fixture_gen4.json", DOMAIN + ) + for fixture in payload["fixture"]: + if fixture["addr"] == 2: + fixture["name"] = "ModernFormsFan" + return AiohttpClientMockResponse(method=method, url=url, json=payload) + + await init_integration_gen4(hass, aioclient_mock, mock_type=exact_match_name_mock) + + entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, "AA:BB:CC:00:11:22_2" + ) + assert entity_id + state = hass.states.get(entity_id) + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "ModernFormsFan" + + +async def test_light_unavailable_when_fixture_disappears_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test a Gen4 light entity goes unavailable if its fixture disappears.""" + removed_addresses: set[int] = set() + + async def fixture_removal_mock( + hass: HomeAssistant, method: str, url: URL, data: dict[str, Any] + ) -> AiohttpClientMockResponse: + """Serve the normal Gen4 fixtures, minus any addresses removed.""" + if not url.path.endswith("/fixture") or not removed_addresses: + return await modern_forms_gen4_call_mock(hass, method, url, data) + payload = await async_load_json_object_fixture( + hass, "fixture_gen4.json", DOMAIN + ) + payload["fixture"] = [ + fixture + for fixture in payload["fixture"] + if fixture["addr"] not in removed_addresses + ] + return AiohttpClientMockResponse(method=method, url=url, json=payload) + + entry = await init_integration_gen4( + hass, aioclient_mock, mock_type=fixture_removal_mock + ) + + state = hass.states.get("light.modernformsfan_uplight") + assert state + assert state.state == STATE_ON + + removed_addresses.add(2) + await entry.runtime_data.async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("light.modernformsfan_uplight") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_light_change_state_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test Gen4 fixture entities control via light_fixture(), not light().""" + await init_integration_gen4(hass, aioclient_mock) + + with ( + patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock, + patch("aiomodernforms.ModernFormsDevice.light") as light_mock, + ): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.modernformsfan_uplight"}, + blocking=True, + ) + await hass.async_block_till_done() + light_fixture_mock.assert_called_once_with(2, on=False) + light_mock.assert_not_called() + + +async def test_sleep_timer_services_gen4( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test Gen4 sleep timer services pass sleep through light_fixture().""" + await init_integration_gen4(hass, aioclient_mock) + + with patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_LIGHT_SLEEP_TIMER, + {ATTR_ENTITY_ID: "light.modernformsfan_uplight", ATTR_SLEEP_TIME: 1}, + blocking=True, + ) + await hass.async_block_till_done() + light_fixture_mock.assert_called_once_with(2, sleep=60) + + with patch("aiomodernforms.ModernFormsDevice.light_fixture") as light_fixture_mock: + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR_LIGHT_SLEEP_TIMER, + {ATTR_ENTITY_ID: "light.modernformsfan_uplight"}, + blocking=True, + ) + await hass.async_block_till_done() + light_fixture_mock.assert_called_once_with(2, sleep=0)