Add on-demand Home Assistant diagnostics (#1575)

* Add privacy-safe config entry diagnostics

* Clarify accumulated diagnostics values
This commit is contained in:
Bas Nijholt 2026-09-06 19:11:55 +02:00 committed by GitHub
commit 3231a1ac27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 523 additions and 0 deletions

View file

@ -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 -->

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

@ -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}