🐛 get_commands: leave out what lights already have with skip_redundant_commands

As when Adaptive Lighting adapts a light with `skip_redundant_commands`,
leave out the attributes the light's current state already has, and
calls with nothing left. The filter is shared with Adaptive Lighting's
own calls (`remove_redundant_service_data`), whose behaviour doesn't
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mathias Berglund 2026-09-27 13:09:36 +03:00
commit 0302f509bb
5 changed files with 131 additions and 15 deletions

View file

@ -256,7 +256,7 @@ Provide a switch in `entity_id`, a list of `lights`, or both.
<!-- OUTPUT:END -->
<!-- SECTION:get-commands:START -->
Call it with a `response_variable`. For each light, the response lists the calls in order, each with its `service_data` and the `delay` in seconds before it (only nonzero between split calls, see `separate_turn_on_commands`). Only the lights and light groups in a switch's `lights` can be passed; without `entity_id`, each uses the switch it's in (of several, the one that's on). A light group is replaced by its members if the switch expands light groups. Options you leave out follow the switch, with its `initial_transition`, and in sleep mode the calls are for the sleep settings, whatever the time. Manual control and whether the lights are on don't change the calls. In YAML files, quote the time (`"22:00:00"`), since `22:00:00` is read as a number.
Call it with a `response_variable`. For each light, the response lists the calls in order, each with its `service_data` and the `delay` in seconds before it (only nonzero between split calls, see `separate_turn_on_commands`). Only the lights and light groups in a switch's `lights` can be passed; without `entity_id`, each uses the switch it's in (of several, the one that's on). A light group is replaced by its members if the switch expands light groups. Options you leave out follow the switch, with its `initial_transition`, and in sleep mode the calls are for the sleep settings, whatever the time. With `skip_redundant_commands`, what a light already has is left out, as when Adaptive Lighting adapts it. Manual control doesn't change the calls. In YAML files, quote the time (`"22:00:00"`), since `22:00:00` is read as a number.
Making the calls is a manual change for Adaptive Lighting: with `take_over_control`, it stops adapting those lights until manual control is reset. A light that's off is adapted to the current settings as it turns on (unless `take_over_control` and `adapt_only_on_bare_turn_on` are enabled), so this example only changes lights that are on:

View file

@ -176,6 +176,19 @@ def _has_relevant_service_data_attributes(service_data: ServiceData) -> bool:
return any(attr not in common_attrs for attr in service_data)
def remove_redundant_service_data(
service_data: ServiceData,
state: State | None,
) -> ServiceData | None:
"""Remove what the light's `state` already has from the data of a service call.
Returns `None` if nothing is left that justifies the service call.
"""
if state is not None:
service_data = _remove_redundant_attributes(service_data, state=state)
return service_data if _has_relevant_service_data_attributes(service_data) else None
async def _create_service_call_data_iterator(
hass: HomeAssistant,
service_datas: list[ServiceData],
@ -192,18 +205,12 @@ async def _create_service_call_data_iterator(
"""
for service_data in service_datas:
if filter_by_state and (entity_id := service_data.get(ATTR_ENTITY_ID)):
current_entity_state = hass.states.get(entity_id)
# Filter data to remove attributes that equal the current state
if current_entity_state is not None:
service_data = _remove_redundant_attributes( # noqa: PLW2901
service_data,
state=current_entity_state,
)
# Emit service data if it still contains relevant attributes (else try next)
if _has_relevant_service_data_attributes(service_data):
yield service_data
if filtered := remove_redundant_service_data(
service_data,
hass.states.get(entity_id),
):
yield filtered
else:
yield service_data

View file

@ -87,6 +87,7 @@ from .adaptation_utils import (
manual_control_event_attribute_to_flags,
plan_service_calls,
prepare_adaptation_data,
remove_redundant_service_data,
)
from .color_and_brightness import SunLightSettings
from .const import (
@ -1501,10 +1502,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
For each light (all of the switch's if `lights` is `None`), the calls in
order, each with its service data and the time to wait before it, adapting
now or at the `time` of day today (lights without a state are left out).
now or at the `time` of day today (lights without a state are left out). With
`skip_redundant_commands`, what a light already has is left out.
Omitted options default to the switch's settings (unlike for `apply`), and
`transition` to its `initial_transition` (like for `apply`). Manual control
and the light's current state don't change the calls, and nothing is sent.
doesn't change the calls, and nothing is sent.
"""
if transition is None:
transition = self.initial_transition
@ -1550,6 +1552,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
self._send_split_delay / 1000.0,
split=self._separate_turn_on_commands,
)
if self._skip_redundant_commands:
# Leave out what the light already has, as when adapting it
state = self.hass.states.get(light)
service_datas = [
filtered
for data in service_datas
if (filtered := remove_redundant_service_data(data, state))
]
commands[light] = [
{
"service_data": {

View file

@ -87,7 +87,7 @@ Provide a switch in `entity_id`, a list of `lights`, or both.
<!-- CODE:END -->
<!-- OUTPUT:START -->
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
Call it with a `response_variable`. For each light, the response lists the calls in order, each with its `service_data` and the `delay` in seconds before it (only nonzero between split calls, see `separate_turn_on_commands`). Only the lights and light groups in a switch's `lights` can be passed; without `entity_id`, each uses the switch it's in (of several, the one that's on). A light group is replaced by its members if the switch expands light groups. Options you leave out follow the switch, with its `initial_transition`, and in sleep mode the calls are for the sleep settings, whatever the time. Manual control and whether the lights are on don't change the calls. In YAML files, quote the time (`"22:00:00"`), since `22:00:00` is read as a number.
Call it with a `response_variable`. For each light, the response lists the calls in order, each with its `service_data` and the `delay` in seconds before it (only nonzero between split calls, see `separate_turn_on_commands`). Only the lights and light groups in a switch's `lights` can be passed; without `entity_id`, each uses the switch it's in (of several, the one that's on). A light group is replaced by its members if the switch expands light groups. Options you leave out follow the switch, with its `initial_transition`, and in sleep mode the calls are for the sleep settings, whatever the time. With `skip_redundant_commands`, what a light already has is left out, as when Adaptive Lighting adapts it. Manual control doesn't change the calls. In YAML files, quote the time (`"22:00:00"`), since `22:00:00` is read as a number.
Making the calls is a manual change for Adaptive Lighting: with `take_over_control`, it stops adapting those lights until manual control is reset. A light that's off is adapted to the current settings as it turns on (unless `take_over_control` and `adapt_only_on_bare_turn_on` are enabled), so this example only changes lights that are on:

View file

@ -24,6 +24,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_PREFER_RGB_COLOR,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
CONF_SKIP_REDUNDANT_COMMANDS,
CONF_SLEEP_RGB_OR_COLOR_TEMP,
CONF_TRANSITION,
DEFAULT_SLEEP_BRIGHTNESS,
@ -738,3 +739,101 @@ async def test_get_commands_response_is_json(hass):
switch, _ = await setup_lights_and_switch(hass, {CONF_PREFER_RGB_COLOR: True})
commands = await _get_commands(hass, switch)
assert json.loads(json.dumps(commands)) == commands
@pytest.mark.parametrize("skip_redundant", [True, False])
async def test_get_commands_skip_redundant(hass, skip_redundant):
"""With `skip_redundant_commands`, what a light already has is left out."""
switch, _ = await setup_lights_and_switch(
hass,
{CONF_SKIP_REDUNDANT_COMMANDS: skip_redundant},
)
with patch(UTCNOW, return_value=_noon()):
full = await _get_commands(hass, switch, lights=[ENTITY_LIGHT_1])
(call,) = full[ENTITY_LIGHT_1]
target = call["service_data"]
# The light has the brightness already, but another color temperature
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
ATTR_BRIGHTNESS: target[ATTR_BRIGHTNESS],
ATTR_COLOR_TEMP_KELVIN: target[ATTR_COLOR_TEMP_KELVIN] - 1000,
},
blocking=True,
)
partial = await _get_commands(hass, switch, lights=[ENTITY_LIGHT_1])
# And then everything
await hass.services.async_call("light", SERVICE_TURN_ON, target, blocking=True)
nothing = await _get_commands(hass, switch, lights=[ENTITY_LIGHT_1])
if not skip_redundant:
assert partial == nothing == full
return
(call,) = partial[ENTITY_LIGHT_1]
assert set(call["service_data"]) == {ATTR_ENTITY_ID, ATTR_COLOR_TEMP_KELVIN}
assert nothing == {ENTITY_LIGHT_1: []}
async def test_get_commands_skip_redundant_split(hass):
"""A split call with nothing left is dropped, and the rest isn't delayed."""
switch, _ = await setup_lights_and_switch(
hass,
{
CONF_SKIP_REDUNDANT_COMMANDS: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 100,
},
)
with patch(UTCNOW, return_value=_noon()):
full = await _get_commands(hass, switch, lights=[ENTITY_LIGHT_1])
brightness_call, color_call = full[ENTITY_LIGHT_1]
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{
**brightness_call["service_data"],
ATTR_COLOR_TEMP_KELVIN: (
color_call["service_data"][ATTR_COLOR_TEMP_KELVIN] - 1000
),
},
blocking=True,
)
commands = await _get_commands(hass, switch, lights=[ENTITY_LIGHT_1])
assert commands == {
ENTITY_LIGHT_1: [{"service_data": color_call["service_data"], "delay": 0}],
}
async def test_get_commands_skip_redundant_match_what_apply_sends(hass):
"""With `skip_redundant_commands`, the calls are still what `apply` sends."""
switch, _ = await setup_lights_and_switch(
hass,
{CONF_SKIP_REDUNDANT_COMMANDS: True},
)
with patch(UTCNOW, return_value=_noon()):
(call,) = (await _get_commands(hass, switch, lights=[ENTITY_LIGHT_1]))[
ENTITY_LIGHT_1
]
await hass.services.async_call(
"light",
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
ATTR_BRIGHTNESS: call["service_data"][ATTR_BRIGHTNESS],
ATTR_COLOR_TEMP_KELVIN: (
call["service_data"][ATTR_COLOR_TEMP_KELVIN] - 1000
),
},
blocking=True,
)
commands = await _get_commands(hass, switch, lights=[ENTITY_LIGHT_1])
calls = _track_adaptive_light_calls(hass)
await hass.services.async_call(
DOMAIN,
SERVICE_APPLY,
{ATTR_ENTITY_ID: switch.entity_id, CONF_LIGHTS: [ENTITY_LIGHT_1]},
blocking=True,
)
await hass.async_block_till_done()
assert [c["service_data"] for c in commands[ENTITY_LIGHT_1]] == calls