mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-28 12:54:22 +02:00
Merge main before final turn-on policy review
This commit is contained in:
commit
b34db1261c
14 changed files with 2181 additions and 25 deletions
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -16,6 +17,10 @@ from homeassistant.components.adaptive_lighting.adaptation_utils import (
|
|||
LightControlAttributes,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
CONF_AUTORESET_CONTROL,
|
||||
CONF_BRIGHTNESS_MODE,
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK,
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
CONF_LIGHTS,
|
||||
CONF_MAX_BRIGHTNESS,
|
||||
|
|
@ -28,9 +33,11 @@ from homeassistant.components.adaptive_lighting.const import (
|
|||
CONF_SLEEP_RGB_OR_COLOR_TEMP,
|
||||
CONF_SUNRISE_TIME,
|
||||
CONF_SUNSET_TIME,
|
||||
CONF_TAKE_OVER_CONTROL_MODE,
|
||||
CONF_TRANSITION,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.blueprint.models import Blueprint
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
|
|
@ -52,6 +59,7 @@ from homeassistant.const import (
|
|||
from homeassistant.core import CoreState, Event, HomeAssistant, State, callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util import yaml as yaml_util
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
|
@ -146,12 +154,543 @@ def _prepare_hass_startup(hass: HomeAssistant) -> None:
|
|||
hass.set_state(CoreState.not_running)
|
||||
|
||||
|
||||
def _blueprint_config(hass, tmp_path, filename, inputs, alias):
|
||||
"""Install an actual published blueprint for Home Assistant to load."""
|
||||
relative_path = f"adaptive_lighting/{filename}"
|
||||
hass.config.config_dir = str(tmp_path)
|
||||
destination = tmp_path / "blueprints" / "automation" / relative_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(README.parent / "blueprints" / "automation" / filename, destination)
|
||||
return {
|
||||
"alias": alias,
|
||||
"use_blueprint": {"path": relative_path, "input": inputs},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(params=["yaml", "blueprint"])
|
||||
def published_automation(hass: HomeAssistant, tmp_path: Path, request):
|
||||
"""Use the published YAML or blueprint with the same behavioral assertions."""
|
||||
|
||||
def config(summary, filename, inputs):
|
||||
yaml_config = _yaml_documents(summary)[-1]
|
||||
if request.param == "yaml":
|
||||
return yaml_config
|
||||
return _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
filename,
|
||||
inputs,
|
||||
yaml_config[0]["alias"],
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
sorted((README.parent / "blueprints" / "automation").glob("*.yaml")),
|
||||
ids=lambda path: path.stem,
|
||||
)
|
||||
def test_published_blueprint_schema(path: Path) -> None:
|
||||
"""Validate every published blueprint with Home Assistant's own schema."""
|
||||
blueprint = Blueprint(
|
||||
yaml_util.load_yaml(str(path)),
|
||||
expected_domain=automation.DOMAIN,
|
||||
schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA,
|
||||
)
|
||||
assert blueprint.validate() is None
|
||||
|
||||
|
||||
@pytest.fixture(params=["yaml", "blueprint", "blueprint-custom-minimum"])
|
||||
def minimum_automation_config(hass: HomeAssistant, tmp_path: Path, request):
|
||||
"""Run the same behavior checks against both published formats."""
|
||||
if request.param == "yaml":
|
||||
return _yaml_documents(
|
||||
"Turn a light off when its adaptive brightness target reaches the minimum.",
|
||||
)[0]
|
||||
inputs = {
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
"light_entity": "light.living_room",
|
||||
}
|
||||
if request.param == "blueprint-custom-minimum":
|
||||
inputs["minimum_pct"] = 10
|
||||
return _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
"turn_off_at_minimum.yaml",
|
||||
inputs,
|
||||
"Turn off at minimum",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("manual_control", [False, True])
|
||||
@pytest.mark.parametrize("trigger_kind", ["interval", "sleep"])
|
||||
@patch(
|
||||
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
|
||||
new=dt_util.utcnow,
|
||||
)
|
||||
async def test_minimum_brightness_power_automation(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
manual_control: bool,
|
||||
trigger_kind: str,
|
||||
minimum_automation_config,
|
||||
) -> None:
|
||||
"""Catch exact-float comparisons, repeated power actions, or lost manual control."""
|
||||
minimum = (
|
||||
minimum_automation_config.get("use_blueprint", {})
|
||||
.get("input", {})
|
||||
.get("minimum_pct", 1)
|
||||
if isinstance(minimum_automation_config, dict)
|
||||
else 1
|
||||
)
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 58, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 77},
|
||||
blocking=True,
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: minimum,
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_BRIGHTNESS_MODE: "linear",
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK: timedelta(hours=1),
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT: timedelta(hours=1),
|
||||
CONF_SUNRISE_TIME: "06:00:00",
|
||||
CONF_SUNSET_TIME: "18:00:00",
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
if manual_control:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
{ATTR_ENTITY_ID: adaptive_switch.entity_id, "manual_control": True},
|
||||
blocking=True,
|
||||
)
|
||||
await _setup_automation(hass, minimum_automation_config)
|
||||
off_calls = []
|
||||
|
||||
@callback
|
||||
def record_off(event: Event) -> None:
|
||||
if (
|
||||
event.data["domain"] == LIGHT_DOMAIN
|
||||
and event.data["service"] == SERVICE_TURN_OFF
|
||||
):
|
||||
off_calls.append(event.data["service_data"])
|
||||
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, record_off)
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] > minimum + 1
|
||||
|
||||
# The curve is above the minimum, but rounds to the same brightness command.
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 59, 50, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
if trigger_kind == "sleep":
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "switch.adaptive_lighting_living_room_sleep_mode"},
|
||||
blocking=True,
|
||||
)
|
||||
else:
|
||||
await adaptive_switch._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
if trigger_kind == "sleep":
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] == 1
|
||||
else:
|
||||
assert (
|
||||
minimum
|
||||
< adaptive_switch.extra_state_attributes["brightness_pct"]
|
||||
< minimum + 0.2
|
||||
)
|
||||
# The default sleep-mode policy clears manual control before publishing its target.
|
||||
should_turn_off = not manual_control or trigger_kind == "sleep"
|
||||
assert hass.states.get("light.living_room").state == (
|
||||
STATE_OFF if should_turn_off else STATE_ON
|
||||
)
|
||||
assert len(off_calls) == int(should_turn_off)
|
||||
|
||||
# Further target changes inside the minimum command range do not retrigger.
|
||||
freezer.move_to(datetime(2026, 9, 6, 19, 1, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await adaptive_switch._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch.extra_state_attributes["brightness_pct"] == (
|
||||
1 if trigger_kind == "sleep" else minimum
|
||||
)
|
||||
assert len(off_calls) == int(should_turn_off)
|
||||
|
||||
if should_turn_off:
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
assert len(off_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous", [None, "unknown", "unavailable"])
|
||||
async def test_minimum_brightness_ignores_missing_previous_target(
|
||||
hass: HomeAssistant,
|
||||
previous: str | None,
|
||||
minimum_automation_config,
|
||||
) -> None:
|
||||
"""A missing target must not become a numeric crossing during recovery."""
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
},
|
||||
)
|
||||
await _setup_automation(hass, minimum_automation_config)
|
||||
attributes = dict(hass.states.get(adaptive_switch.entity_id).attributes)
|
||||
assert attributes["brightness_pct"] == 1
|
||||
if previous is None:
|
||||
hass.states.async_remove(adaptive_switch.entity_id)
|
||||
else:
|
||||
hass.states.async_set(
|
||||
adaptive_switch.entity_id,
|
||||
previous,
|
||||
{**attributes, "brightness_pct": previous},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set(adaptive_switch.entity_id, STATE_ON, attributes)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").state == STATE_ON
|
||||
|
||||
|
||||
@pytest.mark.parametrize("previous_manual", [None, "color", "brightness"])
|
||||
@patch(
|
||||
"homeassistant.components.adaptive_lighting.color_and_brightness.utcnow",
|
||||
new=dt_util.utcnow,
|
||||
)
|
||||
async def test_minimum_manual_control_lifecycle(
|
||||
hass: HomeAssistant,
|
||||
freezer,
|
||||
published_automation,
|
||||
previous_manual: str | None,
|
||||
) -> None:
|
||||
"""Pause at the calculated floor, preserve other flags, and reset on off/on."""
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 58, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
config = published_automation(
|
||||
"Pause brightness at the minimum using manual control.",
|
||||
"manual_control_at_minimum.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
"light_entity": "light.living_room",
|
||||
},
|
||||
)
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 77},
|
||||
blocking=True,
|
||||
)
|
||||
_, profile = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_MIN_COLOR_TEMP: 3000,
|
||||
CONF_MAX_COLOR_TEMP: 3000,
|
||||
CONF_BRIGHTNESS_MODE: "linear",
|
||||
CONF_BRIGHTNESS_MODE_TIME_DARK: timedelta(hours=1),
|
||||
CONF_BRIGHTNESS_MODE_TIME_LIGHT: timedelta(hours=1),
|
||||
CONF_SUNRISE_TIME: "06:00:00",
|
||||
CONF_SUNSET_TIME: "18:00:00",
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TAKE_OVER_CONTROL_MODE: "pause_changed",
|
||||
},
|
||||
)
|
||||
if previous_manual:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
{ATTR_ENTITY_ID: profile.entity_id, "manual_control": previous_manual},
|
||||
blocking=True,
|
||||
)
|
||||
await _setup_automation(hass, config)
|
||||
manual_calls = []
|
||||
|
||||
@callback
|
||||
def record_manual_call(event: Event) -> None:
|
||||
if (
|
||||
event.data["domain"] == DOMAIN
|
||||
and event.data["service"] == "set_manual_control"
|
||||
):
|
||||
manual_calls.append(event.data["service_data"])
|
||||
|
||||
hass.bus.async_listen(EVENT_CALL_SERVICE, record_manual_call)
|
||||
freezer.move_to(datetime(2026, 9, 6, 18, 59, 50, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
flags = profile.manager.get_manual_control_attributes("light.living_room")
|
||||
assert LightControlAttributes.BRIGHTNESS in flags
|
||||
assert (LightControlAttributes.COLOR in flags) is (previous_manual == "color")
|
||||
assert len(manual_calls) == (0 if previous_manual == "brightness" else 1)
|
||||
paused_brightness = hass.states.get("light.living_room").attributes[ATTR_BRIGHTNESS]
|
||||
assert profile.is_on
|
||||
assert profile.adapt_brightness_switch.is_on
|
||||
if previous_manual != "brightness":
|
||||
assert paused_brightness == 3
|
||||
|
||||
freezer.move_to(datetime(2026, 9, 6, 19, 1, tzinfo=dt_util.DEFAULT_TIME_ZONE))
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
assert len(manual_calls) == (0 if previous_manual == "brightness" else 1)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 100,
|
||||
CONF_MAX_BRIGHTNESS: 100,
|
||||
CONF_MIN_COLOR_TEMP: 5000,
|
||||
CONF_MAX_COLOR_TEMP: 5000,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("light.living_room")
|
||||
assert state.state == STATE_ON
|
||||
assert state.attributes[ATTR_BRIGHTNESS] == paused_brightness
|
||||
assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(
|
||||
3000 if previous_manual == "color" else 5000,
|
||||
abs=5,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get("light.living_room").attributes[ATTR_BRIGHTNESS] == 255
|
||||
|
||||
# Another crossing can pause again, but clearing it at the floor must stick.
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert profile.manager.get_manual_control_attributes("light.living_room")
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"set_manual_control",
|
||||
{ATTR_ENTITY_ID: profile.entity_id, "manual_control": False},
|
||||
blocking=True,
|
||||
)
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
|
||||
if previous_manual is None:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 50,
|
||||
CONF_MAX_BRIGHTNESS: 50,
|
||||
CONF_AUTORESET_CONTROL: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert profile.manager.get_manual_control_attributes("light.living_room")
|
||||
cleared, remove_listener = _state_waiter(
|
||||
hass,
|
||||
profile.entity_id,
|
||||
lambda state: state.attributes.get("manual_control_brightness") == [],
|
||||
)
|
||||
freezer.tick(timedelta(seconds=1))
|
||||
async_fire_time_changed(hass, dt_util.utcnow())
|
||||
await asyncio.wait_for(cleared, timeout=2)
|
||||
remove_listener()
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"abort_entity",
|
||||
[
|
||||
"light.living_room",
|
||||
"switch.adaptive_lighting_living_room",
|
||||
"switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
],
|
||||
)
|
||||
async def test_minimum_manual_control_aborts_when_disabled(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
abort_entity: str,
|
||||
) -> None:
|
||||
"""Abandon a pending wait immediately when the light or profile is disabled."""
|
||||
config = published_automation(
|
||||
"Pause brightness at the minimum using manual control.",
|
||||
"manual_control_at_minimum.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"brightness_switch": "switch.adaptive_lighting_living_room_adapt_brightness",
|
||||
"light_entity": "light.living_room",
|
||||
},
|
||||
)
|
||||
await _setup_template_lights(hass, ["Living Room"])
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: "light.living_room", ATTR_BRIGHTNESS: 128},
|
||||
blocking=True,
|
||||
)
|
||||
_, profile = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
CONF_NAME: "Living Room",
|
||||
CONF_LIGHTS: ["light.living_room"],
|
||||
CONF_MIN_BRIGHTNESS: 50,
|
||||
CONF_MAX_BRIGHTNESS: 50,
|
||||
CONF_INITIAL_TRANSITION: 0,
|
||||
CONF_TRANSITION: 0,
|
||||
CONF_TAKE_OVER_CONTROL_MODE: "pause_changed",
|
||||
},
|
||||
)
|
||||
await _setup_automation(hass, config)
|
||||
waiting, remove_listener = _state_waiter(
|
||||
hass,
|
||||
"automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
lambda state: state.attributes.get("current") == 1,
|
||||
)
|
||||
state = hass.states.get(profile.entity_id)
|
||||
hass.states.async_set(
|
||||
profile.entity_id,
|
||||
STATE_ON,
|
||||
{**state.attributes, "brightness_pct": 1},
|
||||
)
|
||||
await asyncio.wait_for(waiting, timeout=1)
|
||||
remove_listener()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
stopped, remove_stopped = _state_waiter(
|
||||
hass,
|
||||
"automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
lambda state: state.attributes.get("current") == 0,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
abort_entity.split(".", maxsplit=1)[0],
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: abort_entity},
|
||||
blocking=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(stopped, timeout=1)
|
||||
finally:
|
||||
remove_stopped()
|
||||
if stopped.cancelled():
|
||||
await hass.services.async_call(
|
||||
automation.DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{
|
||||
ATTR_ENTITY_ID: "automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert not profile.manager.get_manual_control_attributes("light.living_room")
|
||||
assert (
|
||||
hass.states.get(
|
||||
"automation.adaptive_lighting_pause_brightness_at_minimum",
|
||||
).attributes["current"]
|
||||
== 0
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
abort_entity.split(".", maxsplit=1)[0],
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: abort_entity},
|
||||
blocking=True,
|
||||
)
|
||||
await profile._async_update_at_interval_action()
|
||||
await hass.async_block_till_done()
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"change_switch_settings",
|
||||
{
|
||||
ATTR_ENTITY_ID: profile.entity_id,
|
||||
CONF_MIN_BRIGHTNESS: 1,
|
||||
CONF_MAX_BRIGHTNESS: 1,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
profile.manager.get_manual_control_attributes("light.living_room")
|
||||
== LightControlAttributes.BRIGHTNESS
|
||||
)
|
||||
|
||||
|
||||
async def test_schedule_profile_executes_blocks_and_restore(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Catch ignored attribute changes, incomplete restore, or switch coupling."""
|
||||
summary = "Use a Schedule helper as a step-based custom lighting profile."
|
||||
automation_config = _yaml_documents(summary)[-1]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"schedule_profile.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"schedule_entity": "schedule.adaptive_lighting_profile",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{
|
||||
|
|
@ -220,11 +759,21 @@ async def test_schedule_profile_executes_blocks_and_restore(
|
|||
assert adaptive_switch._sun_light_settings.max_color_temp == 2750
|
||||
|
||||
|
||||
async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> None:
|
||||
async def test_schedule_profile_reapplies_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Verify startup applies the already-active schedule block."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = "Use a Schedule helper as a step-based custom lighting profile."
|
||||
automation_config = _yaml_documents(summary)[-1]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"schedule_profile.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"schedule_entity": "schedule.adaptive_lighting_profile",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
hass.states.async_set(
|
||||
"schedule.adaptive_lighting_profile",
|
||||
|
|
@ -241,12 +790,22 @@ async def test_schedule_profile_reapplies_at_startup(hass: HomeAssistant) -> Non
|
|||
assert adaptive_switch._sun_light_settings.max_color_temp == 2500
|
||||
|
||||
|
||||
async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
|
||||
async def test_lux_profile_executes_hysteresis(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Catch missing threshold actions or changes inside the dead band."""
|
||||
summary = (
|
||||
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"illuminance_sensor": "sensor.living_room_illuminance",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
|
|
@ -273,13 +832,21 @@ async def test_lux_profile_executes_hysteresis(hass: HomeAssistant) -> None:
|
|||
|
||||
async def test_lux_profile_executes_unknown_recovery_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Catch a startup hang or failure to recover from an unknown sensor."""
|
||||
_prepare_hass_startup(hass)
|
||||
summary = (
|
||||
"Reduce daytime brightness when an illuminance sensor detects strong daylight."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_living_room",
|
||||
"illuminance_sensor": "sensor.living_room_illuminance",
|
||||
},
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Living Room", CONF_MAX_BRIGHTNESS: 80},
|
||||
|
|
@ -302,6 +869,44 @@ async def test_lux_profile_executes_unknown_recovery_at_startup(
|
|||
assert adaptive_switch._sun_light_settings.max_brightness == 30
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("high_lux", "low_lux"), [(400, 250), (200, 300), (200, 200)])
|
||||
async def test_daylight_blueprint_custom_inputs(
|
||||
hass: HomeAssistant,
|
||||
tmp_path: Path,
|
||||
high_lux: int,
|
||||
low_lux: int,
|
||||
) -> None:
|
||||
"""Use selected entities and limits; invalid threshold order must do nothing."""
|
||||
config = _blueprint_config(
|
||||
hass,
|
||||
tmp_path,
|
||||
"daylight_limit.yaml",
|
||||
{
|
||||
"adaptive_switch": "switch.adaptive_lighting_office",
|
||||
"illuminance_sensor": "sensor.office_illuminance",
|
||||
"high_lux": high_lux,
|
||||
"low_lux": low_lux,
|
||||
"daylight_maximum": 20,
|
||||
"normal_maximum": 70,
|
||||
},
|
||||
"Custom daylight",
|
||||
)
|
||||
_, adaptive_switch = await setup_switch(
|
||||
hass,
|
||||
{CONF_NAME: "Office", CONF_MAX_BRIGHTNESS: 80},
|
||||
)
|
||||
hass.states.async_set("sensor.office_illuminance", "300")
|
||||
await _setup_automation(hass, config)
|
||||
assert hass.states.get("automation.custom_daylight") is not None
|
||||
valid_thresholds = high_lux > low_lux
|
||||
for lux, expected in [(500, 20), (300, 20), (100, 70)]:
|
||||
hass.states.async_set("sensor.office_illuminance", str(lux))
|
||||
await hass.async_block_till_done()
|
||||
assert adaptive_switch._sun_light_settings.max_brightness == (
|
||||
expected if valid_thresholds else 80
|
||||
)
|
||||
|
||||
|
||||
async def test_hue_script_applies_current_values_to_fresh_profile_targets(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
|
|
@ -574,13 +1179,24 @@ async def test_autoreset_manual_control_uses_one_renewable_timer(
|
|||
|
||||
async def test_sleep_toggle_uses_fresh_profile_entity_ids(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Execute state triggers against fresh child entity IDs."""
|
||||
summary = (
|
||||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"sleep_mode.yaml",
|
||||
{
|
||||
"sleep_helper": "input_boolean.sleep_mode",
|
||||
"sleep_switches": [
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
],
|
||||
},
|
||||
)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
|
|
@ -623,6 +1239,7 @@ async def test_sleep_toggle_uses_fresh_profile_entity_ids(
|
|||
|
||||
async def test_sleep_toggle_applies_restored_state_at_startup(
|
||||
hass: HomeAssistant,
|
||||
published_automation,
|
||||
) -> None:
|
||||
"""Verify startup applies the input boolean's restored state."""
|
||||
_prepare_hass_startup(hass)
|
||||
|
|
@ -630,13 +1247,24 @@ async def test_sleep_toggle_applies_restored_state_at_startup(
|
|||
'Toggle multiple Adaptive Lighting switches to "sleep mode" using an '
|
||||
"<code>input_boolean.sleep_mode</code>."
|
||||
)
|
||||
automation_config = _yaml_documents(summary)[0]
|
||||
automation_config = published_automation(
|
||||
summary,
|
||||
"sleep_mode.yaml",
|
||||
{
|
||||
"sleep_helper": "input_boolean.sleep_mode",
|
||||
"sleep_switches": [
|
||||
"switch.adaptive_lighting_living_room_sleep_mode",
|
||||
"switch.adaptive_lighting_bedroom_sleep_mode",
|
||||
],
|
||||
},
|
||||
)
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"input_boolean",
|
||||
{"input_boolean": {"sleep_mode": {}}},
|
||||
)
|
||||
await setup_switch(hass, {CONF_NAME: "Living Room"})
|
||||
await setup_switch(hass, {CONF_NAME: "Bedroom"})
|
||||
await hass.services.async_call(
|
||||
"input_boolean",
|
||||
SERVICE_TURN_ON,
|
||||
|
|
|
|||
372
tests/test_diagnostics.py
Normal file
372
tests/test_diagnostics.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"""Tests for Adaptive Lighting diagnostics."""
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.components.adaptive_lighting.adaptation_utils import (
|
||||
AdaptationData,
|
||||
LightControlAttributes,
|
||||
_create_service_call_data_iterator,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.const import (
|
||||
ATTR_ADAPTIVE_LIGHTING_MANAGER,
|
||||
CONF_AUTORESET_CONTROL,
|
||||
CONF_INTERCEPT,
|
||||
CONF_MANUAL_CONTROL,
|
||||
DOMAIN,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
)
|
||||
from homeassistant.components.adaptive_lighting.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SERVICE_DATA,
|
||||
CONF_LIGHTS,
|
||||
CONF_NAME,
|
||||
EVENT_CALL_SERVICE,
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_OFF,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import State
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import get_diagnostics_for_config_entry
|
||||
|
||||
from .test_switch import (
|
||||
ENTITY_LIGHT_1,
|
||||
ENTITY_LIGHT_2,
|
||||
ENTITY_LIGHT_3,
|
||||
setup_lights,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cleanup_diagnostics(hass):
|
||||
"""Cancel integration tasks created by diagnostics fixtures."""
|
||||
yield
|
||||
manager = hass.data.get(DOMAIN, {}).get(ATTR_ADAPTIVE_LIGHTING_MANAGER)
|
||||
if manager is None:
|
||||
return
|
||||
for timer in manager.auto_reset_manual_control_timers.values():
|
||||
timer.cancel()
|
||||
for timer in manager.transition_timers.values():
|
||||
timer.cancel()
|
||||
for task in manager.adaptation_tasks:
|
||||
task.cancel()
|
||||
|
||||
|
||||
async def _setup_entry(hass, name, lights, **data):
|
||||
"""Set up a real Adaptive Lighting config entry."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_NAME: name,
|
||||
CONF_LIGHTS: lights,
|
||||
CONF_INTERCEPT: False,
|
||||
**data,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
return entry, hass.data[DOMAIN][entry.entry_id][SWITCH_DOMAIN]
|
||||
|
||||
|
||||
async def test_config_entry_diagnostics_reports_allowlisted_current_facts(
|
||||
hass,
|
||||
hass_client,
|
||||
cleanup_diagnostics,
|
||||
):
|
||||
"""Diagnostics report current selected-profile facts without identifiers."""
|
||||
await setup_lights(hass)
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Upstairs Profile",
|
||||
[ENTITY_LIGHT_1, ENTITY_LIGHT_2],
|
||||
**{CONF_AUTORESET_CONTROL: 60},
|
||||
)
|
||||
other_entry, _ = await _setup_entry(
|
||||
hass,
|
||||
"Private Basement Profile",
|
||||
[ENTITY_LIGHT_3],
|
||||
)
|
||||
|
||||
await switch.adapt_color_switch.async_turn_off()
|
||||
await switch.sleep_mode_switch.async_turn_on()
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
{
|
||||
ATTR_ENTITY_ID: switch.entity_id,
|
||||
CONF_LIGHTS: [ENTITY_LIGHT_1],
|
||||
CONF_MANUAL_CONTROL: "brightness",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
{
|
||||
ATTR_ENTITY_ID: switch.entity_id,
|
||||
CONF_LIGHTS: [ENTITY_LIGHT_2],
|
||||
CONF_MANUAL_CONTROL: "color",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
hass.states.async_set(
|
||||
ENTITY_LIGHT_2,
|
||||
STATE_UNAVAILABLE,
|
||||
{"friendly_name": "Private Bedside Lamp", "room": "Private Bedroom"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
|
||||
assert manager.get_manual_control_attributes(ENTITY_LIGHT_1) == (
|
||||
LightControlAttributes.BRIGHTNESS
|
||||
)
|
||||
manager.last_service_data[ENTITY_LIGHT_1] = {
|
||||
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
|
||||
ATTR_BRIGHTNESS: 123,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3456,
|
||||
ATTR_RGB_COLOR: (12, 34, 56),
|
||||
ATTR_TRANSITION: 4.5,
|
||||
"context_id": "private-context-id",
|
||||
"friendly_name": "Private Bedside Lamp",
|
||||
}
|
||||
manager.last_service_data.pop(ENTITY_LIGHT_2, None)
|
||||
|
||||
result = await get_diagnostics_for_config_entry(hass, hass_client, entry)
|
||||
|
||||
assert result["loaded"] is True
|
||||
assert result["profile_switches"] == {
|
||||
"profile": True,
|
||||
"adapt_brightness": True,
|
||||
"adapt_color": False,
|
||||
"sleep_mode": True,
|
||||
}
|
||||
assert result["manager_fact_scope"] == "global_shared_across_profiles"
|
||||
assert list(result["lights"]) == ["light_1", "light_2"]
|
||||
assert result["lights"]["light_1"]["state"] == "on"
|
||||
assert result["lights"]["light_1"]["global_manager_manual_control"] == {
|
||||
"brightness": True,
|
||||
"color": False,
|
||||
}
|
||||
assert result["lights"]["light_1"][
|
||||
"global_manager_autoreset_seconds"
|
||||
] == pytest.approx(60, abs=2)
|
||||
assert result["lights"]["light_1"]["global_manager_last_adaptation_values"] == {
|
||||
ATTR_BRIGHTNESS: 123,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3456,
|
||||
ATTR_RGB_COLOR: [12, 34, 56],
|
||||
ATTR_TRANSITION: 4.5,
|
||||
}
|
||||
assert result["lights"]["light_2"] == {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"global_manager_manual_control": {
|
||||
"brightness": False,
|
||||
"color": True,
|
||||
},
|
||||
"global_manager_autoreset_seconds": pytest.approx(60, abs=2),
|
||||
"global_manager_last_adaptation_values": None,
|
||||
}
|
||||
|
||||
serialized = json.dumps(result, sort_keys=True)
|
||||
for sensitive_value in (
|
||||
entry.entry_id,
|
||||
other_entry.entry_id,
|
||||
ENTITY_LIGHT_1,
|
||||
ENTITY_LIGHT_2,
|
||||
ENTITY_LIGHT_3,
|
||||
"Private Upstairs Profile",
|
||||
"Private Basement Profile",
|
||||
"Private Bedside Lamp",
|
||||
"Private Bedroom",
|
||||
"private-context-id",
|
||||
):
|
||||
assert sensitive_value not in serialized
|
||||
|
||||
|
||||
async def test_diagnostics_labels_accumulated_partial_adaptation_values(
|
||||
hass,
|
||||
cleanup_diagnostics,
|
||||
):
|
||||
"""Diagnostics do not describe merged per-attribute history as one command."""
|
||||
await setup_lights(hass)
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Profile",
|
||||
[ENTITY_LIGHT_1],
|
||||
)
|
||||
commands = [
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_LIGHT_1,
|
||||
ATTR_BRIGHTNESS: 100,
|
||||
ATTR_RGB_COLOR: (12, 34, 56),
|
||||
ATTR_TRANSITION: 2,
|
||||
},
|
||||
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 180},
|
||||
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_COLOR_TEMP_KELVIN: 3500},
|
||||
]
|
||||
call_events = []
|
||||
remove_listener = hass.bus.async_listen(EVENT_CALL_SERVICE, call_events.append)
|
||||
|
||||
await switch._execute_adaptation_calls(
|
||||
AdaptationData(
|
||||
entity_id=ENTITY_LIGHT_1,
|
||||
context=switch.create_context("diagnostics_test"),
|
||||
sleep_time=0,
|
||||
service_call_datas=_create_service_call_data_iterator(
|
||||
hass,
|
||||
commands,
|
||||
filter_by_state=False,
|
||||
),
|
||||
force=True,
|
||||
max_length=len(commands),
|
||||
attributes=LightControlAttributes.ALL,
|
||||
),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
remove_listener()
|
||||
|
||||
actual_commands = [
|
||||
event.data[ATTR_SERVICE_DATA]
|
||||
for event in call_events
|
||||
if event.data["domain"] == LIGHT_DOMAIN
|
||||
and event.data["service"] == SERVICE_TURN_ON
|
||||
]
|
||||
assert actual_commands == commands
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
light = result["lights"]["light_1"]
|
||||
assert "global_manager_last_sent_target" not in light
|
||||
assert light["global_manager_last_adaptation_values"] == {
|
||||
ATTR_BRIGHTNESS: 180,
|
||||
ATTR_COLOR_TEMP_KELVIN: 3500,
|
||||
ATTR_RGB_COLOR: [12, 34, 56],
|
||||
ATTR_TRANSITION: 2,
|
||||
}
|
||||
|
||||
|
||||
async def test_diagnostics_preserves_restored_off_profile_tracked_group(hass):
|
||||
"""Diagnostics report tracked targets without refreshing late groups."""
|
||||
await setup_lights(hass)
|
||||
group = "light.private_late_group"
|
||||
members = [ENTITY_LIGHT_1, ENTITY_LIGHT_2]
|
||||
with patch(
|
||||
"homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state",
|
||||
return_value=State("switch.restored", STATE_OFF),
|
||||
):
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Restored Profile",
|
||||
[group],
|
||||
)
|
||||
assert not switch.is_on
|
||||
assert switch.lights == [group]
|
||||
|
||||
hass.states.async_set(
|
||||
group,
|
||||
STATE_UNAVAILABLE,
|
||||
{ATTR_ENTITY_ID: members, "friendly_name": "Private Late Group"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
|
||||
manager_lights_before = set(manager.lights)
|
||||
reset_times_before = dict(manager.auto_reset_manual_control_times)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
|
||||
assert result["lights"] == {
|
||||
"light_1": {
|
||||
"state": STATE_UNAVAILABLE,
|
||||
"global_manager_manual_control": {
|
||||
"brightness": False,
|
||||
"color": False,
|
||||
},
|
||||
"global_manager_autoreset_seconds": None,
|
||||
"global_manager_last_adaptation_values": None,
|
||||
},
|
||||
}
|
||||
assert switch.lights == [group]
|
||||
assert manager.lights == manager_lights_before
|
||||
assert manager.auto_reset_manual_control_times == reset_times_before
|
||||
assert group not in json.dumps(result)
|
||||
|
||||
|
||||
async def test_diagnostics_handles_missing_states_and_unload_without_side_effects(
|
||||
hass,
|
||||
):
|
||||
"""Diagnostics normalize states and never change live integration state."""
|
||||
await setup_lights(hass)
|
||||
entry, switch = await _setup_entry(
|
||||
hass,
|
||||
"Private Profile",
|
||||
[ENTITY_LIGHT_1, ENTITY_LIGHT_2, ENTITY_LIGHT_3, "light.private_missing"],
|
||||
)
|
||||
hass.states.async_set(ENTITY_LIGHT_2, STATE_OFF)
|
||||
hass.states.async_set(ENTITY_LIGHT_3, STATE_UNAVAILABLE)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
manager = hass.data[DOMAIN][ATTR_ADAPTIVE_LIGHTING_MANAGER]
|
||||
manual_control_before = dict(manager.manual_control)
|
||||
last_service_data_before = deepcopy(manager.last_service_data)
|
||||
timers_before = dict(manager.auto_reset_manual_control_timers)
|
||||
switch_states_before = (
|
||||
switch.is_on,
|
||||
switch.adapt_brightness_switch.is_on,
|
||||
switch.adapt_color_switch.is_on,
|
||||
switch.sleep_mode_switch.is_on,
|
||||
)
|
||||
service_events = []
|
||||
state_events = []
|
||||
remove_service_listener = hass.bus.async_listen(
|
||||
EVENT_CALL_SERVICE,
|
||||
service_events.append,
|
||||
)
|
||||
remove_state_listener = hass.bus.async_listen(
|
||||
EVENT_STATE_CHANGED,
|
||||
state_events.append,
|
||||
)
|
||||
|
||||
result = await async_get_config_entry_diagnostics(hass, entry)
|
||||
await hass.async_block_till_done()
|
||||
remove_service_listener()
|
||||
remove_state_listener()
|
||||
|
||||
assert [light["state"] for light in result["lights"].values()] == [
|
||||
"on",
|
||||
STATE_OFF,
|
||||
STATE_UNAVAILABLE,
|
||||
"missing",
|
||||
]
|
||||
assert json.dumps(result)
|
||||
assert not service_events
|
||||
assert not state_events
|
||||
assert manager.manual_control == manual_control_before
|
||||
assert manager.last_service_data == last_service_data_before
|
||||
assert manager.auto_reset_manual_control_timers == timers_before
|
||||
assert (
|
||||
switch.is_on,
|
||||
switch.adapt_brightness_switch.is_on,
|
||||
switch.adapt_color_switch.is_on,
|
||||
switch.sleep_mode_switch.is_on,
|
||||
) == switch_states_before
|
||||
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert await async_get_config_entry_diagnostics(hass, entry) == {"loaded": False}
|
||||
|
|
@ -41,6 +41,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,
|
||||
|
|
@ -1761,6 +1762,155 @@ 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
|
||||
)
|
||||
# Independent profiles may publish their events in either order.
|
||||
assert sorted(event.data[SWITCH_DOMAIN] for event in events) == sorted(
|
||||
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)
|
||||
|
|
@ -5113,3 +5263,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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue