mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-12 06:44:04 +02:00
Protect per-light dim ceilings during interception
This commit is contained in:
parent
fa83b35305
commit
eba899c02c
5 changed files with 227 additions and 4 deletions
|
|
@ -58,7 +58,7 @@ These lists report manual-control flags. Actual adaptation also depends on `take
|
|||
|
||||
The `skip_brightness_increases` option provides a dim-only policy for automatic adaptation. Adaptive Lighting can lower brightness and continue adapting color, but it will not raise brightness above the light's reported numeric value. To keep adapting after direct or physical brightness changes, use `take_over_control: false` and `detect_non_ha_changes: false`; otherwise the existing `pause_all` or `pause_changed` manual-control policy can pause adaptation before this brightness ceiling is applied.
|
||||
|
||||
This option does not store a brightness to restore later. A retained brightness reported while a light is off remains the ceiling on a bare turn-on, and leaving sleep mode does not necessarily restore the normal brighter target. Send a direct brightness request or disable `skip_brightness_increases` when you want to brighten the light. If the light reports no numeric brightness, Adaptive Lighting uses its calculated target.
|
||||
This option does not store a brightness to restore later. A retained brightness reported while a light is off remains the ceiling on a bare turn-on, and leaving sleep mode does not necessarily restore the normal brighter target. To brighten, send a direct brightness request while the light is already on or disable `skip_brightness_increases`. An explicit brightness that turns a light on follows the existing interception rules: with the default `adapt_only_on_bare_turn_on: false`, Adaptive Lighting may replace it with the automatic target; enabling `adapt_only_on_bare_turn_on` with takeover control preserves it and marks the light manually controlled. If the light reports no numeric brightness, Adaptive Lighting uses its calculated target.
|
||||
|
||||
The attributes are absent when the Adaptive Lighting switch is off. Use a fallback when checking them in templates:
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ from .adaptation_utils import (
|
|||
AdaptationData,
|
||||
LightControlAttributes,
|
||||
ServiceData,
|
||||
_remove_brightness_increases,
|
||||
get_light_control_attributes,
|
||||
has_effect_attribute,
|
||||
manual_control_event_attribute_to_flags,
|
||||
|
|
@ -2222,6 +2223,27 @@ class AdaptiveLightingManager:
|
|||
|
||||
# Update/adapt service call data
|
||||
first_service_data.pop(ATTR_ENTITY_ID, None)
|
||||
brightness_blocked_entities: set[str] = set()
|
||||
if (
|
||||
switch._skip_brightness_increases
|
||||
and len(entity_ids) > 1
|
||||
and ATTR_BRIGHTNESS in first_service_data
|
||||
):
|
||||
brightness_blocked_entities = {
|
||||
entity_id
|
||||
for entity_id in entity_ids
|
||||
if (state := self.hass.states.get(entity_id)) is not None
|
||||
and ATTR_BRIGHTNESS
|
||||
not in _remove_brightness_increases(first_service_data, state)
|
||||
}
|
||||
if brightness_blocked_entities:
|
||||
# A shared brightness must be valid for every target. Re-prepare all
|
||||
# members individually when one reported ceiling rejects it, while
|
||||
# retaining color in the intercepted multi-light call.
|
||||
first_service_data.pop(ATTR_BRIGHTNESS)
|
||||
if next_service_data := await adaptation_data.next_service_call_data():
|
||||
next_service_data.pop(ATTR_ENTITY_ID, None)
|
||||
first_service_data.update(next_service_data)
|
||||
# This is called as a preprocessing step by the schema validation of the original
|
||||
# service call and needs to be repeated here to also process the added adaptation data.
|
||||
# (A more generic alternative would be re-executing the validation, but that is more
|
||||
|
|
@ -2238,7 +2260,7 @@ class AdaptiveLightingManager:
|
|||
already_applied = get_light_control_attributes(first_service_data)
|
||||
for index, entity_id in enumerate(entity_ids):
|
||||
self.set_proactively_adapting(call.context.id, entity_id)
|
||||
if index:
|
||||
if index or brightness_blocked_entities:
|
||||
# Each member needs its own remaining commands and cancellation.
|
||||
# Consuming its first iterator item could discard a color command
|
||||
# when only the shared brightness command has been applied.
|
||||
|
|
@ -2246,6 +2268,9 @@ class AdaptiveLightingManager:
|
|||
entity_id,
|
||||
transition,
|
||||
context=switch.create_context("adapt_lights", parent=call.context),
|
||||
adapt_brightness=(
|
||||
False if entity_id in brightness_blocked_entities else None
|
||||
),
|
||||
already_applied=already_applied,
|
||||
)
|
||||
if adaptation_data is None or not adaptation_data.max_length:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ These lists report manual-control flags. Actual adaptation also depends on `take
|
|||
|
||||
The `skip_brightness_increases` option provides a dim-only policy for automatic adaptation. Adaptive Lighting can lower brightness and continue adapting color, but it will not raise brightness above the light's reported numeric value. To keep adapting after direct or physical brightness changes, use `take_over_control: false` and `detect_non_ha_changes: false`; otherwise the existing `pause_all` or `pause_changed` manual-control policy can pause adaptation before this brightness ceiling is applied.
|
||||
|
||||
This option does not store a brightness to restore later. A retained brightness reported while a light is off remains the ceiling on a bare turn-on, and leaving sleep mode does not necessarily restore the normal brighter target. Send a direct brightness request or disable `skip_brightness_increases` when you want to brighten the light. If the light reports no numeric brightness, Adaptive Lighting uses its calculated target.
|
||||
This option does not store a brightness to restore later. A retained brightness reported while a light is off remains the ceiling on a bare turn-on, and leaving sleep mode does not necessarily restore the normal brighter target. To brighten, send a direct brightness request while the light is already on or disable `skip_brightness_increases`. An explicit brightness that turns a light on follows the existing interception rules: with the default `adapt_only_on_bare_turn_on: false`, Adaptive Lighting may replace it with the automatic target; enabling `adapt_only_on_bare_turn_on` with takeover control preserves it and marks the light manually controlled. If the light reports no numeric brightness, Adaptive Lighting uses its calculated target.
|
||||
|
||||
The attributes are absent when the Adaptive Lighting switch is off. Use a fallback when checking them in templates:
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ Sleep mode is configured through the main Adaptive Lighting configuration. See t
|
|||
| `sleep_transition` | 1 | Transition duration in seconds |
|
||||
| `transition_until_sleep` | false | Gradually transition to sleep settings after sunset |
|
||||
|
||||
With `skip_brightness_increases` enabled, leaving sleep mode does not necessarily restore the normal brighter target. The light's reported sleep brightness remains the automatic ceiling because Adaptive Lighting does not store restoration state. Send a direct brightness request or disable `skip_brightness_increases` when you want to brighten the light.
|
||||
With `skip_brightness_increases` enabled, leaving sleep mode does not necessarily restore the normal brighter target. The light's reported sleep brightness remains the automatic ceiling because Adaptive Lighting does not store restoration state. To brighten, send a direct brightness request while the light is already on or disable `skip_brightness_increases`. An explicit brightness that also turns the light on still follows `adapt_only_on_bare_turn_on` and takeover-control rules.
|
||||
|
||||
## Automation Examples
|
||||
|
||||
|
|
|
|||
|
|
@ -837,6 +837,204 @@ async def test_dim_only_allows_explicit_manual_brightness(hass):
|
|||
assert hass.states.get(light.entity_id).attributes[ATTR_BRIGHTNESS] == 220
|
||||
|
||||
|
||||
async def _setup_dim_only_group_intercept(hass, split, skip_redundant):
|
||||
"""Set up an expanded light group with dim-only interception."""
|
||||
lights = await setup_lights(hass, with_group=True)
|
||||
_, switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_LIGHTS: ["light.light_group"],
|
||||
CONF_INTERCEPT: True,
|
||||
CONF_MULTI_LIGHT_INTERCEPT: True,
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS: split,
|
||||
CONF_SKIP_REDUNDANT_COMMANDS: skip_redundant,
|
||||
CONF_SKIP_BRIGHTNESS_INCREASES: True,
|
||||
CONF_TAKE_OVER_CONTROL: False,
|
||||
CONF_DETECT_NON_HA_CHANGES: False,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
_mock_sun_light_settings(
|
||||
switch,
|
||||
{
|
||||
ATTR_BRIGHTNESS_PCT: 50,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3000,
|
||||
"force_rgb_color": False,
|
||||
},
|
||||
)
|
||||
return switch, lights[-2:]
|
||||
|
||||
|
||||
async def _set_retained_off_brightnesses(hass, lights, brightnesses):
|
||||
"""Set retained brightness in both the entity and HA state."""
|
||||
for light, brightness in zip(lights, brightnesses, strict=True):
|
||||
set_light_brightness(light, brightness)
|
||||
light.async_write_ha_state()
|
||||
attributes = dict(hass.states.get(light.entity_id).attributes)
|
||||
if brightness is None:
|
||||
attributes.pop(ATTR_BRIGHTNESS, None)
|
||||
else:
|
||||
attributes[ATTR_BRIGHTNESS] = brightness
|
||||
hass.states.async_set(light.entity_id, STATE_OFF, attributes)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split", [False, True])
|
||||
@pytest.mark.parametrize("skip_redundant", [False, True])
|
||||
@pytest.mark.parametrize("brightnesses", [(200, 77), (77, 200)])
|
||||
async def test_dim_only_multi_light_intercept_uses_each_member_ceiling(
|
||||
hass,
|
||||
split,
|
||||
skip_redundant,
|
||||
brightnesses,
|
||||
cleanup,
|
||||
):
|
||||
"""A shared turn-on must not use one member's ceiling for another."""
|
||||
switch, lights = await _setup_dim_only_group_intercept(
|
||||
hass,
|
||||
split,
|
||||
skip_redundant,
|
||||
)
|
||||
await _set_retained_off_brightnesses(hass, lights, brightnesses)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.light_group"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await asyncio.gather(*switch.manager.adaptation_tasks)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
states = [hass.states.get(light.entity_id) for light in lights]
|
||||
assert [state.attributes[ATTR_BRIGHTNESS] for state in states] == [
|
||||
min(brightness, 128) for brightness in brightnesses
|
||||
]
|
||||
assert all(
|
||||
state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(3000, abs=5)
|
||||
for state in states
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split", [False, True])
|
||||
@pytest.mark.parametrize("brightnesses", [(None, 77), (77, None)])
|
||||
async def test_dim_only_multi_light_intercept_allows_missing_brightness(
|
||||
hass,
|
||||
split,
|
||||
brightnesses,
|
||||
cleanup,
|
||||
):
|
||||
"""A missing brightness allows its target without raising another member."""
|
||||
switch, lights = await _setup_dim_only_group_intercept(hass, split, False)
|
||||
await _set_retained_off_brightnesses(hass, lights, brightnesses)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.light_group"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await asyncio.gather(*switch.manager.adaptation_tasks)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
states = [hass.states.get(light.entity_id) for light in lights]
|
||||
assert [state.attributes[ATTR_BRIGHTNESS] for state in states] == [
|
||||
128 if brightness is None else brightness for brightness in brightnesses
|
||||
]
|
||||
assert all(
|
||||
state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(3000, abs=5)
|
||||
for state in states
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split", [False, True])
|
||||
async def test_dim_only_multi_light_intercept_shares_safe_brightness(
|
||||
hass,
|
||||
split,
|
||||
cleanup,
|
||||
):
|
||||
"""An automatic target valid for every member remains on the shared call."""
|
||||
members = ["light.light_4", "light.light_5"]
|
||||
switch, lights = await _setup_dim_only_group_intercept(hass, split, False)
|
||||
await _set_retained_off_brightnesses(hass, lights, (128, 200))
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
lights[0],
|
||||
"async_turn_on",
|
||||
wraps=lights[0].async_turn_on,
|
||||
) as first_turn_on,
|
||||
patch.object(
|
||||
lights[1],
|
||||
"async_turn_on",
|
||||
wraps=lights[1].async_turn_on,
|
||||
) as second_turn_on,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.light_group"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await asyncio.gather(*switch.manager.adaptation_tasks)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert first_turn_on.call_args_list[0].kwargs[ATTR_BRIGHTNESS] == 128
|
||||
assert second_turn_on.call_args_list[0].kwargs[ATTR_BRIGHTNESS] == 128
|
||||
assert [
|
||||
hass.states.get(member).attributes[ATTR_BRIGHTNESS] for member in members
|
||||
] == [128, 128]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("adapt_only_on_bare_turn_on", "expected_brightness"),
|
||||
[(False, 51), (True, 220)],
|
||||
)
|
||||
async def test_dim_only_explicit_initial_brightness_follows_interception_policy(
|
||||
hass,
|
||||
adapt_only_on_bare_turn_on,
|
||||
expected_brightness,
|
||||
cleanup,
|
||||
):
|
||||
"""An explicit initial value follows the existing bare-turn-on policy."""
|
||||
switch, (*_, light) = await setup_lights_and_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_LIGHTS: [ENTITY_LIGHT_3],
|
||||
CONF_SKIP_BRIGHTNESS_INCREASES: True,
|
||||
CONF_INTERCEPT: True,
|
||||
CONF_ADAPT_ONLY_ON_BARE_TURN_ON: adapt_only_on_bare_turn_on,
|
||||
CONF_TAKE_OVER_CONTROL: True,
|
||||
CONF_DETECT_NON_HA_CHANGES: False,
|
||||
CONF_MIN_BRIGHTNESS: 20,
|
||||
CONF_MAX_BRIGHTNESS: 20,
|
||||
},
|
||||
all_lights=True,
|
||||
)
|
||||
await _set_retained_off_brightnesses(hass, [light], [100])
|
||||
|
||||
with patch.object(
|
||||
light,
|
||||
"async_turn_on",
|
||||
wraps=light.async_turn_on,
|
||||
) as turn_on:
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: light.entity_id, ATTR_BRIGHTNESS: 220},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await asyncio.gather(*switch.manager.adaptation_tasks)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert turn_on.call_args_list[0].kwargs[ATTR_BRIGHTNESS] == expected_brightness
|
||||
|
||||
|
||||
async def test_manager_not_tracking_untracked_lights(hass):
|
||||
"""Test that lights that are not in a Adaptive Lighting switch aren't tracked."""
|
||||
switch, _ = await setup_lights_and_switch(hass)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue