mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-27 20:34:20 +02:00
fix: make zero brightness adaptation terminal
This commit is contained in:
parent
9ecffe5125
commit
c373e86043
9 changed files with 333 additions and 8 deletions
|
|
@ -108,6 +108,26 @@ async def test_split_service_call_data(input_data, expected_data_list):
|
|||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 227}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 230, ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 1}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 2}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 1, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 0}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 1, ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 0, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON, {ATTR_BRIGHTNESS: 0}),
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
|
|
@ -165,6 +185,10 @@ async def test_split_service_call_data(input_data, expected_data_list):
|
|||
"keep attributes whose values differ from the state",
|
||||
"remove brightness within quantization tolerance (0-99 device scale)",
|
||||
"keep brightness outside quantization tolerance",
|
||||
"keep zero target across power boundary from brightness one",
|
||||
"keep zero target across power boundary from brightness two",
|
||||
"keep positive target across power boundary from zero",
|
||||
"remove zero target when brightness is already zero",
|
||||
"remove color temp within one mired (round-converting integration)",
|
||||
"remove color temp within one mired (floor-converting HA core helpers)",
|
||||
"remove color temp within one mired (6500 K)",
|
||||
|
|
@ -347,6 +371,24 @@ async def test_create_service_call_data_iterator(
|
|||
],
|
||||
0.7,
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_BRIGHTNESS: 0,
|
||||
ATTR_COLOR_TEMP_KELVIN: 4000,
|
||||
ATTR_TRANSITION: 10,
|
||||
},
|
||||
True,
|
||||
False,
|
||||
[
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_BRIGHTNESS: 0,
|
||||
ATTR_TRANSITION: 10,
|
||||
},
|
||||
],
|
||||
1.2,
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
|
|
@ -383,6 +425,7 @@ async def test_create_service_call_data_iterator(
|
|||
ids=[
|
||||
"service data passed through",
|
||||
"service data split",
|
||||
"zero brightness is one terminal command",
|
||||
"service data filtered",
|
||||
"service data split and filtered",
|
||||
],
|
||||
|
|
@ -416,6 +459,31 @@ async def test_prepare_adaptation_data(
|
|||
assert generated_service_datas == service_datas_expected
|
||||
|
||||
|
||||
async def test_prepare_zero_after_shared_brightness_was_applied(hass_states_mock):
|
||||
"""A shared zero-brightness call leaves no per-light color follow-up."""
|
||||
data = prepare_adaptation_data(
|
||||
hass_states_mock,
|
||||
"light.test",
|
||||
Context(id="test-id"),
|
||||
10,
|
||||
0.2,
|
||||
{
|
||||
ATTR_ENTITY_ID: "light.test",
|
||||
ATTR_BRIGHTNESS: 0,
|
||||
ATTR_COLOR_TEMP_KELVIN: 4000,
|
||||
ATTR_TRANSITION: 10,
|
||||
},
|
||||
split=True,
|
||||
filter_by_state=False,
|
||||
force=False,
|
||||
already_applied=LightControlAttributes.BRIGHTNESS,
|
||||
)
|
||||
|
||||
assert [item async for item in data.service_call_datas] == []
|
||||
assert data.max_length == 0
|
||||
assert data.attributes is LightControlAttributes.NONE
|
||||
|
||||
|
||||
@pytest.fixture(name="hass_states_mock")
|
||||
def fixture_hass_states_mock():
|
||||
"""Mocks a HA state machine which returns a mock state."""
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
CONF_RESET_MANUAL_CONTROL_ON_SLEEP_MODE_CHANGE,
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS,
|
||||
CONF_SKIP_REDUNDANT_COMMANDS,
|
||||
CONF_SLEEP_BRIGHTNESS,
|
||||
CONF_SLEEP_RGB_OR_COLOR_TEMP,
|
||||
CONF_SLEEP_TRANSITION,
|
||||
CONF_SUNRISE_OFFSET,
|
||||
|
|
@ -4969,3 +4970,246 @@ 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
|
||||
|
||||
|
||||
async def _finish_zero_sleep_adaptations(hass, switch):
|
||||
"""Wait until zero-sleep adaptation calls have settled."""
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
if switch.manager.adaptation_tasks:
|
||||
await asyncio.gather(*tuple(switch.manager.adaptation_tasks))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
|
||||
def _light_turn_on_payloads(events, entity_id):
|
||||
"""Return turn-on payloads that target one light."""
|
||||
payloads = []
|
||||
for event in events:
|
||||
if (
|
||||
event.data["domain"] != LIGHT_DOMAIN
|
||||
or event.data["service"] != SERVICE_TURN_ON
|
||||
):
|
||||
continue
|
||||
service_data = event.data["service_data"]
|
||||
target = service_data[ATTR_ENTITY_ID]
|
||||
if target == entity_id or (not isinstance(target, str) and entity_id in target):
|
||||
payloads.append(service_data)
|
||||
return payloads
|
||||
|
||||
|
||||
@pytest.mark.parametrize("intercept", [False, True])
|
||||
@pytest.mark.parametrize("split", [False, True])
|
||||
async def test_zero_sleep_bare_turn_on_is_terminal(hass, intercept, split):
|
||||
"""A bare turn-on ends with one color-free zero-brightness command."""
|
||||
switch, (light, *_) = await setup_lights_and_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_INTERCEPT: intercept,
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS: split,
|
||||
CONF_SLEEP_BRIGHTNESS: 0,
|
||||
},
|
||||
)
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: light.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
|
||||
events = []
|
||||
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: light.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
remove_listener()
|
||||
|
||||
payloads = _light_turn_on_payloads(events, light.entity_id)
|
||||
zero_payloads = [data for data in payloads if data.get(ATTR_BRIGHTNESS) == 0]
|
||||
if intercept:
|
||||
assert payloads == [{ATTR_ENTITY_ID: light.entity_id}]
|
||||
else:
|
||||
assert payloads[0] == {ATTR_ENTITY_ID: light.entity_id}
|
||||
assert len(payloads) == 2
|
||||
assert zero_payloads == [
|
||||
{ATTR_ENTITY_ID: light.entity_id, ATTR_BRIGHTNESS: 0},
|
||||
]
|
||||
assert all(ATTR_COLOR_TEMP_KELVIN not in data for data in payloads)
|
||||
assert all(ATTR_RGB_COLOR not in data for data in payloads)
|
||||
assert hass.states.get(light.entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split", [False, True])
|
||||
async def test_zero_sleep_enter_and_exit_does_not_restore_light(hass, split):
|
||||
"""Entering zero sleep turns a light off; exiting leaves it off."""
|
||||
switch, (light, *_) = await setup_lights_and_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS: split,
|
||||
CONF_SLEEP_BRIGHTNESS: 0,
|
||||
},
|
||||
)
|
||||
assert hass.states.get(light.entity_id).state == STATE_ON
|
||||
|
||||
events = []
|
||||
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
enter_payloads = _light_turn_on_payloads(events, light.entity_id)
|
||||
assert len(enter_payloads) == 1
|
||||
assert enter_payloads[0][ATTR_BRIGHTNESS] == 0
|
||||
assert hass.states.get(light.entity_id).state == STATE_OFF
|
||||
|
||||
events.clear()
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
remove_listener()
|
||||
assert _light_turn_on_payloads(events, light.entity_id) == []
|
||||
assert hass.states.get(light.entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize("brightness", [1, 2])
|
||||
@pytest.mark.parametrize("split", [False, True])
|
||||
async def test_zero_sleep_is_not_redundant_with_dimmed_light(
|
||||
hass,
|
||||
brightness,
|
||||
split,
|
||||
):
|
||||
"""Brightness quantization tolerance does not suppress a required off."""
|
||||
switch, (light, *_) = await setup_lights_and_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS: split,
|
||||
CONF_SKIP_REDUNDANT_COMMANDS: True,
|
||||
CONF_SLEEP_BRIGHTNESS: 0,
|
||||
},
|
||||
)
|
||||
set_light_brightness(light, brightness)
|
||||
light.async_write_ha_state()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
events = []
|
||||
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
remove_listener()
|
||||
|
||||
payloads = _light_turn_on_payloads(events, light.entity_id)
|
||||
assert [data[ATTR_BRIGHTNESS] for data in payloads] == [0]
|
||||
assert hass.states.get(light.entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize("split", [False, True])
|
||||
async def test_zero_sleep_command_keeps_full_transition(hass, split, cleanup):
|
||||
"""The terminal zero command keeps the configured sleep transition."""
|
||||
switch, lights = await setup_lights_and_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS: split,
|
||||
CONF_SLEEP_BRIGHTNESS: 0,
|
||||
CONF_SLEEP_TRANSITION: 1,
|
||||
},
|
||||
all_lights=True,
|
||||
)
|
||||
light = lights[2]
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: light.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
|
||||
events = []
|
||||
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
remove_listener()
|
||||
|
||||
payloads = _light_turn_on_payloads(events, light.entity_id)
|
||||
assert payloads == [
|
||||
{
|
||||
ATTR_ENTITY_ID: light.entity_id,
|
||||
ATTR_BRIGHTNESS: 0,
|
||||
ATTR_TRANSITION: 1,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_zero_sleep_multi_light_intercept_has_no_follow_up(hass):
|
||||
"""A shared intercepted zero command needs no per-light color call."""
|
||||
lights = await setup_lights(hass)
|
||||
managed = [lights[0].entity_id, lights[1].entity_id]
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: managed},
|
||||
blocking=True,
|
||||
)
|
||||
_, switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_LIGHTS: managed,
|
||||
CONF_INTERCEPT: True,
|
||||
CONF_MULTI_LIGHT_INTERCEPT: True,
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS: True,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_SLEEP_BRIGHTNESS: 0,
|
||||
},
|
||||
)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: switch.sleep_mode_switch.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
|
||||
events = []
|
||||
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: managed},
|
||||
blocking=True,
|
||||
)
|
||||
await _finish_zero_sleep_adaptations(hass, switch)
|
||||
remove_listener()
|
||||
|
||||
payloads = [
|
||||
event.data["service_data"]
|
||||
for event in events
|
||||
if event.data["domain"] == LIGHT_DOMAIN
|
||||
and event.data["service"] == SERVICE_TURN_ON
|
||||
]
|
||||
assert payloads == [{ATTR_ENTITY_ID: managed}]
|
||||
assert all(hass.states.get(entity_id).state == STATE_OFF for entity_id in managed)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue