Refresh contributor credit against main

This commit is contained in:
Bas Nijholt 2026-09-06 10:13:31 -07:00
commit 9061dcb43c
7 changed files with 790 additions and 17 deletions

View file

@ -1524,6 +1524,33 @@
"bug"
]
},
{
"login": "jaynis",
"name": "jaynis",
"avatar_url": "https://avatars.githubusercontent.com/u/1553675?v=4",
"profile": "https://github.com/jaynis",
"contributions": [
"code"
]
},
{
"login": "alistairg",
"name": "Alistair Galbraith",
"avatar_url": "https://avatars.githubusercontent.com/u/272786?v=4",
"profile": "https://github.com/alistairg",
"contributions": [
"code"
]
},
{
"login": "hesseleo",
"name": "Leonhard Hesse",
"avatar_url": "https://avatars.githubusercontent.com/u/44778508?v=4",
"profile": "https://github.com/hesseleo",
"contributions": [
"code"
]
},
{
"login": "timstallmann",
"name": "Tim Stallmann",

View file

@ -1,7 +1,7 @@
[![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration)
![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-168-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-171-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙
@ -583,6 +583,16 @@ logger:
```
After the issue occurs, create a new issue report with the log (`/config/home-assistant.log`).
For support, use Home Assistant's **Download diagnostics** action on the
Adaptive Lighting config entry. The download is an on-demand snapshot of the
profile's current switches and currently tracked light targets. It does not
refresh group membership or predict targets a disabled profile would use after
being enabled. It does not create live sensors; existing switch attributes
remain the interface for automations.
The reported last adaptation values are the shared manager's latest retained
value for each attribute. They can come from different commands and do not
represent one sent command or the current desired state.
<!-- SECTION:troubleshooting-intro:END -->
<!-- SECTION:common-problems:START -->
@ -926,6 +936,11 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Mariuss811"><img src="https://avatars.githubusercontent.com/u/54115696?v=4?s=100" width="100px;" alt="Wosten"/><br /><sub><b>Wosten</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3AMariuss811" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://zpriddy.com"><img src="https://avatars.githubusercontent.com/u/1858679?v=4?s=100" width="100px;" alt="Zachary Priddy"/><br /><sub><b>Zachary Priddy</b></sub></a><br /><a href="#ideas-zpriddy" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://blakeslee.me"><img src="https://avatars.githubusercontent.com/u/60765958?v=4?s=100" width="100px;" alt="Andrew Blakeslee Moore"/><br /><sub><b>Andrew Blakeslee Moore</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/issues?q=author%3Aabkslm" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jaynis"><img src="https://avatars.githubusercontent.com/u/1553675?v=4?s=100" width="100px;" alt="jaynis"/><br /><sub><b>jaynis</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=jaynis" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/alistairg"><img src="https://avatars.githubusercontent.com/u/272786?v=4?s=100" width="100px;" alt="Alistair Galbraith"/><br /><sub><b>Alistair Galbraith</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=alistairg" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hesseleo"><img src="https://avatars.githubusercontent.com/u/44778508?v=4?s=100" width="100px;" alt="Leonhard Hesse"/><br /><sub><b>Leonhard Hesse</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=hesseleo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.tim-maps.com"><img src="https://avatars.githubusercontent.com/u/6741938?v=4?s=100" width="100px;" alt="Tim Stallmann"/><br /><sub><b>Tim Stallmann</b></sub></a><br /><a href="https://github.com/basnijholt/adaptive-lighting/commits?author=timstallmann" title="Code">💻</a></td>
</tr>
</tbody>

View file

@ -0,0 +1,131 @@
"""Diagnostics support for Adaptive Lighting."""
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from .adaptation_utils import LightControlAttributes
from .const import (
ADAPT_BRIGHTNESS_SWITCH,
ADAPT_COLOR_SWITCH,
ATTR_ADAPTIVE_LIGHTING_MANAGER,
DOMAIN,
SLEEP_MODE_SWITCH,
)
from .switch import AdaptiveLightingManager, AdaptiveSwitch
_REPORTABLE_LIGHT_STATES = {
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
}
_TARGET_ATTRIBUTES = (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ATTR_TRANSITION,
)
def _last_adaptation_values(
manager: AdaptiveLightingManager,
light: str,
) -> dict[str, Any] | None:
"""Return latest retained value for each allowlisted adaptation attribute.
Values may come from different commands because the manager merges partial
service data per attribute.
"""
service_data = manager.last_service_data.get(light)
if service_data is None:
return None
target = {
attribute: (
list(service_data[attribute])
if attribute == ATTR_RGB_COLOR
else service_data[attribute]
)
for attribute in _TARGET_ATTRIBUTES
if attribute in service_data
}
return target or None
def _autoreset_seconds(
manager: AdaptiveLightingManager,
light: str,
) -> float | None:
"""Return remaining time for a running global manual-control reset."""
timer = manager.auto_reset_manual_control_timers.get(light)
if timer is None or not timer.is_running():
return None
remaining = timer.remaining_time()
return round(remaining, 3) if remaining > 0 else None
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> dict[str, Any]:
"""Return an allowlisted, on-demand snapshot for one config entry."""
domain_data = hass.data.get(DOMAIN)
if not isinstance(domain_data, dict):
return {"loaded": False}
entry_data = domain_data.get(config_entry.entry_id)
manager = domain_data.get(ATTR_ADAPTIVE_LIGHTING_MANAGER)
if not isinstance(entry_data, dict) or not isinstance(
manager,
AdaptiveLightingManager,
):
return {"loaded": False}
switch = entry_data.get(SWITCH_DOMAIN)
if not isinstance(switch, AdaptiveSwitch):
return {"loaded": False}
lights: dict[str, Any] = {}
for index, light in enumerate(sorted(switch.lights), start=1):
state = hass.states.get(light)
state_value = "missing"
if state is not None:
state_value = (
state.state
if state.state in _REPORTABLE_LIGHT_STATES
else STATE_UNKNOWN
)
manual_control = manager.get_manual_control_attributes(light)
lights[f"light_{index}"] = {
"state": state_value,
"global_manager_manual_control": {
"brightness": bool(
manual_control & LightControlAttributes.BRIGHTNESS,
),
"color": bool(manual_control & LightControlAttributes.COLOR),
},
"global_manager_autoreset_seconds": _autoreset_seconds(manager, light),
"global_manager_last_adaptation_values": _last_adaptation_values(
manager,
light,
),
}
return {
"loaded": True,
"profile_switches": {
"profile": switch.is_on,
"adapt_brightness": entry_data[ADAPT_BRIGHTNESS_SWITCH].is_on,
"adapt_color": entry_data[ADAPT_COLOR_SWITCH].is_on,
"sleep_mode": entry_data[SLEEP_MODE_SWITCH].is_on,
},
"manager_fact_scope": "global_shared_across_profiles",
"lights": lights,
}

View file

@ -2612,22 +2612,20 @@ class AdaptiveLightingManager:
# Fix for https://github.com/basnijholt/adaptive-lighting/issues/1378
state = self.hass.states.get(eid)
if state is not None and state.state == STATE_ON:
try:
switch = _switch_with_lights(
self.hass,
[eid],
expand_light_groups=False,
)
await self.update_manually_controlled_from_event(
switch,
eid,
force=False,
)
except NoSwitchFoundError:
_LOGGER.debug(
"No switch found for entity_id='%s' in 'on' event listener",
eid,
)
switches = _switches_with_lights(
self.hass,
[eid],
expand_light_groups=False,
)
for switch in switches:
# Preserve tracking for a lone profile, including when off.
# Shared lights notify each enabled owner using its takeover policy.
if switch.is_on or len(switches) == 1:
await self.update_manually_controlled_from_event(
switch,
eid,
force=False,
)
timer = self.auto_reset_manual_control_timers.get(eid)
if (

View file

@ -25,6 +25,16 @@ logger:
After the issue occurs, create a new issue report with the log (`/config/home-assistant.log`).
For support, use Home Assistant's **Download diagnostics** action on the
Adaptive Lighting config entry. The download is an on-demand snapshot of the
profile's current switches and currently tracked light targets. It does not
refresh group membership or predict targets a disabled profile would use after
being enabled. It does not create live sensors; existing switch attributes
remain the interface for automations.
The reported last adaptation values are the shared manager's latest retained
value for each attribute. They can come from different commands and do not
represent one sent command or the current desired state.
<!-- OUTPUT:END -->
## Common Problems & Solutions

372
tests/test_diagnostics.py Normal file
View 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}

View file

@ -40,6 +40,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,
@ -1760,6 +1761,154 @@ 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
)
assert [event.data[SWITCH_DOMAIN] for event in events] == [
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)
@ -4969,3 +5118,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