mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-12 06:44:04 +02:00
Refresh contributor credit against main
This commit is contained in:
commit
9875885d23
7 changed files with 800 additions and 17 deletions
|
|
@ -1524,6 +1524,42 @@
|
|||
"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",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/6741938?v=4",
|
||||
"profile": "http://www.tim-maps.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "lehneres",
|
||||
"name": "lehneres",
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -1,7 +1,7 @@
|
|||
[](https://github.com/hacs/integration)
|
||||

|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
[](#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,12 @@ 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>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lehneres"><img src="https://avatars.githubusercontent.com/u/7437288?v=4?s=100" width="100px;" alt="lehneres"/><br /><sub><b>lehneres</b></sub></a><br /><a href="#ideas-lehneres" title="Ideas, Planning, & Feedback">🤔</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
|||
131
custom_components/adaptive_lighting/diagnostics.py
Normal file
131
custom_components/adaptive_lighting/diagnostics.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue