adaptive-lighting/tests/test_native_scheduling.py

641 lines
22 KiB
Python

"""Independent native requests retain the established adaptation timing policy."""
# pylint: disable=protected-access
import asyncio
import pytest
from homeassistant.components.adaptive_lighting.const import (
CONF_ADAPT_DELAY,
CONF_INTERCEPT,
CONF_SEND_SPLIT_DELAY,
CONF_SEPARATE_TURN_ON_COMMANDS,
)
from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN
from homeassistant.components.light.const import DATA_COMPONENT
from homeassistant.const import STATE_ON
from homeassistant.core import Context
from homeassistant.exceptions import HomeAssistantError
from .support.async_helpers import DelayBarrier
from .support.native_lights import GROUP, RIGHT, RecordingGroup, RecordingMember
from .support.native_scenarios import (
prepare_reported_members,
setup_reported_group,
)
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_split_transition_duration_is_preserved(hass, planner_enabled):
setup = await setup_reported_group(
hass,
**{
CONF_INTERCEPT: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 0,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
setup.switch.manager.native_groups.disabled = not planner_enabled
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": GROUP, "transition": 2.5},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
adaptation = [
call
for call in setup.commands
if "brightness" in call or "color_temp_kelvin" in call
]
assert adaptation, setup.commands
assert all(call.get("transition") == 1.25 for call in adaptation), setup.commands
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_split_delay_includes_first_axis_transition(
hass,
monkeypatch,
planner_enabled,
):
setup = await setup_reported_group(
hass,
**{
CONF_INTERCEPT: True,
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
planner = setup.switch.manager.native_groups
planner.disabled = not planner_enabled
barrier = DelayBarrier(
monkeypatch,
planner if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_execute_adaptation_calls",
)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": GROUP, "transition": 2.5},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert any("brightness" in call for call in setup.commands), setup.commands
assert not setup.colour_commands, setup.commands
actual_delay = barrier.delay
finally:
barrier.release.set()
await task
await hass.async_block_till_done()
assert actual_delay == 2.484, (actual_delay, setup.commands)
adaptation = [
call
for call in setup.commands
if "brightness" in call or "color_temp_kelvin" in call
]
assert any("color_temp_kelvin" in call for call in adaptation), setup.commands
assert all(call.get("transition") == 1.25 for call in adaptation), setup.commands
async def _setup_independent_groups(hass):
"""Install disjoint native targets under one adaptive profile."""
setup = await setup_reported_group(
hass,
**{
CONF_SEPARATE_TURN_ON_COMMANDS: True,
CONF_SEND_SPLIT_DELAY: 1234,
},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=True,
)
await setup.switch.async_turn_off()
second_members = [
RecordingMember(f"light.native_z_member_{index}", setup.commands)
for index in range(3)
]
second_group = RecordingGroup(second_members, setup.commands)
second_group.entity_id = "light.native_z_group"
second_group._attr_is_on = False
for member in second_members:
member._attr_color_temp_kelvin = 4000
await hass.data[DATA_COMPONENT].async_add_entities([*second_members, second_group])
setup.switch._configured_lights.append(second_group.entity_id)
setup.switch._expand_light_groups()
await setup.switch.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
setup.commands.clear()
return setup, second_group
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_split_group_does_not_delay_independent_group_brightness(
hass,
monkeypatch,
planner_enabled,
):
setup, second_group = await _setup_independent_groups(hass)
second_brightness = asyncio.Event()
original = second_group.async_turn_on
async def observed(**kwargs):
await original(**kwargs)
if "brightness" in kwargs:
second_brightness.set()
monkeypatch.setattr(second_group, "async_turn_on", observed)
planner = setup.switch.manager.native_groups
planner.disabled = not planner_enabled
barrier = DelayBarrier(
monkeypatch,
planner if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_execute_adaptation_calls",
)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second_group.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert barrier.delay == 1.234
try:
await asyncio.wait_for(second_brightness.wait(), 0.5)
other_group_started = True
except TimeoutError:
other_group_started = False
paused_commands = list(setup.commands)
finally:
barrier.release.set()
await task
await hass.async_block_till_done()
assert other_group_started, paused_commands
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_explicit_scene_colour_not_held_by_other_members_adapt_delay(
hass,
monkeypatch,
planner_enabled,
):
setup = await setup_reported_group(hass, **{CONF_ADAPT_DELAY: 1})
planner = setup.switch.manager.native_groups
planner.disabled = not planner_enabled
barrier = DelayBarrier(
monkeypatch,
planner if planner_enabled else setup.switch,
"_execute_dispatch" if planner_enabled else "_respond_to_off_to_on_event",
)
entities = {
m.entity_id: {"state": STATE_ON, ATTR_BRIGHTNESS: 100} for m in setup.members
}
member = setup.members[0]
entities[member.entity_id][ATTR_COLOR_TEMP_KELVIN] = 3500
explicit_colour = asyncio.Event()
original_turn_on = member.async_turn_on
async def observe_colour(**kwargs):
await original_turn_on(**kwargs)
if kwargs.get(ATTR_COLOR_TEMP_KELVIN) == 3500:
explicit_colour.set()
monkeypatch.setattr(member, "async_turn_on", observe_colour)
task = asyncio.create_task(
hass.services.async_call(
"scene",
"apply",
{"entities": entities},
blocking=True,
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert barrier.delay == 1
try:
await asyncio.wait_for(explicit_colour.wait(), 0.5)
explicit_completed = True
except TimeoutError:
explicit_completed = False
paused_commands = list(setup.commands)
finally:
barrier.release.set()
await task
await hass.async_block_till_done()
assert explicit_completed, paused_commands
assert member.physical_kelvin == 3500, setup.commands
@pytest.mark.parametrize(
"failed_axis",
["power", ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN],
)
async def test_independent_native_group_error_keeps_other_group(
hass,
monkeypatch,
failed_axis,
):
"""One failed sequence neither cancels a disjoint group nor retries its axis."""
setup, second_group = await _setup_independent_groups(hass)
error = HomeAssistantError("first native group unavailable")
original = setup.group.async_turn_on
attempts = []
async def fail_axis(**kwargs):
attempts.append(dict(kwargs))
if (failed_axis == "power" and not kwargs) or failed_axis in kwargs:
raise error
await original(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", fail_axis)
with pytest.raises(
HomeAssistantError,
match="first native group unavailable",
) as exc:
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second_group.entity_id]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert exc.value is error
assert (
sum(
(not command if failed_axis == "power" else failed_axis in command)
for command in attempts
)
== 1
)
assert all(member.physical_kelvin == 4000 for member in setup.members)
assert all(member.physical_kelvin == 3000 for member in second_group.members)
assert all(member.brightness == 128 for member in second_group.members)
assert all(
call["target"] in (GROUP, second_group.entity_id) for call in setup.commands
), setup.commands
@pytest.mark.parametrize("brightness_adaptation", [False, True])
async def test_member_fallback_preserves_single_and_split_axis_transitions(
hass,
monkeypatch,
brightness_adaptation,
):
"""Colour fallback retains split timing and independently compatible brightness."""
setup = await setup_reported_group(
hass,
**{CONF_SEPARATE_TURN_ON_COMMANDS: True, CONF_SEND_SPLIT_DELAY: 0},
)
await prepare_reported_members(
hass,
setup,
on=(False, False, False),
brightness_adaptation=brightness_adaptation,
)
original = setup.group.async_turn_on
async def restrict_group_colour(**kwargs):
await original(**kwargs)
setup.group._attr_max_color_temp_kelvin = 2500
setup.group.async_write_ha_state()
monkeypatch.setattr(setup.group, "async_turn_on", restrict_group_colour)
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": GROUP, "transition": 2.5},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.is_on for member in setup.members)
assert all(member.physical_kelvin == 3000 for member in setup.members)
assert setup.commands[0] == {
"target": GROUP,
"service": "turn_on",
"transition": 2.5,
}
expected_duration = 1.25 if brightness_adaptation else 2.5
assert len(setup.commands) == (5 if brightness_adaptation else 4), setup.commands
brightness_commands = [
command for command in setup.commands if ATTR_BRIGHTNESS in command
]
if brightness_adaptation:
assert brightness_commands == [
{
"target": GROUP,
"service": "turn_on",
ATTR_BRIGHTNESS: 128,
"transition": expected_duration,
},
]
assert setup.commands[1] == brightness_commands[0]
assert all(member.brightness == 128 for member in setup.members)
else:
assert not brightness_commands
assert [member.brightness for member in setup.members] == [80, 110, 26]
assert len(setup.colour_commands) == 3
assert {command["target"] for command in setup.colour_commands} == {
member.entity_id for member in setup.members
}
for command in setup.colour_commands:
assert command["transition"] == expected_duration
assert ATTR_BRIGHTNESS not in command
@pytest.mark.parametrize("planner_enabled", [False, True])
async def test_independent_group_adapts_while_other_original_power_handler_waits(
hass,
monkeypatch,
planner_enabled,
):
setup, second = await _setup_independent_groups(hass)
setup.switch.manager.native_groups.disabled = not planner_enabled
setup.switch._send_split_delay = 0
entered, release, second_ready = asyncio.Event(), asyncio.Event(), asyncio.Event()
first_on, second_on = setup.group.async_turn_on, second.async_turn_on
async def first(**kwargs):
if not kwargs:
entered.set()
await asyncio.wait_for(release.wait(), 5)
await first_on(**kwargs)
async def other(**kwargs):
await second_on(**kwargs)
if ATTR_COLOR_TEMP_KELVIN in kwargs:
second_ready.set()
monkeypatch.setattr(setup.group, "async_turn_on", first)
monkeypatch.setattr(second, "async_turn_on", other)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
try:
await asyncio.wait_for(second_ready.wait(), 0.5)
other_completed = True
except TimeoutError:
other_completed = False
paused_commands = list(setup.commands)
paused_colour = [member.physical_kelvin for member in second.members]
finally:
release.set()
await task
await hass.async_block_till_done()
assert other_completed, (paused_commands, paused_colour)
assert all(member.physical_kelvin == 3000 for member in second.members)
@pytest.mark.parametrize("partial_parent", [False, True])
async def test_shared_ancestor_keeps_grouping_and_late_aggregate_coverage(
hass,
partial_parent,
):
"""Shared full/partial native ancestors keep one owner for aggregate reports."""
setup, second = await _setup_independent_groups(hass)
setup.switch._send_split_delay = 0
await setup.switch.async_turn_off()
selected_members = [*setup.members, *second.members]
extra = RecordingMember("light.native_unselected", setup.commands)
parent = RecordingGroup(
[*selected_members, *([extra] if partial_parent else [])],
setup.commands,
)
parent.entity_id = "light.native_parent"
parent._attr_is_on = False
await hass.data[DATA_COMPONENT].async_add_entities([extra, parent])
setup.switch._configured_lights.append(parent.entity_id)
setup.switch._expand_light_groups()
await setup.switch.async_turn_on(adapt_lights=False)
await hass.async_block_till_done()
setup.commands.clear()
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
)
await hass.async_block_till_done()
assert all(member.physical_kelvin == 3000 for member in selected_members)
assert all(member.brightness == 128 for member in selected_members)
assert not extra.is_on
assert extra.physical_kelvin == 4000
assert {command["target"] for command in setup.colour_commands} == (
{GROUP, second.entity_id} if partial_parent else {parent.entity_id}
), setup.commands
before_report = list(setup.commands)
# A separate aggregate ON report may arrive after the complete service.
parent.async_set_context(Context())
parent._attr_is_on = True
parent.async_write_ha_state()
await hass.async_block_till_done()
assert setup.commands == before_report
assert not extra.is_on
async def test_unsupported_overlapping_native_target_keeps_shared_dispatch_boundary(
hass,
monkeypatch,
):
"""An ineligible native target still shares physical members with its peer."""
setup, second = await _setup_independent_groups(hass)
setup.switch._send_split_delay = 0
extra = RecordingMember("light.native_foreign_member", setup.commands)
overlapping = RecordingGroup([*second.members, extra], setup.commands)
overlapping.entity_id = "light.native_unowned_overlap"
overlapping._attr_is_on = False
await hass.data[DATA_COMPONENT].async_add_entities([extra, overlapping])
entered, release = asyncio.Event(), asyncio.Event()
peer_power, peer_colour = asyncio.Event(), asyncio.Event()
original_overlap, original_peer = overlapping.async_turn_on, second.async_turn_on
async def hold_overlap(**kwargs):
entered.set()
await asyncio.wait_for(release.wait(), 5)
await original_overlap(**kwargs)
async def observe_peer(**kwargs):
await original_peer(**kwargs)
if not kwargs:
peer_power.set()
if ATTR_COLOR_TEMP_KELVIN in kwargs:
peer_colour.set()
monkeypatch.setattr(overlapping, "async_turn_on", hold_overlap)
monkeypatch.setattr(second, "async_turn_on", observe_peer)
task = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [overlapping.entity_id, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(peer_power.wait(), 2)
try:
await asyncio.wait_for(peer_colour.wait(), 0.5)
correction_before_shared_power = True
except TimeoutError:
correction_before_shared_power = False
finally:
release.set()
await task
await hass.async_block_till_done()
assert not correction_before_shared_power, setup.commands
assert all(member.physical_kelvin == 3000 for member in second.members)
assert extra.is_on
assert extra.physical_kelvin == 4000
@pytest.mark.parametrize("action", ["cancel", "new_colour"])
async def test_parallel_split_dispatch_respects_parent_cancel_and_new_member_intent(
hass,
monkeypatch,
action,
):
setup, second = await _setup_independent_groups(hass)
planner = setup.switch.manager.native_groups
barrier = DelayBarrier(monkeypatch, planner, "_execute_dispatch")
other_brightness = asyncio.Event()
original = second.async_turn_on
async def observe(**kwargs):
await original(**kwargs)
if ATTR_BRIGHTNESS in kwargs:
other_brightness.set()
monkeypatch.setattr(second, "async_turn_on", observe)
parent = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(barrier.entered.wait(), 2)
assert barrier.delay == 1.234
await asyncio.wait_for(other_brightness.wait(), 2)
assert not setup.colour_commands
if action == "cancel":
parent.cancel()
with pytest.raises(asyncio.CancelledError):
await parent
else:
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": RIGHT, ATTR_COLOR_TEMP_KELVIN: 4500},
blocking=True,
context=Context(),
)
finally:
barrier.release.set()
await asyncio.gather(parent, return_exceptions=True)
await hass.async_block_till_done()
if action == "cancel":
assert not setup.colour_commands, setup.commands
assert all(m.physical_kelvin == 4000 for m in [*setup.members, *second.members])
else:
assert [m.physical_kelvin for m in setup.members] == [3000, 4500, 3000]
assert all(m.physical_kelvin == 3000 for m in second.members)
assert not any(c["target"] == GROUP for c in setup.colour_commands)
assert all(m.is_on for m in [*setup.members, *second.members])
async def test_completed_partition_retains_late_report_claim_after_peer_cancel(
hass,
monkeypatch,
):
setup, second = await _setup_independent_groups(hass)
setup.switch._send_split_delay = 0
held_member = second.members[0]
held_member.hold_reports = True
entered, release, partition_done = asyncio.Event(), asyncio.Event(), asyncio.Event()
first_on = setup.group.async_turn_on
planner = setup.switch.manager.native_groups
original_run = planner._run
async def observe_partition(batch, invoke):
result = await original_run(batch, invoke)
if second.entity_id in batch.plan.requests:
partition_done.set()
return result
monkeypatch.setattr(planner, "_run", observe_partition)
async def first(**kwargs):
if not kwargs:
entered.set()
await asyncio.wait_for(release.wait(), 5)
await first_on(**kwargs)
monkeypatch.setattr(setup.group, "async_turn_on", first)
parent = asyncio.create_task(
hass.services.async_call(
"light",
"turn_on",
{"entity_id": [GROUP, second.entity_id]},
blocking=True,
context=Context(),
),
)
try:
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(partition_done.wait(), 2)
assert held_member.physical_kelvin == 3000
assert hass.states.is_state(held_member.entity_id, "off")
parent.cancel()
with pytest.raises(asyncio.CancelledError):
await parent
finally:
release.set()
await asyncio.gather(parent, return_exceptions=True)
await hass.async_block_till_done()
before_report = list(setup.commands)
held_member.hold_reports = False
held_member.async_set_context(Context())
held_member.async_write_ha_state()
await hass.async_block_till_done()
assert hass.states.is_state(held_member.entity_id, "on")
assert setup.commands == before_report
assert all(m.physical_kelvin == 3000 for m in second.members)