diff --git a/README.md b/README.md index a819f788..1c72e95e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ The YAML and frontend configuration methods support all of the options listed be | `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | | `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | `False` | `bool` | | `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | +| `adapt_only_on_ha_turn_on` | When a light turns on from `off`, only adapt it if Home Assistant issued the `light.turn_on`; lights turned on by a physical switch or an external scene (e.g. Lutron) are left untouched. Unlike disabling `detect_non_ha_changes`, this still detects manual changes to already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | | `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | | `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | @@ -387,6 +388,8 @@ Addressing these issues will significantly improve your Home Assistant experienc In case lights are suddenly turning on by themselves, this is most likely due to the light incorrectly reporting an "on" state to Home Assistant, leading to an undesired Adaptive Lighting action. To prevent adapting in cases *where the state of the light is suddenly "on" and only adapt if there is an associated `light.turn_on` service call*, set `detect_non_ha_changes: false`. +If turning a light on from *off* with a physical wall switch or a hub/manufacturer scene (e.g., Lutron, CasΓ©ta) makes Adaptive Lighting immediately override the brightness/color that source set β€” forcing you to set it a second time β€” and you still want to keep `detect_non_ha_changes: true` to catch manual changes to already-on lights, set `adapt_only_on_ha_turn_on: true`. Adaptive Lighting will then leave externally turned-on lights untouched (marking them `manual_control`) while continuing to adapt lights turned on through Home Assistant. + #### :signal_strength: WiFi Networks Ensure your light bulbs have a strong WiFi connection. If the signal strength is less than -70dBm, the connection may be weak and prone to dropping messages. diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 502318f0..800cdaec 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -100,6 +100,17 @@ DOCS[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] = ( "Needs `take_over_control` enabled. πŸ•΅οΈ" ) +CONF_ADAPT_ONLY_ON_HA_TURN_ON, DEFAULT_ADAPT_ONLY_ON_HA_TURN_ON = ( + "adapt_only_on_ha_turn_on", + False, +) +DOCS[CONF_ADAPT_ONLY_ON_HA_TURN_ON] = ( + "When a light turns on from `off`, only adapt it if Home Assistant issued the " + "`light.turn_on`; lights turned on by a physical switch or an external scene " + "(e.g. Lutron) are left untouched. Unlike disabling `detect_non_ha_changes`, this " + "still detects manual changes to already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ" +) + CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False DOCS[CONF_PREFER_RGB_COLOR] = ( "Whether to prefer RGB color adjustment over " @@ -394,6 +405,7 @@ VALIDATION_TUPLES: list[tuple[str, Any, Any]] = [ ), (CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE, bool), (CONF_ADAPT_ONLY_ON_BARE_TURN_ON, DEFAULT_ADAPT_ONLY_ON_BARE_TURN_ON, bool), + (CONF_ADAPT_ONLY_ON_HA_TURN_ON, DEFAULT_ADAPT_ONLY_ON_HA_TURN_ON, bool), (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float), diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index cf816d4f..a0007cac 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -58,6 +58,7 @@ "autoreset_control_seconds": "autoreset_control_seconds", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„", "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. πŸ•΅οΈ", + "adapt_only_on_ha_turn_on": "adapt_only_on_ha_turn_on: When a light turns on from `off`, only adapt it if Home Assistant issued the `light.turn_on`; lights turned on by a physical switch or an external scene (e.g. Lutron) are left untouched. Unlike disabling `detect_non_ha_changes`, this still detects manual changes to already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ", "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€", "send_split_delay": "send_split_delay", "adapt_delay": "adapt_delay", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3b042702..1559d0c4 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -90,6 +90,7 @@ from .const import ( ATTR_ADAPTIVE_LIGHTING_MANAGER, CONF_ADAPT_DELAY, CONF_ADAPT_ONLY_ON_BARE_TURN_ON, + CONF_ADAPT_ONLY_ON_HA_TURN_ON, CONF_ADAPT_UNTIL_SLEEP, CONF_AUTORESET_CONTROL, CONF_BRIGHTNESS_MODE, @@ -929,12 +930,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] self._take_over_control = data[CONF_TAKE_OVER_CONTROL] if not data[CONF_TAKE_OVER_CONTROL] and ( - data[CONF_DETECT_NON_HA_CHANGES] or data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] + data[CONF_DETECT_NON_HA_CHANGES] + or data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] + or data[CONF_ADAPT_ONLY_ON_HA_TURN_ON] ): _LOGGER.warning( - "%s: Config mismatch: `detect_non_ha_changes` or `adapt_only_on_bare_turn_on` " - "set to `true` requires `take_over_control` to be enabled. Adjusting config " - "and continuing setup with `take_over_control: true`.", + "%s: Config mismatch: `detect_non_ha_changes`, `adapt_only_on_bare_turn_on`, " + "or `adapt_only_on_ha_turn_on` set to `true` requires `take_over_control` to be " + "enabled. Adjusting config and continuing setup with `take_over_control: true`.", self._name, ) self._take_over_control = True @@ -943,6 +946,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] self._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_TURN_ON] + self._adapt_only_on_ha_turn_on = data[CONF_ADAPT_ONLY_ON_HA_TURN_ON] self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] self._skip_redundant_commands = data[CONF_SKIP_REDUNDANT_COMMANDS] self._intercept = data[CONF_INTERCEPT] @@ -1530,16 +1534,24 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) if ( self._take_over_control - and not self._detect_non_ha_changes + and (not self._detect_non_ha_changes or self._adapt_only_on_ha_turn_on) and not from_turn_on ): # There is an edge case where 2 switches control the same light, e.g., # one for brightness and one for color. Now we will mark both switches # as manually controlled, which is not 100% correct. + # + # This 'off' β†’ 'on' event was not caused by an HA `light.turn_on` call, so + # it comes from an external source (a physical switch or a hub/manufacturer + # scene like Lutron). We hand control over and skip adaptation when either: + # - `detect_non_ha_changes` is False (we can't reliably track manual changes + # to already-on lights anyway), or + # - `adapt_only_on_ha_turn_on` is True (the user explicitly wants external + # turn-ons left untouched, even while `detect_non_ha_changes` is enabled). _LOGGER.debug( "%s: Ignoring 'off' β†’ 'on' event for '%s' with context.id='%s'" " because 'light.turn_on' was not called by HA and" - " 'detect_non_ha_changes' is False", + " ('detect_non_ha_changes' is False or 'adapt_only_on_ha_turn_on' is True)", self._name, entity_id, event.context.id, diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 43bb2105..23323fb4 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -59,6 +59,7 @@ "autoreset_control_seconds": "autoreset_control_seconds", "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„", "adapt_only_on_bare_turn_on": "adapt_only_on_bare_turn_on: When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. πŸ•΅οΈ", + "adapt_only_on_ha_turn_on": "adapt_only_on_ha_turn_on: When a light turns on from `off`, only adapt it if Home Assistant issued the `light.turn_on`; lights turned on by a physical switch or an external scene (e.g. Lutron) are left untouched. Unlike disabling `detect_non_ha_changes`, this still detects manual changes to already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ", "separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€", "send_split_delay": "send_split_delay", "adapt_delay": "adapt_delay", diff --git a/docs/advanced/manual-control.md b/docs/advanced/manual-control.md index 36720f3c..1535ec3f 100644 --- a/docs/advanced/manual-control.md +++ b/docs/advanced/manual-control.md @@ -98,6 +98,24 @@ adaptive_lighting: adapt_only_on_bare_turn_on: true ``` +### adapt_only_on_ha_turn_on + +When enabled, a light that turns on from `off` is only adapted if the turn-on came from a Home Assistant `light.turn_on` call. Lights turned on by an external source β€” a physical wall switch or a hub/manufacturer scene (such as a Lutron keypad or Pico scene) β€” are marked as manually controlled and left at whatever brightness/color that source set, instead of being immediately overridden by Adaptive Lighting. + +This is the option to reach for when turning a light on with its local switch (or a native Lutron/CasΓ©ta scene) makes Adaptive Lighting override your brightness a moment later, forcing you to set it twice. + +Its advantage over simply disabling `detect_non_ha_changes` is that the two behaviors are decoupled: you can keep `detect_non_ha_changes: true` to catch manual dimming of lights that are *already on*, while still leaving *externally turned-on* lights untouched. + +```yaml +adaptive_lighting: + - name: "Respect physical switches and Lutron scenes" + lights: + - light.living_room + take_over_control: true + detect_non_ha_changes: true # still catch manual changes to already-on lights + adapt_only_on_ha_turn_on: true # but don't override external offβ†’on turn-ons +``` + ## Checking Manual Control Status You can see which lights are marked as manually controlled by checking the switch attributes: diff --git a/docs/configuration.md b/docs/configuration.md index 21c7028e..bcf4b591 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,6 +72,7 @@ All configuration options are listed below with their default values. These opti | `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-31536000 | | `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | `False` | `bool` | | `adapt_only_on_bare_turn_on` | When turning lights on initially. If set to `true`, AL adapts only if `light.turn_on` is invoked without specifying color or brightness. ❌🌈 This e.g., prevents adaptation when activating a scene and marks the light as manually controlled. If `false`, AL adapts regardless of the presence of color or brightness in the initial `service_data`. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | +| `adapt_only_on_ha_turn_on` | When a light turns on from `off`, only adapt it if Home Assistant issued the `light.turn_on`; lights turned on by a physical switch or an external scene (e.g. Lutron) are left untouched. Unlike disabling `detect_non_ha_changes`, this still detects manual changes to already-on lights. Needs `take_over_control` enabled. πŸ•΅οΈ | `False` | `bool` | | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | | `send_split_delay` | Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️ | `0` | `int` 0-10000 | | `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️ | `0` | `float > 0` | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index cdff4487..60705bd2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -49,6 +49,8 @@ Addressing these issues will significantly improve your Home Assistant experienc In case lights are suddenly turning on by themselves, this is most likely due to the light incorrectly reporting an "on" state to Home Assistant, leading to an undesired Adaptive Lighting action. To prevent adapting in cases *where the state of the light is suddenly "on" and only adapt if there is an associated `light.turn_on` service call*, set `detect_non_ha_changes: false`. +If turning a light on from *off* with a physical wall switch or a hub/manufacturer scene (e.g., Lutron, CasΓ©ta) makes Adaptive Lighting immediately override the brightness/color that source set β€” forcing you to set it a second time β€” and you still want to keep `detect_non_ha_changes: true` to catch manual changes to already-on lights, set `adapt_only_on_ha_turn_on: true`. Adaptive Lighting will then leave externally turned-on lights untouched (marking them `manual_control`) while continuing to adapt lights turned on through Home Assistant. + #### :signal_strength: WiFi Networks Ensure your light bulbs have a strong WiFi connection. If the signal strength is less than -70dBm, the connection may be weak and prone to dropping messages. diff --git a/tests/test_switch.py b/tests/test_switch.py index eabe226a..d5f4e1ba 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -29,6 +29,7 @@ from homeassistant.components.adaptive_lighting.const import ( ADAPT_COLOR_SWITCH, ATTR_ADAPTIVE_LIGHTING_MANAGER, CONF_ADAPT_ONLY_ON_BARE_TURN_ON, + CONF_ADAPT_ONLY_ON_HA_TURN_ON, CONF_ADAPT_UNTIL_SLEEP, CONF_AUTORESET_CONTROL, CONF_BRIGHTNESS_MODE, @@ -3079,6 +3080,97 @@ async def test_automation_turn_on_from_off_not_marked_as_manual_control(hass): ) +@pytest.mark.parametrize("adapt_only_on_ha_turn_on", [True, False]) +async def test_adapt_only_on_ha_turn_on(hass, adapt_only_on_ha_turn_on): + """Test `adapt_only_on_ha_turn_on` with `detect_non_ha_changes` enabled. + + When a light turns on from OFF via an external source (a physical wall switch + or a hub/manufacturer scene like Lutron), Home Assistant sees the state change + but there is no matching `light.turn_on` call, so `from_turn_on` is False. + + With `detect_non_ha_changes=True`, the reactive off->on handler would normally + adapt (and thus override) such an external turn-on. `adapt_only_on_ha_turn_on` + lets the user keep manual-change detection for already-on lights while still + leaving externally turned-on lights untouched: + + - adapt_only_on_ha_turn_on=True: mark manual, do NOT adapt (hands off). + - adapt_only_on_ha_turn_on=False: adapt as before (existing behavior). + """ + switch, _ = await setup_lights_and_switch( + hass, + { + CONF_TAKE_OVER_CONTROL: True, + CONF_DETECT_NON_HA_CHANGES: True, + CONF_ADAPT_ONLY_ON_HA_TURN_ON: adapt_only_on_ha_turn_on, + }, + ) + assert switch._detect_non_ha_changes + assert switch._adapt_only_on_ha_turn_on == adapt_only_on_ha_turn_on + + # Start from a clean slate for the light. + switch.manager.manual_control.pop(ENTITY_LIGHT_1, None) + switch.manager.last_service_data.pop(ENTITY_LIGHT_1, None) + switch.manager.turn_on_event.pop(ENTITY_LIGHT_1, None) + + # Simulate the light being turned on from OFF by an external source (e.g. a + # Lutron wall switch/scene): the state is 'on' but no `light.turn_on` was + # issued by HA, so the event carries a foreign context that does not match + # any tracked turn_on_event -> `from_turn_on` is False. + hass.states.async_set( + ENTITY_LIGHT_1, + STATE_ON, + {ATTR_BRIGHTNESS: 200, ATTR_SUPPORTED_FEATURES: 1}, + ) + await hass.async_block_till_done() + + external_context = Context(id="lutron_external_turn_on") + assert not switch.manager._off_to_on_state_event_is_from_turn_on( + ENTITY_LIGHT_1, + Event( + EVENT_STATE_CHANGED, + {ATTR_ENTITY_ID: ENTITY_LIGHT_1}, + context=external_context, + ), + ) + + event = Event( + EVENT_STATE_CHANGED, + { + ATTR_ENTITY_ID: ENTITY_LIGHT_1, + "old_state": State(ENTITY_LIGHT_1, STATE_OFF), + "new_state": State( + ENTITY_LIGHT_1, + STATE_ON, + {ATTR_BRIGHTNESS: 200}, + context=external_context, + ), + }, + context=external_context, + ) + await switch._respond_to_off_to_on_event(ENTITY_LIGHT_1, event) + await hass.async_block_till_done() + + manual_control_attrs = switch.manager.manual_control.get(ENTITY_LIGHT_1) + last_service_data = switch.manager.last_service_data.get(ENTITY_LIGHT_1) + + if adapt_only_on_ha_turn_on: + # Hands off: mark manual and skip adaptation entirely. + assert manual_control_attrs, ( + "With adapt_only_on_ha_turn_on=True, an external off->on turn-on should " + f"be marked as manually controlled, but got: {manual_control_attrs}." + ) + assert last_service_data is None, ( + "With adapt_only_on_ha_turn_on=True, Adaptive Lighting should not adapt " + f"an externally turned-on light, but last_service_data={last_service_data}." + ) + else: + # Existing behavior: adapt the light (overriding the external brightness). + assert last_service_data is not None, ( + "With adapt_only_on_ha_turn_on=False, Adaptive Lighting should still adapt " + "the light on an external off->on turn-on (existing behavior)." + ) + + @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.