diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 32782338fe5..77b97472747 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -5,7 +5,6 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -16,7 +15,7 @@ PLATFORMS = (LIGHT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WLED from a config entry.""" - coordinator = WLEDDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) + coordinator = WLEDDataUpdateCoordinator(hass, entry=entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 16d56705879..b730ac1543a 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -6,25 +6,37 @@ from typing import Callable from wled import WLED, Device as WLEDDevice, WLEDConnectionClosed, WLEDError -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import ( + CONF_KEEP_MASTER_LIGHT, + DEFAULT_KEEP_MASTER_LIGHT, + DOMAIN, + LOGGER, + SCAN_INTERVAL, +) class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" + keep_master_light: bool + def __init__( self, hass: HomeAssistant, *, - host: str, + entry: ConfigEntry, ) -> None: """Initialize global WLED data updater.""" - self.wled = WLED(host, session=async_get_clientsession(hass)) + self.keep_master_light = entry.options.get( + CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT + ) + self.wled = WLED(entry.data[CONF_HOST], session=async_get_clientsession(hass)) self.unsub: Callable | None = None super().__init__( @@ -34,6 +46,13 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): update_interval=SCAN_INTERVAL, ) + @property + def has_master_light(self) -> bool: + """Return if the coordinated device has an master light.""" + return self.keep_master_light or ( + self.data is not None and len(self.data.state.segments) > 1 + ) + def update_listeners(self) -> None: """Call update on all listeners.""" for update_callback in self._listeners: diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 533cb595638..0cb45caab87 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -24,9 +24,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import ( - async_get_registry as async_get_entity_registry, -) from .const import ( ATTR_COLOR_PRIMARY, @@ -38,8 +35,6 @@ from .const import ( ATTR_REVERSE, ATTR_SEGMENT_ID, ATTR_SPEED, - CONF_KEEP_MASTER_LIGHT, - DEFAULT_KEEP_MASTER_LIGHT, DOMAIN, SERVICE_EFFECT, SERVICE_PRESET, @@ -87,17 +82,13 @@ async def async_setup_entry( "async_preset", ) - keep_master_light = entry.options.get( - CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT - ) - if keep_master_light: + if coordinator.keep_master_light: async_add_entities([WLEDMasterLight(coordinator=coordinator)]) update_segments = partial( async_update_segments, entry, coordinator, - keep_master_light, {}, async_add_entities, ) @@ -130,6 +121,11 @@ class WLEDMasterLight(WLEDEntity, LightEntity): """Return the state of the light.""" return bool(self.coordinator.data.state.on) + @property + def available(self) -> bool: + """Return if this master light is available or not.""" + return self.coordinator.has_master_light and super().available + @wled_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" @@ -182,18 +178,17 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): self, coordinator: WLEDDataUpdateCoordinator, segment: int, - keep_master_light: bool, ) -> None: """Initialize WLED segment light.""" super().__init__(coordinator=coordinator) - self._keep_master_light = keep_master_light self._rgbw = coordinator.data.info.leds.rgbw self._wv = coordinator.data.info.leds.wv self._segment = segment - # If this is the one and only segment, use a simpler name + # Segment 0 uses a simpler name, which is more natural for when using + # a single segment / using WLED with one big LED strip. self._attr_name = f"{coordinator.data.info.name} Segment {segment}" - if len(coordinator.data.state.segments) == 1: + if segment == 0: self._attr_name = coordinator.data.info.name self._attr_unique_id = ( @@ -264,7 +259,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): # If this is the one and only segment, calculate brightness based # on the master and segment brightness - if not self._keep_master_light and len(state.segments) == 1: + if not self.coordinator.has_master_light: return int( (state.segments[self._segment].brightness * state.brightness) / 255 ) @@ -281,8 +276,9 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): """Return the state of the light.""" state = self.coordinator.data.state - # If there is a single segment, take master into account - if len(state.segments) == 1 and not state.on: + # If there is no master, we take the master state into account + # on the segment level. + if not self.coordinator.has_master_light and not state.on: return False return bool(state.segments[self._segment].on) @@ -295,11 +291,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): # WLED uses 100ms per unit, so 10 = 1 second. transition = round(kwargs[ATTR_TRANSITION] * 10) - # If there is a single segment, control via the master - if ( - not self._keep_master_light - and len(self.coordinator.data.state.segments) == 1 - ): + # If there is no master control, and only 1 segment, handle the + if not self.coordinator.has_master_light: await self.coordinator.wled.master(on=False, transition=transition) return @@ -331,12 +324,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): if ATTR_EFFECT in kwargs: data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] - # When only 1 segment is present, switch along the master, and use - # the master for power/brightness control. - if ( - not self._keep_master_light - and len(self.coordinator.data.state.segments) == 1 - ): + # If there is no master control, and only 1 segment, handle the master + if not self.coordinator.has_master_light: master_data = {ATTR_ON: True} if ATTR_BRIGHTNESS in data: master_data[ATTR_BRIGHTNESS] = data[ATTR_BRIGHTNESS] @@ -384,56 +373,28 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): def async_update_segments( entry: ConfigEntry, coordinator: WLEDDataUpdateCoordinator, - keep_master_light: bool, current: dict[int, WLEDSegmentLight | WLEDMasterLight], async_add_entities, ) -> None: """Update segments.""" segment_ids = {light.segment_id for light in coordinator.data.state.segments} current_ids = set(current) + new_entities = [] # Discard master (if present) current_ids.discard(-1) - new_entities = [] - # Process new segments, add them to Home Assistant for segment_id in segment_ids - current_ids: - current[segment_id] = WLEDSegmentLight( - coordinator, segment_id, keep_master_light - ) + current[segment_id] = WLEDSegmentLight(coordinator, segment_id) new_entities.append(current[segment_id]) - # More than 1 segment now? Add master controls - if not keep_master_light and (len(current_ids) < 2 and len(segment_ids) > 1): + # More than 1 segment now? No master? Add master controls + if not coordinator.keep_master_light and ( + len(current_ids) < 2 and len(segment_ids) > 1 + ): current[-1] = WLEDMasterLight(coordinator) new_entities.append(current[-1]) if new_entities: async_add_entities(new_entities) - - # Process deleted segments, remove them from Home Assistant - for segment_id in current_ids - segment_ids: - coordinator.hass.async_create_task( - async_remove_entity(segment_id, coordinator, current) - ) - - # Remove master if there is only 1 segment left - if not keep_master_light and len(current_ids) > 1 and len(segment_ids) < 2: - coordinator.hass.async_create_task( - async_remove_entity(-1, coordinator, current) - ) - - -async def async_remove_entity( - index: int, - coordinator: WLEDDataUpdateCoordinator, - current: dict[int, WLEDSegmentLight | WLEDMasterLight], -) -> None: - """Remove WLED segment light from Home Assistant.""" - entity = current[index] - await entity.async_remove(force_remove=True) - registry = await async_get_entity_registry(coordinator.hass) - if entity.entity_id in registry.entities: - registry.async_remove(entity.entity_id) - del current[index] diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index a4e3f712547..d61b675e2f2 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -50,7 +50,7 @@ async def test_rgb_light_state( entity_registry = er.async_get(hass) # First segment of the strip - state = hass.states.get("light.wled_rgb_light_segment_0") + state = hass.states.get("light.wled_rgb_light") assert state assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.attributes.get(ATTR_EFFECT) == "Solid" @@ -64,7 +64,7 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_SPEED) == 32 assert state.state == STATE_ON - entry = entity_registry.async_get("light.wled_rgb_light_segment_0") + entry = entity_registry.async_get("light.wled_rgb_light") assert entry assert entry.unique_id == "aabbccddeeff_0" @@ -107,7 +107,7 @@ async def test_segment_change_state( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", ATTR_TRANSITION: 5}, + {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_TRANSITION: 5}, blocking=True, ) await hass.async_block_till_done() @@ -124,7 +124,7 @@ async def test_segment_change_state( { ATTR_BRIGHTNESS: 42, ATTR_EFFECT: "Chase", - ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", + ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_RGB_COLOR: [255, 0, 0], ATTR_TRANSITION: 5, }, @@ -211,36 +211,53 @@ async def test_master_change_state( ) +@pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) async def test_dynamically_handle_segments( hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test if a new/deleted segment is dynamically added/removed.""" - assert hass.states.get("light.wled_rgb_light_master") - assert hass.states.get("light.wled_rgb_light_segment_0") - assert hass.states.get("light.wled_rgb_light_segment_1") + master = hass.states.get("light.wled_rgb_light_master") + segment0 = hass.states.get("light.wled_rgb_light") + segment1 = hass.states.get("light.wled_rgb_light_segment_1") + assert segment0 + assert segment0.state == STATE_ON + assert not master + assert not segment1 return_value = mock_wled.update.return_value mock_wled.update.return_value = WLEDDevice( - json.loads(load_fixture("wled/rgb_single_segment.json")) + json.loads(load_fixture("wled/rgb.json")) ) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - assert hass.states.get("light.wled_rgb_light_segment_0") - assert not hass.states.get("light.wled_rgb_light_segment_1") - assert not hass.states.get("light.wled_rgb_light_master") + master = hass.states.get("light.wled_rgb_light_master") + segment0 = hass.states.get("light.wled_rgb_light") + segment1 = hass.states.get("light.wled_rgb_light_segment_1") + assert master + assert master.state == STATE_ON + assert segment0 + assert segment0.state == STATE_ON + assert segment1 + assert segment1.state == STATE_ON # Test adding if segment shows up again, including the master entity mock_wled.update.return_value = return_value async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() - assert hass.states.get("light.wled_rgb_light_master") - assert hass.states.get("light.wled_rgb_light_segment_0") - assert hass.states.get("light.wled_rgb_light_segment_1") + master = hass.states.get("light.wled_rgb_light_master") + segment0 = hass.states.get("light.wled_rgb_light") + segment1 = hass.states.get("light.wled_rgb_light_segment_1") + assert master + assert master.state == STATE_UNAVAILABLE + assert segment0 + assert segment0.state == STATE_ON + assert segment1 + assert segment1.state == STATE_UNAVAILABLE @pytest.mark.parametrize("mock_wled", ["wled/rgb_single_segment.json"], indirect=True) @@ -320,12 +337,12 @@ async def test_light_error( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0"}, + {ATTR_ENTITY_ID: "light.wled_rgb_light"}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light_segment_0") + state = hass.states.get("light.wled_rgb_light") assert state assert state.state == STATE_ON assert "Invalid response from API" in caplog.text @@ -345,12 +362,12 @@ async def test_light_connection_error( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0"}, + {ATTR_ENTITY_ID: "light.wled_rgb_light"}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light_segment_0") + state = hass.states.get("light.wled_rgb_light") assert state assert state.state == STATE_UNAVAILABLE assert "Error communicating with API" in caplog.text @@ -395,7 +412,7 @@ async def test_effect_service( SERVICE_EFFECT, { ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", + ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_INTENSITY: 200, ATTR_PALETTE: "Tiamat", ATTR_REVERSE: True, @@ -417,7 +434,7 @@ async def test_effect_service( await hass.services.async_call( DOMAIN, SERVICE_EFFECT, - {ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", ATTR_EFFECT: 9}, + {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_EFFECT: 9}, blocking=True, ) await hass.async_block_till_done() @@ -435,7 +452,7 @@ async def test_effect_service( DOMAIN, SERVICE_EFFECT, { - ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", + ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_INTENSITY: 200, ATTR_REVERSE: True, ATTR_SPEED: 100, @@ -458,7 +475,7 @@ async def test_effect_service( SERVICE_EFFECT, { ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", + ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_PALETTE: "Tiamat", ATTR_REVERSE: True, ATTR_SPEED: 100, @@ -481,7 +498,7 @@ async def test_effect_service( SERVICE_EFFECT, { ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", + ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_INTENSITY: 200, ATTR_SPEED: 100, }, @@ -503,7 +520,7 @@ async def test_effect_service( SERVICE_EFFECT, { ATTR_EFFECT: "Rainbow", - ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", + ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_INTENSITY: 200, ATTR_REVERSE: True, }, @@ -533,12 +550,12 @@ async def test_effect_service_error( await hass.services.async_call( DOMAIN, SERVICE_EFFECT, - {ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", ATTR_EFFECT: 9}, + {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_EFFECT: 9}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light_segment_0") + state = hass.states.get("light.wled_rgb_light") assert state assert state.state == STATE_ON assert "Invalid response from API" in caplog.text @@ -556,7 +573,7 @@ async def test_preset_service( DOMAIN, SERVICE_PRESET, { - ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", + ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_PRESET: 1, }, blocking=True, @@ -591,12 +608,12 @@ async def test_preset_service_error( await hass.services.async_call( DOMAIN, SERVICE_PRESET, - {ATTR_ENTITY_ID: "light.wled_rgb_light_segment_0", ATTR_PRESET: 1}, + {ATTR_ENTITY_ID: "light.wled_rgb_light", ATTR_PRESET: 1}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("light.wled_rgb_light_segment_0") + state = hass.states.get("light.wled_rgb_light") assert state assert state.state == STATE_ON assert "Invalid response from API" in caplog.text