Track manual changes across shared light profiles (#1569)

This commit is contained in:
Bas Nijholt 2026-09-06 19:09:26 +02:00 committed by GitHub
commit cda1c2db33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 234 additions and 16 deletions

View file

@ -2612,22 +2612,20 @@ class AdaptiveLightingManager:
# Fix for https://github.com/basnijholt/adaptive-lighting/issues/1378
state = self.hass.states.get(eid)
if state is not None and state.state == STATE_ON:
try:
switch = _switch_with_lights(
self.hass,
[eid],
expand_light_groups=False,
)
await self.update_manually_controlled_from_event(
switch,
eid,
force=False,
)
except NoSwitchFoundError:
_LOGGER.debug(
"No switch found for entity_id='%s' in 'on' event listener",
eid,
)
switches = _switches_with_lights(
self.hass,
[eid],
expand_light_groups=False,
)
for switch in switches:
# Preserve tracking for a lone profile, including when off.
# Shared lights notify each enabled owner using its takeover policy.
if switch.is_on or len(switches) == 1:
await self.update_manually_controlled_from_event(
switch,
eid,
force=False,
)
timer = self.auto_reset_manual_control_timers.get(eid)
if (

View file

@ -40,6 +40,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
CONF_MIN_COLOR_TEMP,
CONF_MULTI_LIGHT_INTERCEPT,
@ -1760,6 +1761,154 @@ async def test_manual_control_state_updates_shared_switches(hass):
assert attrs["manual_control_color"] == []
@pytest.mark.parametrize("intercept", [False, True])
@pytest.mark.parametrize(
(
"brightness_takeover",
"color_takeover",
"color_enabled",
"color_mode",
"expected_brightness",
"expected_color_calls",
"event_profiles",
),
[
(
True,
True,
True,
TakeOverControlMode.PAUSE_CHANGED,
77,
1,
["brightness", "color"],
),
(
True,
True,
True,
TakeOverControlMode.PAUSE_ALL,
77,
0,
["brightness", "color"],
),
(False, True, True, TakeOverControlMode.PAUSE_CHANGED, 77, 1, ["color"]),
(True, False, True, TakeOverControlMode.PAUSE_CHANGED, 77, 1, ["brightness"]),
(False, False, True, TakeOverControlMode.PAUSE_CHANGED, 128, 1, []),
(False, True, False, TakeOverControlMode.PAUSE_CHANGED, 128, 0, []),
(True, True, False, TakeOverControlMode.PAUSE_CHANGED, 77, 0, ["brightness"]),
],
)
async def test_shared_profiles_track_manual_brightness(
hass,
intercept,
brightness_takeover,
color_takeover,
color_enabled,
color_mode,
expected_brightness,
expected_color_calls,
event_profiles,
):
"""Shared owners track manual service calls and apply each profile's pause mode."""
await setup_lights(hass)
profiles = {}
for name, takeover, mode in (
("brightness", brightness_takeover, TakeOverControlMode.PAUSE_CHANGED),
("color", color_takeover, color_mode),
):
_, switch = await setup_switch(
hass,
{
CONF_NAME: name,
CONF_LIGHTS: [ENTITY_LIGHT_1],
CONF_INTERCEPT: intercept,
CONF_TAKE_OVER_CONTROL: takeover,
CONF_TAKE_OVER_CONTROL_MODE: mode,
CONF_DETECT_NON_HA_CHANGES: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
CONF_MIN_COLOR_TEMP: 4000,
CONF_MAX_COLOR_TEMP: 4000,
},
)
other_axis = (
switch.adapt_color_switch
if name == "brightness"
else switch.adapt_brightness_switch
)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: other_axis.entity_id},
blocking=True,
)
profiles[name] = switch
if not color_enabled:
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: profiles["color"].entity_id},
blocking=True,
)
await hass.async_block_till_done()
events = []
remove_events = hass.bus.async_listen(f"{DOMAIN}.manual_control", events.append)
context = Context()
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 77},
context=context,
blocking=True,
)
await hass.async_block_till_done()
calls = []
remove_calls = hass.bus.async_listen(EVENT_CALL_SERVICE, calls.append)
for switch in profiles.values():
if switch.is_on:
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
remove_calls()
remove_events()
light_calls = [
event.data["service_data"]
for event in calls
if event.data["domain"] == LIGHT_DOMAIN
and event.data["service"] == SERVICE_TURN_ON
]
assert (
sum(ATTR_COLOR_TEMP_KELVIN in call for call in light_calls)
== expected_color_calls
)
assert sum(ATTR_BRIGHTNESS in call for call in light_calls) == (
expected_brightness == 128
)
assert (
hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS]
== expected_brightness
)
assert [event.data[SWITCH_DOMAIN] for event in events] == [
profiles[name].entity_id for name in event_profiles
]
assert all(event.context == context for event in events)
assert all(
event.data[CONF_MANUAL_CONTROL] == LightControlAttributes.BRIGHTNESS
for event in events
)
for switch in profiles.values():
if not switch.is_on:
continue
attrs = hass.states.get(switch.entity_id).attributes
assert attrs["manual_control_brightness"] == (
[ENTITY_LIGHT_1] if event_profiles else []
)
assert attrs["manual_control_color"] == []
async def test_manual_control_state_ignores_incomplete_entries(hass):
"""An entry awaiting platform setup must not break another profile's updates."""
switch, (light, *_) = await setup_lights_and_switch(hass)
@ -4969,3 +5118,74 @@ async def test_forced_split_apply_stays_off(hass, off_action, cleanup):
assert ATTR_BRIGHTNESS in turn_on_events[0].data["service_data"]
assert ATTR_COLOR_TEMP_KELVIN not in turn_on_events[0].data["service_data"]
assert hass.states.get(ENTITY_LIGHT_3).state == STATE_OFF
@pytest.mark.parametrize("intercept", [False, True])
async def test_shared_profiles_keep_independent_sun_schedules(
hass,
intercept,
reset_time_zone,
):
"""A later color sunrise keeps running after manual brightness takeover."""
await hass.config.async_set_time_zone("UTC")
await setup_lights(hass)
profiles = []
for name, sunrise, sunset in (("brightness", 6, 18), ("color", 10, 22)):
_, switch = await setup_switch(
hass,
{
CONF_NAME: name,
CONF_LIGHTS: [ENTITY_LIGHT_1],
CONF_INTERCEPT: intercept,
CONF_TAKE_OVER_CONTROL_MODE: TakeOverControlMode.PAUSE_CHANGED,
CONF_DETECT_NON_HA_CHANGES: False,
CONF_INITIAL_TRANSITION: 0,
CONF_TRANSITION: 0,
CONF_SUNRISE_TIME: datetime.time(sunrise),
CONF_SUNSET_TIME: datetime.time(sunset),
CONF_MIN_BRIGHTNESS: 10,
CONF_MAX_BRIGHTNESS: 90,
CONF_MIN_COLOR_TEMP: 2000,
CONF_MAX_COLOR_TEMP: 6000,
},
)
other_axis = (
switch.adapt_color_switch
if name == "brightness"
else switch.adapt_brightness_switch
)
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: other_axis.entity_id},
blocking=True,
)
profiles.append(switch)
with patch(
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
return_value=datetime.datetime.fromisoformat("2026-09-05T08:00:00+00:00"),
):
for switch in profiles:
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
morning = hass.states.get(ENTITY_LIGHT_1)
assert morning.attributes[ATTR_BRIGHTNESS] > 26
assert morning.attributes[ATTR_COLOR_TEMP_KELVIN] == 2000
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 77},
blocking=True,
)
await hass.async_block_till_done()
with patch(
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
return_value=datetime.datetime.fromisoformat("2026-09-05T12:00:00+00:00"),
):
for switch in profiles:
await switch._async_update_at_interval_action()
await hass.async_block_till_done()
noon = hass.states.get(ENTITY_LIGHT_1)
assert noon.attributes[ATTR_BRIGHTNESS] == 77
assert noon.attributes[ATTR_COLOR_TEMP_KELVIN] > 2000