diff --git a/README.md b/README.md index dff1f89c..1f2065c7 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,8 @@ The YAML and frontend configuration methods support all of the options listed be | `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` | | `skip_redundant_commands` | Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. ๐Ÿ“‰Disable if physical light states get out of sync with HA's recorded state. | `False` | `bool` | -| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. โž—โš ๏ธ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. | `True` | `bool` | +| `intercept` | Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. ๐ŸŽ๏ธ Disable for lights that do not support `light.turn_on` with color and brightness. | `True` | `bool` | +| `multi_light_intercept` | Intercept and adapt `light.turn_on` calls that target multiple lights. โž—โš ๏ธ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled. | `True` | `bool` | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. ๐Ÿ“ | `False` | `bool` | diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index c747528d..cf93cee6 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -231,6 +231,13 @@ DOCS[CONF_SKIP_REDUNDANT_COMMANDS] = ( "Disable if physical light states get out of sync with HA's recorded state." ) +CONF_INTERCEPT, DEFAULT_INTERCEPT = "intercept", True +DOCS[CONF_INTERCEPT] = ( + "Intercept and adapt `light.turn_on` calls to enabling instantaneous color " + "and brightness adaptation. ๐ŸŽ๏ธ Disable for lights that do not " + "support `light.turn_on` with color and brightness." +) + CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT = ( "multi_light_intercept", True, @@ -238,7 +245,8 @@ CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT = ( DOCS[CONF_MULTI_LIGHT_INTERCEPT] = ( "Intercept and adapt `light.turn_on` calls that target multiple lights. โž—" "โš ๏ธ This might result in splitting up a single `light.turn_on` call " - "into multiple calls, e.g., when lights are in different switches." + "into multiple calls, e.g., when lights are in different switches. " + "Requires `intercept` to be enabled." ) SLEEP_MODE_SWITCH = "sleep_mode_switch" @@ -356,6 +364,7 @@ VALIDATION_TUPLES = [ DEFAULT_SKIP_REDUNDANT_COMMANDS, bool, ), + (CONF_INTERCEPT, DEFAULT_INTERCEPT, bool), (CONF_MULTI_LIGHT_INTERCEPT, DEFAULT_MULTI_LIGHT_INTERCEPT, bool), (CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES, bool), ] diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 5733bfd5..f6f6890b 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -54,7 +54,8 @@ "send_split_delay": "send_split_delay", "adapt_delay": "adapt_delay", "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. ๐Ÿ“‰Disable if physical light states get out of sync with HA's recorded state.", - "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. โž—โš ๏ธ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches.", + "intercept": "intercept: Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. ๐ŸŽ๏ธ Disable for lights that do not support `light.turn_on` with color and brightness.", + "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. โž—โš ๏ธ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled.", "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. ๐Ÿ“" }, "data_description": { diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 150f2ef4..4d66cc3d 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -107,6 +107,7 @@ from .const import ( CONF_DETECT_NON_HA_CHANGES, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, + CONF_INTERCEPT, CONF_INTERVAL, CONF_LIGHTS, CONF_MANUAL_CONTROL, @@ -180,11 +181,6 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) -# A (non-user-configurable, thus internal) flag to control the proactive adaptation mode. -# This exists to disable the proactive adaptation in the unit tests and enable it -# only for specific unit tests and when running as integration.""" -INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION = "proactive_adaptation" - # Consider it a significant change when attribute changes more than BRIGHTNESS_CHANGE = 25 # โ‰ˆ10% of total range COLOR_TEMP_CHANGE = 100 # โ‰ˆ3% of total range (2000-6500) @@ -427,7 +423,7 @@ async def async_setup_entry( # noqa: PLR0915 return if (manager := data.get(ATTR_ADAPTIVE_LIGHTING_MANAGER)) is None: - manager = AdaptiveLightingManager(hass, config_entry) + manager = AdaptiveLightingManager(hass) data[ATTR_ADAPTIVE_LIGHTING_MANAGER] = manager sleep_mode_switch = SimpleSwitch( @@ -882,7 +878,16 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._adapt_only_on_bare_turn_on = data[CONF_ADAPT_ONLY_ON_BARE_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] self._multi_light_intercept = data[CONF_MULTI_LIGHT_INTERCEPT] + if not data[CONF_INTERCEPT] and data[CONF_MULTI_LIGHT_INTERCEPT]: + _LOGGER.warning( + "%s: Config mismatch: `multi_light_intercept` set to `true` requires `intercept`" + " to be enabled. Adjusting config and continuing setup with" + " `multi_light_intercept: false`.", + self._name, + ) + self._multi_light_intercept = False self._expand_light_groups() # updates manual control timers location, _ = get_astral_location(self.hass) @@ -1603,11 +1608,10 @@ class SimpleSwitch(SwitchEntity, RestoreEntity): class AdaptiveLightingManager: """Track 'light.turn_off' and 'light.turn_on' service calls.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the AdaptiveLightingManager that is shared among all switches.""" assert hass is not None self.hass = hass - data = validate(config_entry) self.lights: set[str] = set() # Tracks 'light.turn_off' service calls @@ -1658,38 +1662,32 @@ class AdaptiveLightingManager: self._proactively_adapting_contexts: dict[str, str] = {} - is_proactive_adaptation_enabled = data.get( - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION, - True, - ) + try: + self.listener_removers.append( + setup_service_call_interceptor( + hass, + LIGHT_DOMAIN, + SERVICE_TURN_ON, + self._service_interceptor_turn_on_handler, + ), + ) - if is_proactive_adaptation_enabled: - try: - self.listener_removers.append( - setup_service_call_interceptor( - hass, - LIGHT_DOMAIN, - SERVICE_TURN_ON, - self._service_interceptor_turn_on_handler, - ), - ) + self.listener_removers.append( + setup_service_call_interceptor( + hass, + LIGHT_DOMAIN, + SERVICE_TOGGLE, + self._service_interceptor_turn_on_handler, + ), + ) - self.listener_removers.append( - setup_service_call_interceptor( - hass, - LIGHT_DOMAIN, - SERVICE_TOGGLE, - self._service_interceptor_turn_on_handler, - ), - ) - - _LOGGER.debug("Proactive adaptation enabled") - except RuntimeError: - _LOGGER.warning( - "Failed to set up service call interceptors, " - "falling back to event-reactive mode", - exc_info=True, - ) + _LOGGER.debug("Proactive adaptation enabled") + except RuntimeError: + _LOGGER.warning( + "Failed to set up service call interceptors, " + "falling back to event-reactive mode", + exc_info=True, + ) def disable(self): """Disable the listener by removing all subscribed handlers.""" @@ -1812,6 +1810,7 @@ class AdaptiveLightingManager: else: if ( not switch.is_on + or not switch._intercept # Never adapt on light groups, because HA will make a separate light.turn_on or _is_light_group(self.hass.states.get(entity_id)) # Prevent adaptation of TURN_ON calls when light is already on, @@ -1829,12 +1828,13 @@ class AdaptiveLightingManager: ): _LOGGER.debug( "Switch is off or light is already on for entity_id='%s', skipped='%s'" - " (is_on='%s', is_state='%s', manual_control='%s')", + " (is_on='%s', is_state='%s', manual_control='%s', switch._intercept='%s')", entity_id, skipped, switch.is_on, self.hass.states.is_state(entity_id, STATE_ON), self.manual_control.get(entity_id, False), + switch._intercept, ) skipped.append(entity_id) else: diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index eaf28798..f55a6d88 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -55,7 +55,8 @@ "send_split_delay": "send_split_delay", "adapt_delay": "adapt_delay", "skip_redundant_commands": "skip_redundant_commands: Skip sending adaptation commands whose target state already equals the light's known state. Minimizes network traffic and improves the adaptation responsivity in some situations. ๐Ÿ“‰Disable if physical light states get out of sync with HA's recorded state.", - "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. โž—โš ๏ธ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches.", + "intercept": "intercept: Intercept and adapt `light.turn_on` calls to enabling instantaneous color and brightness adaptation. ๐ŸŽ๏ธ Disable for lights that do not support `light.turn_on` with color and brightness.", + "multi_light_intercept": "multi_light_intercept: Intercept and adapt `light.turn_on` calls that target multiple lights. โž—โš ๏ธ This might result in splitting up a single `light.turn_on` call into multiple calls, e.g., when lights are in different switches. Requires `intercept` to be enabled.", "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. ๐Ÿ“" }, "data_description": { diff --git a/tests/test_switch.py b/tests/test_switch.py index 4744f878..018c8965 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -92,7 +92,7 @@ from custom_components.adaptive_lighting.const import ( UNDO_UPDATE_LISTENER, ) from custom_components.adaptive_lighting.switch import ( - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION, + CONF_INTERCEPT, AdaptiveSwitch, _attributes_have_changed, color_difference_redmean, @@ -167,7 +167,7 @@ async def setup_switch(hass, extra_data) -> tuple[MockConfigEntry, AdaptiveSwitc domain=DOMAIN, data={ CONF_NAME: DEFAULT_NAME, - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: False, + CONF_INTERCEPT: False, **extra_data, }, ) @@ -579,7 +579,7 @@ async def test_manual_control( hass, { CONF_ADAPT_ONLY_ON_BARE_TURN_ON: adapt_only_on_bare_turn_on, - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: proactive_service_call_adaptation, + CONF_INTERCEPT: proactive_service_call_adaptation, }, ) assert switch._take_over_control @@ -1470,9 +1470,7 @@ def _mock_sun_light_settings(switch: AdaptiveSwitch, settings: dict[str, Any]): async def test_proactive_adaptation(hass): """Validate that a proactive adaptation updates the original service call.""" - switch, _ = await setup_lights_and_switch( - hass, {INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: True}, True - ) + switch, _ = await setup_lights_and_switch(hass, {CONF_INTERCEPT: True}, True) _mock_sun_light_settings( switch, @@ -1503,7 +1501,7 @@ async def test_proactive_adaptation_with_separate_commands(hass): switch, _ = await setup_lights_and_switch( hass, { - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: True, + CONF_INTERCEPT: True, CONF_SEPARATE_TURN_ON_COMMANDS: True, }, True, @@ -1539,9 +1537,7 @@ async def test_proactive_adaptation_toggle(hass): This test is based on the fact that contexts of proactive adaptations are recorded. """ - switch, _ = await setup_lights_and_switch( - hass, {INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: True}, True - ) + switch, _ = await setup_lights_and_switch(hass, {CONF_INTERCEPT: True}, True) # Toggle ON await hass.services.async_call( @@ -1571,7 +1567,7 @@ async def test_proactive_adaptation_transition_override(hass): switch, (_, _, light3) = await setup_lights_and_switch( hass, { - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: True, + CONF_INTERCEPT: True, CONF_INITIAL_TRANSITION: 123, }, True, @@ -1628,7 +1624,7 @@ async def setup_proactive_multiple_lights_two_switches(hass): CONF_DETECT_NON_HA_CHANGES: True, CONF_PREFER_RGB_COLOR: False, CONF_MIN_COLOR_TEMP: 2500, # to not coincide with sleep_color_temp} - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: True, + CONF_INTERCEPT: True, } _, switch1 = await setup_switch( hass, {CONF_NAME: "switch1", CONF_LIGHTS: [ENTITY_LIGHT_1], **defaults} @@ -1753,7 +1749,7 @@ async def test_two_switches_for_single_light(hass): One switch for brightness and another for color. """ - extra_conf = {INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: True} + extra_conf = {CONF_INTERCEPT: True} switch1, (light1, *_) = await setup_lights_and_switch( hass, extra_conf | {CONF_NAME: "switch1"}, all_lights=True ) @@ -1928,7 +1924,7 @@ async def test_light_group( hass, { CONF_LIGHTS: entity_ids, - INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION: proactive_service_call_adaptation, + CONF_INTERCEPT: proactive_service_call_adaptation, CONF_TAKE_OVER_CONTROL: take_over_control, CONF_MULTI_LIGHT_INTERCEPT: multi_light_intercept, },