diff --git a/.all-contributorsrc b/.all-contributorsrc
index ddf01f78..260b8410 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1524,6 +1524,24 @@
"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",
diff --git a/README.md b/README.md
index b594905d..e56098eb 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
[](https://github.com/hacs/integration)

-[](#contributors-)
+[](#contributors-)
# 🌞 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.
@@ -926,6 +936,10 @@ Notice the values of `brightness_mode_time_light` and `brightness_mode_time_dark
 Wosten 🐛 |
 Zachary Priddy 🤔 |
 Andrew Blakeslee Moore 🐛 |
+  jaynis 💻 |
+
+
+  Alistair Galbraith 💻 |
 Leonhard Hesse 💻 |
diff --git a/custom_components/adaptive_lighting/diagnostics.py b/custom_components/adaptive_lighting/diagnostics.py
new file mode 100644
index 00000000..80ae56d0
--- /dev/null
+++ b/custom_components/adaptive_lighting/diagnostics.py
@@ -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,
+ }
diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py
index f240a334..0a340c6c 100644
--- a/custom_components/adaptive_lighting/switch.py
+++ b/custom_components/adaptive_lighting/switch.py
@@ -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 (
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 170f5ddf..c5f59353 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -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.
+
## Common Problems & Solutions
diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py
new file mode 100644
index 00000000..9c96b295
--- /dev/null
+++ b/tests/test_diagnostics.py
@@ -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}
diff --git a/tests/test_switch.py b/tests/test_switch.py
index 03f0066c..733b1ef3 100644
--- a/tests/test_switch.py
+++ b/tests/test_switch.py
@@ -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