Add manual_control_on_external_turn_on option (#1490)

* feat: add `adapt_only_on_ha_turn_on` to skip adapting externally turned-on lights

When a light turns on from `off` via a source outside Home Assistant — a
physical wall switch or a hub/manufacturer scene (e.g. Lutron) — and
`detect_non_ha_changes` is enabled, Adaptive Lighting adapts the light on the
resulting `off` → `on` event, overriding the brightness/color the external
source just set. Disabling `detect_non_ha_changes` avoids this but also stops
detection of manual changes to already-on lights; the two behaviors were
coupled to a single flag.

Add `adapt_only_on_ha_turn_on` (default `false`, requires `take_over_control`).
When enabled, an `off` → `on` transition with no matching HA `light.turn_on`
context is marked `manual_control` and left untouched, independent of
`detect_non_ha_changes`, decoupling the two behaviors.

The off→on guard reduces to the previous expression when the option is `false`,
so existing configurations are unaffected. Includes a parametrized regression
test, docs, and regenerated strings/services/README via
scripts/update-generated-content.

Refs #435

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Shorten generated turn-on option description

* Document shared turn-on policy limitations

* Name external turn-on policy after manual-control behavior

* Clarify settings needed to adapt unmatched turn-ons

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Bas Nijholt <bas@nijho.lt>
This commit is contained in:
Alistair Galbraith 2026-09-06 12:02:30 -07:00 committed by GitHub
commit 9e29a21197
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 235 additions and 7 deletions

View file

@ -12,8 +12,10 @@ except ImportError:
from homeassistant.components.adaptive_lighting.const import (
BASIC_OPTIONS,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
CONF_SUNRISE_TIME,
CONF_SUNSET_TIME,
DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
DEFAULT_NAME,
DOMAIN,
NONE_STR,
@ -149,6 +151,10 @@ async def test_options_schema_has_each_setting_once(hass):
advanced = _advanced_section(result)
assert advanced.options == {"collapsed": True}
assert (
_schema_defaults(advanced.schema)[CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON]
is DEFAULT_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON
)
assert {key.schema for key in schema if key.schema != "advanced"} == BASIC_OPTIONS
assert {key.schema for key in advanced.schema.schema} == set(
DEFAULT_DATA,

View file

@ -39,6 +39,7 @@ from homeassistant.components.adaptive_lighting.const import (
CONF_DETECT_NON_HA_CHANGES,
CONF_INITIAL_TRANSITION,
CONF_MANUAL_CONTROL,
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON,
CONF_MAX_BRIGHTNESS,
CONF_MAX_COLOR_TEMP,
CONF_MIN_BRIGHTNESS,
@ -4473,6 +4474,152 @@ async def test_automation_turn_on_from_off_not_marked_as_manual_control(hass):
)
@pytest.mark.parametrize("intercept", [True, False])
async def test_manual_control_on_external_turn_on_allows_tracked_service_call(
hass,
intercept,
):
"""Test a real HA turn-on remains eligible for initial adaptation."""
switch, _ = await setup_lights_and_switch(
hass,
{
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON: True,
CONF_DETECT_NON_HA_CHANGES: True,
CONF_INTERCEPT: intercept,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
},
)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1},
blocking=True,
)
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: ENTITY_LIGHT_1, ATTR_BRIGHTNESS: 200},
blocking=True,
context=Context(id=f"ha_turn_on_{intercept}"),
)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_LIGHT_1)
assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 128
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.NONE
)
@pytest.mark.parametrize("intercept", [True, False])
@pytest.mark.parametrize(
(
"manual_control_on_external_turn_on",
"detect_non_ha_changes",
"expected_manual_control",
"expected_adaptation",
),
[
(True, True, LightControlAttributes.ALL, False),
(True, False, LightControlAttributes.ALL, False),
(False, True, LightControlAttributes.NONE, True),
(False, False, LightControlAttributes.ALL, False),
],
)
async def test_manual_control_on_external_turn_on_external_state_change(
hass,
freezer,
intercept,
manual_control_on_external_turn_on,
detect_non_ha_changes,
expected_manual_control,
expected_adaptation,
):
"""Test an unmatched off-to-on state event follows the opt-in policy."""
switch, _ = await setup_lights_and_switch(
hass,
{
"manual_control_on_external_turn_on": manual_control_on_external_turn_on,
CONF_DETECT_NON_HA_CHANGES: detect_non_ha_changes,
CONF_INTERCEPT: intercept,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
},
)
external_attributes = dict(hass.states.get(ENTITY_LIGHT_1).attributes)
external_attributes[ATTR_BRIGHTNESS] = 200
hass.states.async_set(
ENTITY_LIGHT_1,
STATE_OFF,
external_attributes,
context=Context(id=f"unmatched_turn_off_{intercept}"),
)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_LIGHT_1).state == STATE_OFF
freezer.tick(6)
hass.states.async_set(
ENTITY_LIGHT_1,
STATE_ON,
external_attributes,
context=Context(id=f"unmatched_turn_on_{intercept}"),
)
await hass.async_block_till_done()
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== expected_manual_control
)
last_service_data = switch.manager.last_service_data.get(ENTITY_LIGHT_1)
if expected_adaptation:
assert last_service_data[ATTR_BRIGHTNESS] == 128
else:
assert last_service_data is None
assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200
@pytest.mark.parametrize("intercept", [True, False])
async def test_manual_control_on_external_turn_on_keeps_non_ha_change_detection(
hass,
intercept,
):
"""Test the option does not disable manual tracking for an on light."""
switch, (light, *_) = await setup_lights_and_switch(
hass,
{
CONF_MANUAL_CONTROL_ON_EXTERNAL_TURN_ON: True,
CONF_DETECT_NON_HA_CHANGES: True,
CONF_INTERCEPT: intercept,
CONF_MIN_BRIGHTNESS: 50,
CONF_MAX_BRIGHTNESS: 50,
},
)
await switch._update_attrs_and_maybe_adapt_lights(
context=switch.create_context("test"),
transition=0,
)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 128
set_light_brightness(light, 200)
light.async_write_ha_state()
await switch._update_attrs_and_maybe_adapt_lights(
context=switch.create_context("test"),
transition=0,
)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_LIGHT_1).attributes[ATTR_BRIGHTNESS] == 200
assert (
switch.manager.get_manual_control_attributes(ENTITY_LIGHT_1)
== LightControlAttributes.BRIGHTNESS
)
@pytest.mark.parametrize("intercept", [True, False])
async def test_adapt_only_on_bare_turn_on_respects_pause_changed_mode(hass, intercept):
"""Test that adapt_only_on_bare_turn_on respects take_over_control_mode=PAUSE_CHANGED.