From ea2a6b0173240f98e50319f8a1db26d48ee1c5b8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 2 Apr 2023 19:22:23 -0700 Subject: [PATCH 01/26] Add auto_reset_manual_control with async timer (#487) * Add auto_reset_manual_control with async timer * Add failing test * Debugging * Refactor find_switch_for_lights * Revert changes in is_manually_controlled * style * Fix test_manual_control * add types * fixes * dict * make all tests pass * text * Rework find_switch_for_lights * Style * Better check * revert, do in other test! * No need to log when raising * Add type hint * Suggestion https://github.com/basnijholt/adaptive-lighting/pull/488/files#r1152408541 by @th3w1zard1 * Small fixes * document new config everywhere (#496) * chore(docs): update TOC * undo change * fi * Update README.md * Update README.md * chore(docs): update TOC * Update README.md * Use markdown-code-runner * Remove * Use markdown-code-runner instead of packaged solution * fix comment * Only commit when needed --------- Co-authored-by: Benjamin Auquite Co-authored-by: basnijholt Co-authored-by: github-actions[bot] --- .github/workflows/update-readme.yml | 10 +- README.md | 7 +- custom_components/adaptive_lighting/const.py | 10 ++ .../adaptive_lighting/services.yaml | 6 + .../adaptive_lighting/strings.json | 3 +- custom_components/adaptive_lighting/switch.py | 122 +++++++++++++++++- tests/test_switch.py | 48 ++++++- 7 files changed, 195 insertions(+), 11 deletions(-) diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index f2a62407..4d4c4530 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -30,13 +30,21 @@ jobs: run: markdown-code-runner --debug README.md - name: Commit updated README.md + id: commit run: | git add README.md git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" - git diff --quiet && git diff --staged --quiet || git commit -m "Update README.md" + if git diff --quiet && git diff --staged --quiet; then + echo "No changes in README.md, skipping commit." + echo "commit_status=skipped" >> $GITHUB_ENV + else + git commit -m "Update README.md" + echo "commit_status=committed" >> $GITHUB_ENV + fi - name: Push changes + if: env.commit_status == 'committed' uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 378c0bb6..6c03f1f7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Adaptive Lighting provides four switches (using "living_room" as an example comp Adaptive Lighting is designed to automatically detect when you or another source (e.g., automation) manually changes light settings đŸ•šī¸. When this occurs, the affected light is marked as "manually controlled," and Adaptive Lighting will not make further adjustments until the light is turned off and back on or reset using the `adaptive_lighting.set_manual_control` service call. -This feature is available when take_over_control is enabled. +This feature is available when `take_over_control` is enabled. Additionally, enabling detect_non_ha_changes allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings. The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations 🤖. @@ -47,7 +47,7 @@ The `adaptive_lighting.manual_control` event is fired when a light is marked as - [`adaptive_lighting.change_switch_settings`](#adaptive_lightingchange_switch_settings) - [:robot: Automation examples](#robot-automation-examples) - [Additional Information](#additional-information) -- [Troubleshooting](#troubleshooting) +- [:sos: Troubleshooting](#sos-troubleshooting) - [:exclamation: Common Problems & Solutions](#exclamation-common-problems--solutions) - [:bulb: Lights Not Responding or Turning On by Themselves](#bulb-lights-not-responding-or-turning-on-by-themselves) - [:signal_strength: WiFi Networks](#signal_strength-wifi-networks) @@ -119,6 +119,7 @@ The YAML and frontend configuration methods support all of the options listed be | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` | | `send_split_delay` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. â˛ī¸ | `0` | `int` 0-10000 | | `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. â˛ī¸ | `0` | `float > 0` | +| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. â˛ī¸ | `0` | `int` 0-604800 | @@ -298,7 +299,7 @@ For more details on adding the integration and setting options, refer to the [do Adaptive Lighting was initially inspired by @claytonjn's [hass-circadian\_lighting](https://github.com/claytonjn/hass-circadian_lighting), but has since been entirely rewritten and expanded with new features. -# Troubleshooting +# :sos: Troubleshooting Encountering issues? Enable debug logging in your `configuration.yaml`: diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 421eadcc..eb29fa13 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -152,6 +152,11 @@ DOCS[CONF_SEND_SPLIT_DELAY] = ( "Helps ensure correct handling. â˛ī¸" ) +CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0 +DOCS[CONF_AUTORESET_CONTROL] = ( + "Automatically reset the manual control after a number of seconds. " + "Set to 0 to disable. â˛ī¸" +) SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" @@ -220,6 +225,11 @@ VALIDATION_TUPLES = [ (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), + ( + CONF_AUTORESET_CONTROL, + DEFAULT_AUTORESET_CONTROL, + int_between(0, 7 * 24 * 60 * 60), # 7 days max + ), ] diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index 5eb49149..373e796a 100755 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -248,3 +248,9 @@ change_switch_settings: example: 0 selector: text: + autoreset_control_seconds: + description: "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" + required: false + example: 0 + selector: + text: diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 73f70a2c..5a8ef6af 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -45,7 +45,8 @@ "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "Transition time when applying a change to the lights (seconds)", - "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." + "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering.", + "autoreset_control_seconds": "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" } } }, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index c94ab32a..5cf17122 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -97,6 +97,7 @@ from .const import ( ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, CONF_ADAPT_DELAY, + CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, @@ -265,7 +266,7 @@ def find_switch_for_lights( is_on: bool = False, ) -> AdaptiveSwitch: """Find the switch that controls the lights in 'lights'.""" - switches = _get_switches_with_lights(hass, lights, is_on) + switches = _get_switches_with_lights(hass, lights) if len(switches) == 1: return switches[0] elif len(switches) > 1: @@ -330,7 +331,7 @@ def _get_switches_from_service_call( async def handle_change_switch_settings( switch: AdaptiveSwitch, service_call: ServiceCall -): +) -> None: """Allows HASS to change config values via a service call.""" data = service_call.data @@ -473,7 +474,7 @@ async def async_setup_entry( all_lights = _expand_light_groups(switch.hass, lights) if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - switch.turn_on_off_listener.manual_control[light] = True + switch.turn_on_off_listener.mark_as_manual_control(light) _fire_manual_control_event(switch, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) @@ -820,6 +821,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._transition = data[CONF_TRANSITION] self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] _loc = get_astral_location(self.hass) if isinstance(_loc, tuple): # Astral v2.2 @@ -893,6 +895,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): def _expand_light_groups(self) -> None: all_lights = _expand_light_groups(self.hass, self._lights) self.turn_on_off_listener.lights.update(all_lights) + self.turn_on_off_listener.set_auto_reset_manual_control_times( + all_lights, self._auto_reset_manual_control_time + ) self._lights = list(all_lights) async def _setup_listeners(self, _=None) -> None: @@ -1526,6 +1531,10 @@ class TurnOnOffListener: # Track last 'service_data' to 'light.turn_on' resulting from this integration self.last_service_data: dict[str, dict[str, Any]] = {} + # Track auto reset of manual_control + self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} + self.auto_reset_manual_control_times: dict[str, float] = {} + # When a state is different `max_cnt_significant_changes` times in a row, # mark it as manually_controlled. self.max_cnt_significant_changes = 2 @@ -1537,11 +1546,71 @@ class TurnOnOffListener: EVENT_STATE_CHANGED, self.state_changed_event_listener ) + def set_auto_reset_manual_control_times(self, lights: list[str], time: float): + """Set the time after which the lights are automatically reset.""" + if time == 0: + return + for light in lights: + old_time = self.auto_reset_manual_control_times.get(light) + if (old_time is not None) and (old_time != time): + _LOGGER.info( + "Setting auto_reset_manual_control for '%s' from %s seconds to %s seconds." + " This might happen because the light is in multiple swiches" + " or because of a config change.", + light, + old_time, + time, + ) + self.auto_reset_manual_control_times[light] = time + + def mark_as_manual_control(self, light: str) -> None: + """Mark a light as manually controlled.""" + _LOGGER.debug("Marking '%s' as manually controlled.", light) + self.manual_control[light] = True + delay = self.auto_reset_manual_control_times.get(light) + timer = self.auto_reset_manual_control_timers.get(light) + if timer is not None: + if delay is None: # Timer object exists, but should not anymore + timer.cancel() + self.auto_reset_manual_control_timers.pop(light) + else: # Timer object already exists, just update the delay and restart it + timer.delay = delay + timer.start() + elif delay is not None: # Timer object does not exist, create it + + async def reset(): + self.reset(light) + switches = _get_switches_with_lights(self.hass, [light]) + for switch in switches: + if not switch.is_on: + continue + # pylint: disable=protected-access + await switch._update_attrs_and_maybe_adapt_lights( + [light], + transition=switch._initial_transition, + force=True, + context=switch.create_context("autoreset"), + ) + _LOGGER.debug( + "Auto resetting 'manual_control' status of '%s' because" + " it was not manually controlled for %s seconds.", + light, + delay, + ) + assert not self.manual_control[light] + + timer = _AsyncSingleShotTimer(delay, reset) + self.auto_reset_manual_control_timers[light] = timer + timer.start() + def reset(self, *lights, reset_manual_control=True) -> None: """Reset the 'manual_control' status of the lights.""" for light in lights: if reset_manual_control: self.manual_control[light] = False + timer = self.auto_reset_manual_control_timers.pop(light, None) + if timer is not None: + timer.cancel() self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) self.cnt_significant_changes[light] = 0 @@ -1599,6 +1668,14 @@ class TurnOnOffListener: if task is not None: task.cancel() self.turn_on_event[eid] = event + timer = self.auto_reset_manual_control_timers.get(eid) + if ( + timer is not None + and timer.is_running() + and event.time_fired > timer.start_time + ): + # Restart the auto reset timer + timer.start() async def state_changed_event_listener(self, event: Event) -> None: """Track 'state_changed' events.""" @@ -1674,7 +1751,7 @@ class TurnOnOffListener: ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manual_control = self.manual_control[light] = True + manual_control = self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" @@ -1746,7 +1823,7 @@ class TurnOnOffListener: # Only mark a light as significantly changing, if changed==True # N times in a row. We do this because sometimes a state changes # happens only *after* a new update interval has already started. - self.manual_control[light] = True + self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, context, is_async=False) else: if n_changes > 1: @@ -1857,3 +1934,38 @@ class TurnOnOffListener: # other 'off' → 'on' state switches resulting from polling. That # would mean we 'return True' here. return False + + +class _AsyncSingleShotTimer: + def __init__(self, delay, callback): + """Initialize the timer.""" + self.delay = delay + self.callback = callback + self.task = None + self.start_time: int | None = None + + async def _run(self): + """Run the timer. Don't call this directly, use start() instead.""" + self.start_time = dt_util.utcnow() + await asyncio.sleep(self.delay) + if self.callback: + if asyncio.iscoroutinefunction(self.callback): + await self.callback() + else: + self.callback() + + def is_running(self): + """Return whether the timer is running.""" + return self.task is not None and not self.task.done() + + def start(self): + """Start the timer.""" + if self.task is not None and not self.task.done(): + self.task.cancel() + self.task = asyncio.create_task(self._run()) + + def cancel(self): + """Cancel the timer.""" + if self.task: + self.task.cancel() + self.callback = None diff --git a/tests/test_switch.py b/tests/test_switch.py index 8584bb1f..5b74c94c 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -10,6 +10,7 @@ from homeassistant.components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, ATTR_TURN_ON_OFF_LISTENER, + CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, @@ -184,7 +185,7 @@ async def setup_lights_and_switch(hass, extra_conf=None): # Setup switch lights = [ - "light.bed_light", + ENTITY_LIGHT, "light.ceiling_lights", ] assert all(hass.states.get(light) is not None for light in lights) @@ -584,6 +585,50 @@ async def test_manual_control(hass): assert all([not manual_control[eid] for eid in switch._lights]) +async def test_auto_reset_manual_control(hass): + switch, (light, *_) = await setup_lights_and_switch( + hass, {CONF_AUTORESET_CONTROL: 0.1} + ) + context = switch.create_context("test") # needs to be passed to update method + manual_control = switch.turn_on_off_listener.manual_control + + async def update(): + await switch._update_attrs_and_maybe_adapt_lights(transition=0, context=context) + await hass.async_block_till_done() + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: light.entity_id, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + await update() + _LOGGER.debug( + "Turn light %s to state %s, to %s", light.entity_id, state, kwargs + ) + + _LOGGER.debug("Start test auto reset manual control") + await turn_light(True, brightness=1) + await turn_light(True, brightness=10) + assert manual_control[light.entity_id] + await asyncio.sleep(0.3) # Should be enough time for auto reset + await update() + assert not manual_control[light.entity_id], (light, manual_control) + + # Do a couple of quick changes and check that light is not reset + for i in range(3): + _LOGGER.debug("Quick change %s", i) + await turn_light(True, brightness=(i + 1) * 20) + await asyncio.sleep(0.05) # Less than 0.1 + assert manual_control[light.entity_id] + + await asyncio.sleep(0.3) # Wait the auto reset time + await update() + assert not manual_control[light.entity_id] + + async def test_apply_service(hass): """Test adaptive_lighting.apply service.""" switch, (_, _, light) = await setup_lights_and_switch(hass) @@ -734,6 +779,7 @@ async def test_significant_change(hass): assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # On next update the light should be marked as manually controlled await update(force=False) + # TODO: the state should be `bool(manual_control) is True` assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] From c915bda9b94896a8016f69436a1b4b2af7cf9432 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 01:57:12 -0500 Subject: [PATCH 02/26] Autoreset_Control_Time - small changes (#515) * small changes * Update README.md * trivial comment change --------- Co-authored-by: github-actions[bot] Co-authored-by: Bas Nijholt --- README.md | 2 +- custom_components/adaptive_lighting/const.py | 2 +- custom_components/adaptive_lighting/switch.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c03f1f7..037a6b05 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ The YAML and frontend configuration methods support all of the options listed be | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 | `False` | `bool` | | `send_split_delay` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. â˛ī¸ | `0` | `int` 0-10000 | | `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. â˛ī¸ | `0` | `float > 0` | -| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. â˛ī¸ | `0` | `int` 0-604800 | +| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. â˛ī¸ | `0` | `int` 0-31536000 | diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index eb29fa13..471d593c 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -228,7 +228,7 @@ VALIDATION_TUPLES = [ ( CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL, - int_between(0, 7 * 24 * 60 * 60), # 7 days max + int_between(0, 365 * 24 * 60 * 60), # 1 year max ), ] diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5cf17122..6527b73d 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -822,6 +822,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] + self._expand_light_groups() # updates manual control timers _loc = get_astral_location(self.hass) if isinstance(_loc, tuple): # Astral v2.2 From 9caf3048f1889129d8225a90a35384721f671830 Mon Sep 17 00:00:00 2001 From: igiannakas <59056762+igiannakas@users.noreply.github.com> Date: Mon, 3 Apr 2023 08:27:07 +0100 Subject: [PATCH 03/26] Continue to adapt color temperature down to the sleep temperature after sunset (#87) * Update switch.py Continue to adapt color temperature down to the sleep temperature after sunset. Results in a gradually warming light during the night time rather than a fixed color temperature throughout the night time. * Run pre-commit * Merge branch 'master' into pr/87 * add config option bool `adapt_until_sleep` defaulting to `false` --------- Co-authored-by: Bas Nijholt Co-authored-by: Bas Nijholt Co-authored-by: Benjamin Auquite --- README.md | 6 ++++++ custom_components/adaptive_lighting/const.py | 10 ++++++++++ custom_components/adaptive_lighting/strings.json | 1 + custom_components/adaptive_lighting/switch.py | 10 +++++++++- 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 037a6b05..c354153d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The `adaptive_lighting.manual_control` event is fired when a light is marked as - [:sunny: Sun Position](#sunny-sun-position) - [:thermometer: Color Temperature](#thermometer-color-temperature) - [:high_brightness: Brightness](#high_brightness-brightness) + - [While using `adapt_until_sleep: true`](#while-using-adapt_until_sleep-true) - [:busts_in_silhouette: Contributors](#busts_in_silhouette-contributors) @@ -95,6 +96,7 @@ The YAML and frontend configuration methods support all of the options listed be | `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s | | `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | `False` | `bool` | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | +| `adapt_until_sleep` | When `true`, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset | `False` | `bool` | | `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. â˛ī¸ | `1` | `float` 0-6553 | | `sleep_transition` | Duration of transition when 'sleep mode' is toggled. 😴 | `1` | `float` 0-6553 | | `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | @@ -364,6 +366,10 @@ These graphs were generated using the values calculated by the Adaptive Lighting #### :high_brightness: Brightness ![cl_brightness|690x130](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/5/8/58ebd994b62a8b1abfb3497a5288d923ff4e2330.PNG) +#### While using `adapt_until_sleep: true` +![image](https://user-images.githubusercontent.com/2219836/228949675-f9699624-8abc-466c-bb04-250ce0f495b8.png) + + ## :busts_in_silhouette: Contributors diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 471d593c..a5a5f2c1 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -140,6 +140,15 @@ DOCS[CONF_TAKE_OVER_CONTROL] = ( CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 DOCS[CONF_TRANSITION] = "Duration of transition when lights change, in seconds. 🕑" +CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP = ( + "transition_until_sleep", + False, +) +DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( + "When checked, Adaptive Lighting will use the sleep settings as the minimum," + " and transition to these values past the sunset" +) + CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 DOCS[CONF_ADAPT_DELAY] = ( "Wait time (seconds) between light turn on and Adaptive Lighting applying " @@ -190,6 +199,7 @@ VALIDATION_TUPLES = [ (CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION), (CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION), (CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION), + (CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool), (CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int), (CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)), (CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)), diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 5a8ef6af..2ebd96d0 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -22,6 +22,7 @@ "lights": "lights", "initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)", "include_config_in_attributes": "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)", + "adapt_until_sleep": "adapt_until_sleep: When checked, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset (default: false)", "sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)", "interval": "interval: Time between switch updates. (seconds)", "max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)", diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 6527b73d..75361965 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -97,6 +97,7 @@ from .const import ( ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, CONF_ADAPT_DELAY, + CONF_ADAPT_UNTIL_SLEEP, CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, @@ -834,6 +835,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._sun_light_settings = SunLightSettings( name=self._name, astral_location=location, + adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP], max_brightness=data[CONF_MAX_BRIGHTNESS], max_color_temp=data[CONF_MAX_COLOR_TEMP], min_brightness=data[CONF_MIN_BRIGHTNESS], @@ -1325,6 +1327,7 @@ class SunLightSettings: name: str astral_location: astral.Location + adapt_until_sleep: bool max_brightness: int max_color_temp: int min_brightness: int @@ -1474,7 +1477,12 @@ class SunLightSettings: delta = self.max_color_temp - self.min_color_temp ct = (delta * percent) + self.min_color_temp return 5 * round(ct / 5) # round to nearest 5 - return self.min_color_temp + if percent == 0 or not self.adapt_until_sleep: + return self.min_color_temp + if self.adapt_until_sleep and percent < 0: + delta = abs(self.min_color_temp - self.sleep_color_temp) + ct = (delta * abs(1 + percent)) + self.sleep_color_temp + return 5 * round(ct / 5) # round to nearest 5 def get_settings( self, is_sleep, transition From cdc3585a66969211a08762eb05ae36ab3f070130 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 07:58:20 +0000 Subject: [PATCH 04/26] docs: add igiannakas as a contributor for code (#517) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 063b429c..344b3112 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -429,6 +429,15 @@ "contributions": [ "code" ] + }, + { + "login": "igiannakas", + "name": "igiannakas", + "avatar_url": "https://avatars.githubusercontent.com/u/59056762?v=4", + "profile": "https://github.com/igiannakas", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index c354153d..9ebc7397 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/hacs/integration) ![Version](https://img.shields.io/github/v/release/basnijholt/adaptive-lighting?style=for-the-badge) -[![All Contributors](https://img.shields.io/badge/all_contributors-46-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-47-orange.svg?style=flat-square)](#contributors-) # 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting 🌙 @@ -436,6 +436,7 @@ These graphs were generated using the values calculated by the Adaptive Lighting Skyler Carlson
Skyler Carlson

📖 Chris
Chris

đŸ’ģ Raman Gupta
Raman Gupta

đŸ’ģ + igiannakas
igiannakas

đŸ’ģ From 19fcb1d6b9c1c0a0abcb0a88bc16b05839818726 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 00:58:39 -0700 Subject: [PATCH 05/26] Automatically generate more service tables in the README (#509) * Automatically sync more data * Automatically generate apply table * Generate manual control * Add manual control docs * Update README.md * Move docs functions to separate file * Update code * simple * no path * link paths * Fix link * missed * cd core * Update README.md * Allow alternative docs * update readme * More special * Fx * Update README.md * Remove common descriptions * Update README.md * Update README.md * Rephrase * Update README.md --------- Co-authored-by: github-actions[bot] --- .github/workflows/pytest.yaml | 8 +- .github/workflows/update-readme.yml | 7 +- README.md | 75 ++++---- .../adaptive_lighting/_docs_helpers.py | 116 ++++++++++++ custom_components/adaptive_lighting/const.py | 142 +++++++-------- .../adaptive_lighting/services.yaml | 168 +++++++++--------- custom_components/adaptive_lighting/switch.py | 28 +-- 7 files changed, 333 insertions(+), 211 deletions(-) create mode 100644 custom_components/adaptive_lighting/_docs_helpers.py diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 47d4df8a..02bf93e3 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -31,8 +31,8 @@ jobs: echo "::notice::### 4. ERROR:homeassistant.setup:Setup failed for 'component': Unable to import component: No module named ''module'' ###" echo "::notice::### 5. add 'component'.'module' (without the '') from the above log into the 'required' list inside of 'test_dependencies.py' ###" echo "::notice::### 6. Try again! If more issues persist they should be easily solvable by reading the verbose logs now. ###" - - name: Run pytest - timeout-minutes: 60 + + - name: Link custom_components/adaptive_lighting run: | cd core @@ -46,6 +46,10 @@ jobs: ln -fs ../../../tests adaptive_lighting cd - + - name: Run pytest + timeout-minutes: 60 + run: | + cd core python3 -X dev -m pytest \ -qq \ --timeout=9 \ diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index 4d4c4530..bc31bb94 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -22,10 +22,15 @@ jobs: with: python_version: "3.10" - - name: Install pandas and tabulate + - name: Install markdown-code-runner and README code dependencies run: | pip install markdown-code-runner pandas tabulate + - name: Link custom_components/adaptive_lighting + run: | + cd core/homeassistant/components + ln -fs ../../../custom_components/adaptive_lighting adaptive_lighting + - name: Run markdown-code-runner run: markdown-code-runner --debug README.md diff --git a/README.md b/README.md index 9ebc7397..5b98de8b 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,8 @@ All of the configuration options are listed below, along with their default valu The YAML and frontend configuration methods support all of the options listed below. - - - - - + + @@ -94,27 +91,27 @@ The YAML and frontend configuration methods support all of the options listed be | Variable name | Description | Default | Type | |:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| | `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s | -| `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | `False` | `bool` | +| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | -| `adapt_until_sleep` | When `true`, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset | `False` | `bool` | -| `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. â˛ī¸ | `1` | `float` 0-6553 | -| `sleep_transition` | Duration of transition when 'sleep mode' is toggled. 😴 | `1` | `float` 0-6553 | +| `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. â˛ī¸ | `1` | `float` 0-6553 | +| `sleep_transition` | Duration of transition when 'sleep mode' is toggled in seconds. 😴 | `1` | `float` 0-6553 | | `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | +| `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | | `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | | `min_brightness` | Minimum brightness percentage. 💡 | `1` | `int` 1-100 | | `max_brightness` | Maximum brightness percentage. 💡 | `100` | `int` 1-100 | | `min_color_temp` | Warmest color temperature in Kelvin. đŸ”Ĩ | `2000` | `int` 1000-10000 | | `max_color_temp` | Coldest color temperature in Kelvin. â„ī¸ | `5500` | `int` 1000-10000 | -| `sleep_brightness` | Brightness of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | | `sleep_rgb_or_color_temp` | Use either `'rgb_color'` or `'color_temp'` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | -| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`). 😴 | `1000` | `int` 1000-10000 | +| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | | `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is 'rgb_color'). 🌈 | `[255, 56, 0]` | RGB color | -| `sunrise_time` | Set a fixed time for sunrise. 🌅 | `None` | `str` | -| `max_sunrise_time` | Set the latest virtual sunrise time, allowing for earlier real sunrises. 🌅 | `None` | `str` | -| `sunrise_offset` | Adjust sunrise time with a positive or negative offset. ⏰ | `0` | `int` | -| `sunset_time` | Set a fixed time for sunset. 🌇 | `None` | `str` | -| `min_sunset_time` | Set the earliest virtual sunset time, allowing for later real sunsets. 🌇 | `None` | `str` | -| `sunset_offset` | Adjust sunset time with a positive or negative offset. ⏰ | `0` | `int` | +| `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | +| `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | +| `sunset_time` | Set a fixed time (HH:MM:SS) for sunset. 🌇 | `None` | `str` | +| `min_sunset_time` | Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 | `None` | `str` | +| `sunset_offset` | Adjust sunset time with a positive or negative offset in seconds. ⏰ | `0` | `int` | | `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 | `False` | `bool` | | `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` | | `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. đŸ•ĩī¸ | `False` | `bool` | @@ -158,26 +155,42 @@ adaptive_lighting: `adaptive_lighting.apply` applies Adaptive Lighting settings to lights on demand. -| Service data attribute | Required | Description | -| ---------------------- | -------- | -------------------------------------------------------------------------------------------- | -| `entity_id` | ✅ | The `entity_id` of the switch with the settings to apply. | -| `lights` | ❌ | A light (or list of lights) to apply the settings to. | -| `transition` | ❌ | The number of seconds for the transition. | -| `adapt_brightness` | ❌ | Whether to change the brightness of the light or not. | -| `adapt_color` | ❌ | Whether to adapt the color on supporting lights. | -| `prefer_rgb_color` | ❌ | Whether to prefer RGB color adjustment over of native light color temperature when possible. | -| `turn_on_lights` | ❌ | Whether to turn on lights that are currently off. | + + + + + + +| Service data attribute | Description | Required | Type | +|:-------------------------|:-------------------------------------------------------------------------------------|:-----------|:---------------------| +| `entity_id` | The `entity_id` of the switch with the settings to apply. 📝 | ✅ | list of `entity_id`s | +| `lights` | A light (or list of lights) to apply the settings to. 💡 | ❌ | list of `entity_id`s | +| `transition` | Duration of transition when lights change, in seconds. 🕑 | ❌ | `float` 0-6553 | +| `adapt_brightness` | Whether to adapt the brightness of the light. 🌞 | ❌ | bool | +| `adapt_color` | Whether to adapt the color on supporting lights. 🌈 | ❌ | bool | +| `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | ❌ | bool | +| `turn_on_lights` | Whether to turn on lights that are currently off. 🔆 | ❌ | bool | + + #### `adaptive_lighting.set_manual_control` `adaptive_lighting.set_manual_control` can mark (or unmark) whether a light is "manually controlled", meaning that when a light has `manual_control`, the light is not adapted. -| Service data attribute | Required | Description | -| ---------------------- | -------- | --------------------------------------------------------------------------------------------------- | -| `entity_id` | ✅ | The `entity_id` of the switch in which to (un)mark the light as being "manually controlled". | -| `lights` | ❌ | entity_id(s) of lights, if not specified, all lights in the switch are selected. | -| `manual_control` | ❌ | Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true | + + + + + + +| Service data attribute | Description | Required | Type | +|:-------------------------|:-----------------------------------------------------------------------------------------------|:-----------|:---------------------| +| `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ✅ | list of `entity_id`s | +| `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s | +| `manual_control` | Whether to add ('true') or remove ('false') the light from the 'manual_control' list. 🔒 | ❌ | bool | + + #### `adaptive_lighting.change_switch_settings` `adaptive_lighting.change_switch_settings` (new in 1.7.0) Change any of the above configuration options of Adaptive Lighting (such as `sunrise_time` or `prefer_rgb_color`) with a service call directly from your script/automation. diff --git a/custom_components/adaptive_lighting/_docs_helpers.py b/custom_components/adaptive_lighting/_docs_helpers.py new file mode 100644 index 00000000..40afc235 --- /dev/null +++ b/custom_components/adaptive_lighting/_docs_helpers.py @@ -0,0 +1,116 @@ +from typing import Any + +from homeassistant.helpers import selector +import homeassistant.helpers.config_validation as cv +import pandas as pd +import voluptuous as vol + +from .const import ( + DOCS, + DOCS_APPLY, + DOCS_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, + VALIDATION_TUPLES, + apply_service_schema, +) + + +def _format_voluptuous_instance(instance): + coerce_type = None + min_val = None + max_val = None + + for validator in instance.validators: + if isinstance(validator, vol.Coerce): + coerce_type = validator.type.__name__ + elif isinstance(validator, (vol.Clamp, vol.Range)): + min_val = validator.min + max_val = validator.max + + if min_val is not None and max_val is not None: + return f"`{coerce_type}` {min_val}-{max_val}" + elif min_val is not None: + return f"`{coerce_type} > {min_val}`" + elif max_val is not None: + return f"`{coerce_type} < {max_val}`" + else: + return f"`{coerce_type}`" + + +def _type_to_str(type_: Any) -> str: + """Convert a (voluptuous) type to a string.""" + if type_ == cv.entity_ids: + return "list of `entity_id`s" + elif type_ in (bool, int, float, str): + return f"`{type_.__name__}`" + elif type_ == cv.boolean: + return "bool" + elif isinstance(type_, vol.All): + return _format_voluptuous_instance(type_) + elif isinstance(type_, vol.In): + return f"one of `{type_.container}`" + elif isinstance(type_, selector.SelectSelector): + return f"one of `{type_.config['options']}`" + elif isinstance(type_, selector.ColorRGBSelector): + return "RGB color" + else: + raise ValueError(f"Unknown type: {type_}") + + +def generate_config_markdown_table(): + import pandas as pd + + rows = [] + for k, default, type_ in VALIDATION_TUPLES: + description = DOCS[k] + row = { + "Variable name": f"`{k}`", + "Description": description, + "Default": f"`{default}`", + "Type": _type_to_str(type_), + } + rows.append(row) + + df = pd.DataFrame(rows) + return df.to_markdown(index=False) + + +def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]: + result = {} + for key, value in schema.schema.items(): + if isinstance(key, vol.Optional): + default_value = key.default + result[key.schema] = (default_value, value) + return result + + +def _generate_service_markdown_table( + schema: dict[str, tuple[Any, Any]], alternative_docs: dict[str, str] = None +): + schema = _schema_to_dict(schema) + rows = [] + for k, (default, type_) in schema.items(): + if alternative_docs is not None and k in alternative_docs: + description = alternative_docs[k] + else: + description = DOCS[k] + row = { + "Service data attribute": f"`{k}`", + "Description": description, + "Required": "✅" if default == vol.UNDEFINED else "❌", + "Type": _type_to_str(type_), + } + rows.append(row) + + df = pd.DataFrame(rows) + return df.to_markdown(index=False) + + +def generate_apply_markdown_table(): + return _generate_service_markdown_table(apply_service_schema(), DOCS_APPLY) + + +def generate_set_manual_control_markdown_table(): + return _generate_service_markdown_table( + SET_MANUAL_CONTROL_SCHEMA, DOCS_MANUAL_CONTROL + ) diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index a5a5f2c1..bb0c07cd 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -1,6 +1,7 @@ """Constants for the Adaptive Lighting integration.""" from homeassistant.components.light import VALID_TRANSITION +from homeassistant.const import CONF_ENTITY_ID from homeassistant.helpers import selector import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -14,7 +15,7 @@ DOMAIN = "adaptive_lighting" SUN_EVENT_NOON = "solar_noon" SUN_EVENT_MIDNIGHT = "solar_midnight" -DOCS = {} +DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"} CONF_NAME, DEFAULT_NAME = "name", "default" @@ -45,11 +46,14 @@ DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = ( CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 DOCS[CONF_INITIAL_TRANSITION] = ( - "Duration of the first transition when lights turn " "from `off` to `on`. â˛ī¸" + "Duration of the first transition when lights turn " + "from `off` to `on` in seconds. â˛ī¸" ) CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1 -DOCS[CONF_SLEEP_TRANSITION] = "Duration of transition when 'sleep mode' is toggled. 😴" +DOCS[CONF_SLEEP_TRANSITION] = ( + "Duration of transition when 'sleep mode' is toggled " "in seconds. 😴" +) CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 DOCS[CONF_INTERVAL] = "Frequency to adapt the lights, in seconds. 🔄" @@ -73,9 +77,10 @@ DOCS[CONF_ONLY_ONCE] = ( ) CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False -DOCS[ - CONF_PREFER_RGB_COLOR -] = "Use RGB color adjustment instead of native light color temperature. 🌈" +DOCS[CONF_PREFER_RGB_COLOR] = ( + "Whether to prefer RGB color adjustment over " + "light color temperature when possible. 🌈" +) CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS = ( "separate_turn_on_commands", @@ -87,12 +92,12 @@ DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = ( ) CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 -DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness of lights in sleep mode. 😴" +DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness percentage of lights in sleep mode. 😴" CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 DOCS[CONF_SLEEP_COLOR_TEMP] = ( "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is " - "`color_temp`). 😴" + "`color_temp`) in Kelvin. 😴" ) CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0] @@ -104,30 +109,36 @@ CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = ( "sleep_rgb_or_color_temp", "color_temp", ) -DOCS[ - CONF_SLEEP_RGB_OR_COLOR_TEMP -] = "Use either `'rgb_color'` or `'color_temp'` in sleep mode. 🌙" +DOCS[CONF_SLEEP_RGB_OR_COLOR_TEMP] = ( + "Use either `'rgb_color'` or `'color_temp'` " "in sleep mode. 🌙" +) CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 -DOCS[CONF_SUNRISE_OFFSET] = "Adjust sunrise time with a positive or negative offset. ⏰" +DOCS[CONF_SUNRISE_OFFSET] = ( + "Adjust sunrise time with a positive or negative offset " "in seconds. ⏰" +) CONF_SUNRISE_TIME = "sunrise_time" -DOCS[CONF_SUNRISE_TIME] = "Set a fixed time for sunrise. 🌅" +DOCS[CONF_SUNRISE_TIME] = "Set a fixed time (HH:MM:SS) for sunrise. 🌅" CONF_MAX_SUNRISE_TIME = "max_sunrise_time" DOCS[CONF_MAX_SUNRISE_TIME] = ( - "Set the latest virtual sunrise time, allowing" " for earlier real sunrises. 🌅" + "Set the latest virtual sunrise time (HH:MM:SS), allowing" + " for earlier real sunrises. 🌅" ) CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 -DOCS[CONF_SUNSET_OFFSET] = "Adjust sunset time with a positive or negative offset. ⏰" +DOCS[ + CONF_SUNSET_OFFSET +] = "Adjust sunset time with a positive or negative offset in seconds. ⏰" CONF_SUNSET_TIME = "sunset_time" -DOCS[CONF_SUNSET_TIME] = "Set a fixed time for sunset. 🌇" +DOCS[CONF_SUNSET_TIME] = "Set a fixed time (HH:MM:SS) for sunset. 🌇" CONF_MIN_SUNSET_TIME = "min_sunset_time" DOCS[CONF_MIN_SUNSET_TIME] = ( - "Set the earliest virtual sunset time, allowing" " for later real sunsets. 🌇" + "Set the earliest virtual sunset time (HH:MM:SS), allowing" + " for later real sunsets. 🌇" ) CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True @@ -145,8 +156,8 @@ CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP = ( False, ) DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( - "When checked, Adaptive Lighting will use the sleep settings as the minimum," - " and transition to these values past the sunset" + "When enabled, Adaptive Lighting will treat sleep settings as the minimum, " + "transitioning to these values after sunset. 🌙" ) CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 @@ -174,18 +185,36 @@ ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener" UNDO_UPDATE_LISTENER = "undo_update_listener" NONE_STR = "None" ATTR_ADAPT_COLOR = "adapt_color" +DOCS[ATTR_ADAPT_COLOR] = "Whether to adapt the color on supporting lights. 🌈" ATTR_ADAPT_BRIGHTNESS = "adapt_brightness" +DOCS[ATTR_ADAPT_BRIGHTNESS] = "Whether to adapt the brightness of the light. 🌞" SERVICE_SET_MANUAL_CONTROL = "set_manual_control" CONF_MANUAL_CONTROL = "manual_control" +DOCS[CONF_MANUAL_CONTROL] = "Whether to manually control the lights. 🔒" SERVICE_APPLY = "apply" CONF_TURN_ON_LIGHTS = "turn_on_lights" +DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆" SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings" CONF_USE_DEFAULTS = "use_defaults" - +DOCS[CONF_USE_DEFAULTS] = "Whether to use default settings for the switches. âš™ī¸" TURNING_OFF_DELAY = 5 +DOCS_MANUAL_CONTROL = { + CONF_ENTITY_ID: "The `entity_id` of the switch in which to (un)mark the " + "light as being `manually controlled`. 📝", + CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the " + "switch are selected. 💡", + CONF_MANUAL_CONTROL: "Whether to add ('true') or remove ('false') the " + "light from the 'manual_control' list. 🔒", +} + +DOCS_APPLY = { + CONF_ENTITY_ID: "The `entity_id` of the switch with the settings to apply. 📝", + CONF_LIGHTS: "A light (or list of lights) to apply the settings to. 💡", +} + def int_between(min_int, max_int): """Return an integer between 'min_int' and 'max_int'.""" @@ -290,55 +319,28 @@ _DOMAIN_SCHEMA = vol.Schema( ) -def _format_voluptuous_instance(instance): - coerce_type = None - min_val = None - max_val = None - - for validator in instance.validators: - if isinstance(validator, vol.Coerce): - coerce_type = validator.type.__name__ - elif isinstance(validator, (vol.Clamp, vol.Range)): - min_val = validator.min - max_val = validator.max - - if min_val is not None and max_val is not None: - return f"`{coerce_type}` {min_val}-{max_val}" - elif min_val is not None: - return f"`{coerce_type} > {min_val}`" - elif max_val is not None: - return f"`{coerce_type} < {max_val}`" - else: - return f"`{coerce_type}`" - - -def generate_markdown_table(): - import pandas as pd - - rows = [] - for k, default, type_ in VALIDATION_TUPLES: - description = DOCS[k] - if type_ == cv.entity_ids: - type_ = "list of `entity_id`s" - elif type_ in (bool, int, float, str): - type_ = f"`{type_.__name__}`" - elif isinstance(type_, vol.All): - type_ = _format_voluptuous_instance(type_) - elif isinstance(type_, vol.In): - type_ = f"one of `{type_.container}`" - elif isinstance(type_, selector.SelectSelector): - type_ = f"one of `{type_.config['options']}`" - elif isinstance(type_, selector.ColorRGBSelector): - type_ = "RGB color" - else: - raise ValueError(f"Unknown type: {type_}") - row = { - "Variable name": f"`{k}`", - "Description": description, - "Default": f"`{default}`", - "Type": type_, +def apply_service_schema(initial_transition: int = 1): + """Return the schema for the apply service.""" + return vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional( + CONF_TRANSITION, + default=initial_transition, + ): VALID_TRANSITION, + vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, + vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, + vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, + vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, } - rows.append(row) + ) - df = pd.DataFrame(rows) - return df.to_markdown(index=False) + +SET_MANUAL_CONTROL_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, + vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, + } +) diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index 373e796a..89c112e3 100755 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -2,7 +2,7 @@ apply: description: Applies the current Adaptive Lighting settings to lights. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. + description: "Entity ID of the switch. \U0001F4DD" example: switch.adaptive_lighting_default selector: entity: @@ -10,43 +10,42 @@ apply: domain: switch multiple: false lights: - description: entity_id(s) of lights, if not specified, all lights in the switch are selected. + description: "List of light entities to be controlled by Adaptive Lighting (may be empty). \U0001F31F" example: light.bedroom_ceiling selector: entity: domain: light multiple: true transition: - description: Transition of the lights. + description: "Duration of transition when lights change, in seconds. \U0001F551" example: 10 selector: - text: + text: null adapt_brightness: - description: "Adapt the 'brightness', default: true" + description: "Whether to adapt the brightness of the light. \U0001F31E" example: true selector: - boolean: + boolean: null adapt_color: - description: "Adapt the color_temp/color_rgb, default: true" + description: "Whether to adapt the color of the light. \U0001F308" example: true selector: - boolean: + boolean: null prefer_rgb_color: - description: "Prefer to use color_rgb over color_temp if possible, default: false" + description: "Use RGB color adjustment instead of native light color temperature. \U0001F308" example: false selector: - boolean: + boolean: null turn_on_lights: - description: "Turn on the lights that are off, default: false" + description: "Whether to turn on lights if they are off. \U0001F506" example: false selector: - boolean: - + boolean: null set_manual_control: description: Mark whether a light is 'manually controlled'. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. + description: "Entity ID of the switch. \U0001F4DD" example: switch.adaptive_lighting_default selector: entity: @@ -54,138 +53,137 @@ set_manual_control: domain: switch multiple: false lights: - description: entity_id(s) of lights, if not specified, all lights in the switch are selected. + description: "List of light entities to be controlled by Adaptive Lighting (may be empty). \U0001F31F" example: light.bedroom_ceiling selector: entity: domain: light multiple: true manual_control: - description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" + description: "Whether to manually control the lights. \U0001F512" example: true default: true selector: - boolean: - + boolean: null change_switch_settings: - description: "Change any settings you'd like in the switch. All options here are the same as in the config flow." + description: Change any settings you'd like in the switch. All options here are the same as in the config flow. fields: entity_id: - description: "entity_id of the Adaptive Lighting switch." + description: "Entity ID of the switch. \U0001F4DD" required: true selector: entity: domain: switch use_defaults: - description: "(default: 'current' for current settings) You can set this to 'factory', 'configuration', or 'current' to reset the variables not being set with this service call. 'current' leaves them as is, 'configuration' resets to whatever already initializes at startup, 'factory' resets to the default values listed in the documentation." - example: "current" + description: "Whether to use default settings for the switches. \u2699\uFE0F" + example: current required: false - default: "current" + default: current selector: select: options: - - "current" - - "configuration" - - "factory" + - current + - configuration + - factory include_config_in_attributes: - description: "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)" + description: "Show all options as attributes on the switch in Home Assistant when set to `true`. \U0001F4DD" required: false selector: - boolean: + boolean: null turn_on_lights: - description: "Turn on the lights that are off, default: false" + description: "Whether to turn on lights if they are off. \U0001F506" example: false required: false selector: - boolean: + boolean: null initial_transition: - description: "initial_transition: When lights turn 'off' to 'on'. (seconds)" + description: "Duration of the first transition when lights turn from `off` to `on` in seconds. \u23F2\uFE0F" example: 1 required: false selector: - text: + text: null sleep_transition: - description: "sleep_transition: When 'sleep_state' changes. (seconds)" + description: "Duration of transition when 'sleep mode' is toggled in seconds. \U0001F634" example: 1 required: false selector: - text: + text: null max_brightness: - description: "max_brightness: Highest brightness of lights during a cycle. (%)" + description: "Maximum brightness percentage. \U0001F4A1" required: false example: 100 selector: - text: + text: null max_color_temp: - description: "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)" + description: "Coldest color temperature in Kelvin. \u2744\uFE0F" required: false example: 5500 selector: - text: + text: null min_brightness: - description: "min_brightness: Lowest brightness of lights during a cycle. (%)" + description: "Minimum brightness percentage. \U0001F4A1" required: false example: 1 selector: - text: + text: null min_color_temp: - description: "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)" + description: "Warmest color temperature in Kelvin. \U0001F525" required: false example: 2000 selector: - text: + text: null only_once: - description: "only_once: Only adapt the lights when turning them on." + description: "Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). \U0001F504" example: false required: false selector: - boolean: + boolean: null prefer_rgb_color: - description: "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible." + description: "Use RGB color adjustment instead of native light color temperature. \U0001F308" required: false example: false selector: - boolean: + boolean: null separate_turn_on_commands: - description: "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights)." + description: "Use separate `light.turn_on` calls for color and brightness, needed for some light types. \U0001F500" required: false example: false selector: - boolean: + boolean: null send_split_delay: - description: "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly." + description: "Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. \u23F2\uFE0F" required: false example: 0 selector: - boolean: + boolean: null sleep_brightness: - description: "sleep_brightness, Brightness setting for Sleep Mode. (%)" + description: "Brightness percentage of lights in sleep mode. \U0001F634" required: false example: 1 selector: - text: + text: null sleep_rgb_or_color_temp: - description: "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'" + description: "Use either `'rgb_color'` or `'color_temp'` in sleep mode. \U0001F319" required: false - example: "color_temp" + example: color_temp selector: select: options: - - "rgb_color" - - "color_temp" + - rgb_color + - color_temp sleep_rgb_color: - description: "sleep_rgb_color, in RGB" + description: "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is 'rgb_color'). \U0001F308" required: false selector: - color_rgb: + color_rgb: null sleep_color_temp: - description: "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)" + description: "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. \U0001F634" required: false example: 1000 selector: - text: + text: null sunrise_offset: - description: sunrise_offset, in +/- seconds (integer) + description: "Adjust sunrise time with a positive or negative offset in seconds. \u23F0" required: false example: 0 selector: @@ -193,64 +191,64 @@ change_switch_settings: min: 0 max: 86300 sunrise_time: - description: sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location) + description: "Set a fixed time (HH:MM:SS) for sunrise. \U0001F305" required: false - example: "" + example: '' selector: - time: + time: null sunset_offset: - description: sunset_offset, in +/- seconds (integer) + description: "Adjust sunset time with a positive or negative offset in seconds. \u23F0" required: false - example: "" + example: '' selector: number: min: 0 max: 86300 sunset_time: - description: sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location) - example: "" + description: "Set a fixed time (HH:MM:SS) for sunset. \U0001F307" + example: '' required: false selector: - time: + time: null max_sunrise_time: - description: "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)" - example: "" + description: "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. \U0001F305" + example: '' required: false selector: - time: + time: null min_sunset_time: - description: "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)" - example: "" + description: "Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. \U0001F307" + example: '' required: false selector: - time: + time: null take_over_control: - description: "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on." + description: "Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! \U0001F512" required: false example: true selector: - boolean: + boolean: null detect_non_ha_changes: - description: "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)" + description: "Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. \U0001F575\uFE0F" required: false example: false selector: - boolean: + boolean: null transition: - description: "Transition time when applying a change to the lights (seconds)" + description: "Duration of transition when lights change, in seconds. \U0001F551" required: false example: 45 selector: - text: + text: null adapt_delay: - description: "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." + description: "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. \u23F2\uFE0F" required: false example: 0 selector: - text: + text: null autoreset_control_seconds: - description: "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" + description: "Automatically reset the manual control after a number of seconds. Set to 0 to disable. \u23F2\uFE0F" required: false example: 0 selector: - text: + text: null diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 75361965..3e1c86db 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -39,7 +39,6 @@ from homeassistant.components.light import ( SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, - VALID_TRANSITION, is_on, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN @@ -137,11 +136,13 @@ from .const import ( SERVICE_APPLY, SERVICE_CHANGE_SWITCH_SETTINGS, SERVICE_SET_MANUAL_CONTROL, + SET_MANUAL_CONTROL_SCHEMA, SLEEP_MODE_SWITCH, SUN_EVENT_MIDNIGHT, SUN_EVENT_NOON, TURNING_OFF_DELAY, VALIDATION_TUPLES, + apply_service_schema, replace_none_str, ) @@ -495,20 +496,9 @@ async def async_setup_entry( domain=DOMAIN, service=SERVICE_APPLY, service_func=handle_apply, - schema=vol.Schema( - { - vol.Optional("entity_id"): cv.entity_ids, - vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, - vol.Optional( - CONF_TRANSITION, - default=switch._initial_transition, # pylint: disable=protected-access - ): VALID_TRANSITION, - vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean, - vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean, - vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean, - vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean, - } - ), + schema=apply_service_schema( + switch._initial_transition + ), # pylint: disable=protected-access ) # Register `set_manual_control` service @@ -516,13 +506,7 @@ async def async_setup_entry( domain=DOMAIN, service=SERVICE_SET_MANUAL_CONTROL, service_func=handle_set_manual_control, - schema=vol.Schema( - { - vol.Optional("entity_id"): cv.entity_ids, - vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids, - vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean, - } - ), + schema=SET_MANUAL_CONTROL_SCHEMA, ) args = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string} From 87391e6d24752141b1559ff65c95478b405ef185 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 01:01:58 -0700 Subject: [PATCH 06/26] Bump to 1.9.0 (#518) --- custom_components/adaptive_lighting/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 39276752..803069a7 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": [], - "version": "1.8.0" + "version": "1.9.0" } From c6a6cd323f701f356decbcca4a75a03500ca7f6b Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 03:02:06 -0500 Subject: [PATCH 07/26] Basic community fixes PR (#460) * Fixes #423 #423 * Do not adapt lights turned on with custom payloads. * Update switch.py * Issue fixes #423, #378, #403, #449 * quickly test #274 * Revert feature requests, this branch only has fixes. Reverted FR 274 * pre-commit fix * Create automerge.yaml * test * Delete automerge.yaml My bad. * Fix #460 and #408 * see @basnijholt 's comment in #450. * @basnijholt requested changes. --------- Co-authored-by: Bas Nijholt --- .../adaptive_lighting/services.yaml | 4 ---- custom_components/adaptive_lighting/switch.py | 21 ++++++++++++++++--- tests/test_switch.py | 11 ++++++++-- 3 files changed, 27 insertions(+), 9 deletions(-) mode change 100755 => 100644 custom_components/adaptive_lighting/services.yaml diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml old mode 100755 new mode 100644 index 89c112e3..351fe5b5 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -3,7 +3,6 @@ apply: fields: entity_id: description: "Entity ID of the switch. \U0001F4DD" - example: switch.adaptive_lighting_default selector: entity: integration: adaptive_lighting @@ -11,7 +10,6 @@ apply: multiple: false lights: description: "List of light entities to be controlled by Adaptive Lighting (may be empty). \U0001F31F" - example: light.bedroom_ceiling selector: entity: domain: light @@ -46,7 +44,6 @@ set_manual_control: fields: entity_id: description: "Entity ID of the switch. \U0001F4DD" - example: switch.adaptive_lighting_default selector: entity: integration: adaptive_lighting @@ -54,7 +51,6 @@ set_manual_control: multiple: false lights: description: "List of light entities to be controlled by Adaptive Lighting (may be empty). \U0001F31F" - example: light.bedroom_ceiling selector: entity: domain: light diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 3e1c86db..4b9e1856 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -582,7 +582,7 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]: def _supported_features(hass: HomeAssistant, light: str): state = hass.states.get(light) - supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported = { key for key, value in _SUPPORT_OPTS.items() if supported_features & value } @@ -1017,7 +1017,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if prefer_rgb_color is None: prefer_rgb_color = self._prefer_rgb_color - if "transition" in features: + # Check transition == 0 to fix #378 + if "transition" in features and transition > 0: service_data[ATTR_TRANSITION] = transition # The switch might be off and not have _settings set. @@ -1064,7 +1065,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) ): return - self.turn_on_off_listener.last_service_data[light] = service_data + # See #80. Doesn't check if transitions differ but it does the job. + last_service_data = self.turn_on_off_listener.last_service_data + if light in last_service_data and last_service_data[light] == service_data: + _LOGGER.debug( + "%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')", + self._name, + light, + context.id, + ) + return + else: + self.turn_on_off_listener.last_service_data[light] = service_data async def turn_on(service_data): _LOGGER.debug( @@ -1489,11 +1501,14 @@ class SunLightSettings: rgb_color: tuple[float, float, float] = color_temperature_to_rgb( color_temp_kelvin ) + # backwards compatibility for versions < 1.3.1 - see #403 + color_temp_mired: float = math.floor(1000000 / color_temp_kelvin) xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color) hs_color: tuple[float, float] = color_xy_to_hs(*xy_color) return { "brightness_pct": brightness_pct, "color_temp_kelvin": color_temp_kelvin, + "color_temp_mired": color_temp_mired, "rgb_color": rgb_color, "xy_color": xy_color, "hs_color": hs_color, diff --git a/tests/test_switch.py b/tests/test_switch.py index 5b74c94c..212cea82 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -527,11 +527,18 @@ async def test_manual_control(hass): await turn_switch(True, entity_id) assert not manual_control[ENTITY_LIGHT] + # Check that manual control is still enabled if set while bulb is off. + # Test issue #37 + await turn_light(False) + await change_manual_control(True) + await turn_light(True) + assert manual_control[ENTITY_LIGHT] + # Check that when 'adapt_brightness' is off, changing the brightness # doesn't mark it as manually controlled but changing color_temp # does - await turn_light(False) # reset manually controlled status - await turn_light(True) + await turn_light(False) + await turn_light(True) # reset manually controlled status assert not manual_control[ENTITY_LIGHT] await switch.adapt_brightness_switch.async_turn_off() await turn_light(True, brightness=increased_brightness()) From 0958feb744d503c2a7680476e9c80dcdc44d0b75 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 01:47:01 -0700 Subject: [PATCH 08/26] Undo accidental changes introduced in #509, but adds the changes from #460 (#521) --- .../adaptive_lighting/services.yaml | 168 +++++++++--------- 1 file changed, 85 insertions(+), 83 deletions(-) mode change 100644 => 100755 custom_components/adaptive_lighting/services.yaml diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml old mode 100644 new mode 100755 index 351fe5b5..c22d1f76 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -2,184 +2,186 @@ apply: description: Applies the current Adaptive Lighting settings to lights. fields: entity_id: - description: "Entity ID of the switch. \U0001F4DD" + description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. selector: entity: integration: adaptive_lighting domain: switch multiple: false lights: - description: "List of light entities to be controlled by Adaptive Lighting (may be empty). \U0001F31F" + description: entity_id(s) of lights, if not specified, all lights in the switch are selected. selector: entity: domain: light multiple: true transition: - description: "Duration of transition when lights change, in seconds. \U0001F551" + description: Transition of the lights. example: 10 selector: - text: null + text: adapt_brightness: - description: "Whether to adapt the brightness of the light. \U0001F31E" + description: "Adapt the 'brightness', default: true" example: true selector: - boolean: null + boolean: adapt_color: - description: "Whether to adapt the color of the light. \U0001F308" + description: "Adapt the color_temp/color_rgb, default: true" example: true selector: - boolean: null + boolean: prefer_rgb_color: - description: "Use RGB color adjustment instead of native light color temperature. \U0001F308" + description: "Prefer to use color_rgb over color_temp if possible, default: false" example: false selector: - boolean: null + boolean: turn_on_lights: - description: "Whether to turn on lights if they are off. \U0001F506" + description: "Turn on the lights that are off, default: false" example: false selector: - boolean: null + boolean: + set_manual_control: description: Mark whether a light is 'manually controlled'. fields: entity_id: - description: "Entity ID of the switch. \U0001F4DD" + description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. selector: entity: integration: adaptive_lighting domain: switch multiple: false lights: - description: "List of light entities to be controlled by Adaptive Lighting (may be empty). \U0001F31F" + description: entity_id(s) of lights, if not specified, all lights in the switch are selected. selector: entity: domain: light multiple: true manual_control: - description: "Whether to manually control the lights. \U0001F512" + description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" example: true default: true selector: - boolean: null + boolean: + change_switch_settings: - description: Change any settings you'd like in the switch. All options here are the same as in the config flow. + description: "Change any settings you'd like in the switch. All options here are the same as in the config flow." fields: entity_id: - description: "Entity ID of the switch. \U0001F4DD" + description: "entity_id of the Adaptive Lighting switch." required: true selector: entity: domain: switch use_defaults: - description: "Whether to use default settings for the switches. \u2699\uFE0F" - example: current + description: "(default: 'current' for current settings) You can set this to 'factory', 'configuration', or 'current' to reset the variables not being set with this service call. 'current' leaves them as is, 'configuration' resets to whatever already initializes at startup, 'factory' resets to the default values listed in the documentation." + example: "current" required: false - default: current + default: "current" selector: select: options: - - current - - configuration - - factory + - "current" + - "configuration" + - "factory" include_config_in_attributes: - description: "Show all options as attributes on the switch in Home Assistant when set to `true`. \U0001F4DD" + description: "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)" required: false selector: - boolean: null + boolean: turn_on_lights: - description: "Whether to turn on lights if they are off. \U0001F506" + description: "Turn on the lights that are off, default: false" example: false required: false selector: - boolean: null + boolean: initial_transition: - description: "Duration of the first transition when lights turn from `off` to `on` in seconds. \u23F2\uFE0F" + description: "initial_transition: When lights turn 'off' to 'on'. (seconds)" example: 1 required: false selector: - text: null + text: sleep_transition: - description: "Duration of transition when 'sleep mode' is toggled in seconds. \U0001F634" + description: "sleep_transition: When 'sleep_state' changes. (seconds)" example: 1 required: false selector: - text: null + text: max_brightness: - description: "Maximum brightness percentage. \U0001F4A1" + description: "max_brightness: Highest brightness of lights during a cycle. (%)" required: false example: 100 selector: - text: null + text: max_color_temp: - description: "Coldest color temperature in Kelvin. \u2744\uFE0F" + description: "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)" required: false example: 5500 selector: - text: null + text: min_brightness: - description: "Minimum brightness percentage. \U0001F4A1" + description: "min_brightness: Lowest brightness of lights during a cycle. (%)" required: false example: 1 selector: - text: null + text: min_color_temp: - description: "Warmest color temperature in Kelvin. \U0001F525" + description: "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)" required: false example: 2000 selector: - text: null + text: only_once: - description: "Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). \U0001F504" + description: "only_once: Only adapt the lights when turning them on." example: false required: false selector: - boolean: null + boolean: prefer_rgb_color: - description: "Use RGB color adjustment instead of native light color temperature. \U0001F308" + description: "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible." required: false example: false selector: - boolean: null + boolean: separate_turn_on_commands: - description: "Use separate `light.turn_on` calls for color and brightness, needed for some light types. \U0001F500" + description: "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights)." required: false example: false selector: - boolean: null + boolean: send_split_delay: - description: "Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. \u23F2\uFE0F" + description: "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly." required: false example: 0 selector: - boolean: null + boolean: sleep_brightness: - description: "Brightness percentage of lights in sleep mode. \U0001F634" + description: "sleep_brightness, Brightness setting for Sleep Mode. (%)" required: false example: 1 selector: - text: null + text: sleep_rgb_or_color_temp: - description: "Use either `'rgb_color'` or `'color_temp'` in sleep mode. \U0001F319" + description: "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'" required: false - example: color_temp + example: "color_temp" selector: select: options: - - rgb_color - - color_temp + - "rgb_color" + - "color_temp" sleep_rgb_color: - description: "RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is 'rgb_color'). \U0001F308" + description: "sleep_rgb_color, in RGB" required: false selector: - color_rgb: null + color_rgb: sleep_color_temp: - description: "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. \U0001F634" + description: "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)" required: false example: 1000 selector: - text: null + text: sunrise_offset: - description: "Adjust sunrise time with a positive or negative offset in seconds. \u23F0" + description: sunrise_offset, in +/- seconds (integer) required: false example: 0 selector: @@ -187,64 +189,64 @@ change_switch_settings: min: 0 max: 86300 sunrise_time: - description: "Set a fixed time (HH:MM:SS) for sunrise. \U0001F305" + description: sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location) required: false - example: '' + example: "" selector: - time: null + time: sunset_offset: - description: "Adjust sunset time with a positive or negative offset in seconds. \u23F0" + description: sunset_offset, in +/- seconds (integer) required: false - example: '' + example: "" selector: number: min: 0 max: 86300 sunset_time: - description: "Set a fixed time (HH:MM:SS) for sunset. \U0001F307" - example: '' + description: sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location) + example: "" required: false selector: - time: null + time: max_sunrise_time: - description: "Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. \U0001F305" - example: '' + description: "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)" + example: "" required: false selector: - time: null + time: min_sunset_time: - description: "Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. \U0001F307" - example: '' + description: "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)" + example: "" required: false selector: - time: null + time: take_over_control: - description: "Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! \U0001F512" + description: "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on." required: false example: true selector: - boolean: null + boolean: detect_non_ha_changes: - description: "Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. \U0001F575\uFE0F" + description: "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)" required: false example: false selector: - boolean: null + boolean: transition: - description: "Duration of transition when lights change, in seconds. \U0001F551" + description: "Transition time when applying a change to the lights (seconds)" required: false example: 45 selector: - text: null + text: adapt_delay: - description: "Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. \u23F2\uFE0F" + description: "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." required: false example: 0 selector: - text: null + text: autoreset_control_seconds: - description: "Automatically reset the manual control after a number of seconds. Set to 0 to disable. \u23F2\uFE0F" + description: "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" required: false example: 0 selector: - text: null + text: From d768a1e825302b593c7a01f5d57256a04fd30463 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 02:07:05 -0700 Subject: [PATCH 09/26] Release 1.9.1 (#522) * Bump to 1.9.1 * Add CODEOWNERS --- .github/CODEOWNERS | 1 + custom_components/adaptive_lighting/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..847961cb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @basnijholt diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 803069a7..cb7ba6e1 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": [], - "version": "1.9.0" + "version": "1.9.1" } From 26974c8fd5db90f800592912080eca4b816f5710 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 11:32:38 -0700 Subject: [PATCH 10/26] Simplify if-statement, (small #460 fix) (#526) --- custom_components/adaptive_lighting/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 4b9e1856..031396fe 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1067,7 +1067,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): return # See #80. Doesn't check if transitions differ but it does the job. last_service_data = self.turn_on_off_listener.last_service_data - if light in last_service_data and last_service_data[light] == service_data: + if last_service_data.get(light) == service_data: _LOGGER.debug( "%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')", self._name, From b730c7cc9009be3b7a9a187e15286dcb4d06c6ef Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Apr 2023 14:59:14 -0700 Subject: [PATCH 11/26] Add scripts to auto update en.json, strings.json and services.yaml (#520) * Add scripts to auto update strings.json and services.yaml * Run services * simplify * Run strings * rerun * revert * allow unicode * Add CODEOWNERS * Update CODEOWNERS * set CONF_USE_DEFAULTS docs * add field_name * Auto run scripts * Update desc * Update README.md, strings.json, and services.yaml * double quotes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update README.md, strings.json, and services.yaml * Add newline * sync changes between en.json and strings.json * Update README.md, strings.json, and services.yaml * double quotes * fix * Update README.md, strings.json, and services.yaml * Add comments * Remove comments * shorter * Update README.md, strings.json, and services.yaml * Rephrase * Update README.md, strings.json, and services.yaml * remove key from desc --------- Co-authored-by: Benjamin Auquite Co-authored-by: github-actions[bot] Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/update-services.py | 25 +++ .github/update-strings.py | 31 ++++ .github/workflows/update-readme.yml | 14 +- README.md | 14 +- custom_components/adaptive_lighting/const.py | 26 +-- .../adaptive_lighting/services.yaml | 169 +++++++++--------- .../adaptive_lighting/strings.json | 60 +++---- .../adaptive_lighting/translations/en.json | 58 +++--- 8 files changed, 231 insertions(+), 166 deletions(-) create mode 100644 .github/update-services.py create mode 100644 .github/update-strings.py mode change 100755 => 100644 custom_components/adaptive_lighting/services.yaml diff --git a/.github/update-services.py b/.github/update-services.py new file mode 100644 index 00000000..df4c7b30 --- /dev/null +++ b/.github/update-services.py @@ -0,0 +1,25 @@ +from pathlib import Path +import sys + +import yaml + +sys.path.append(str(Path(__file__).parent.parent)) + +from custom_components.adaptive_lighting import const # noqa: E402 + +services_filename = "custom_components/adaptive_lighting/services.yaml" +with open(services_filename) as f: + services = yaml.safe_load(f) + +for service_name, dct in services.items(): + _docs = {"set_manual_control": const.DOCS_MANUAL_CONTROL, "apply": const.DOCS_APPLY} + alternative_docs = _docs.get(service_name, const.DOCS) + for field_name, field in dct["fields"].items(): + description = alternative_docs.get(field_name, const.DOCS[field_name]) + field["description"] = description + +comment = "# This file is auto-generated by .github/update-services.py." + +with open(services_filename, "w") as f: + f.write(comment + "\n") + yaml.dump(services, f, sort_keys=False, width=1000, allow_unicode=True) diff --git a/.github/update-strings.py b/.github/update-strings.py new file mode 100644 index 00000000..aabc7443 --- /dev/null +++ b/.github/update-strings.py @@ -0,0 +1,31 @@ +import json +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).parent.parent)) + +from custom_components.adaptive_lighting import const # noqa: E402 + +strings_fname = "custom_components/adaptive_lighting/strings.json" +en_fname = "custom_components/adaptive_lighting/translations/en.json" +with open(strings_fname) as f: + strings = json.load(f) + +data = {k: f"{k}: {const.DOCS[k]}" for k, _, _ in const.VALIDATION_TUPLES} +strings["options"]["step"]["init"]["data"] = data + +with open(strings_fname, "w") as f: + json.dump(strings, f, indent=2, ensure_ascii=False) + f.write("\n") + + +# Sync changes from strings.json to en.json +with open(en_fname) as f: + en = json.load(f) + +en["config"]["step"]["user"] = strings["config"]["step"]["user"] +en["options"]["step"]["init"]["data"] = data + +with open(en_fname, "w") as f: + json.dump(en, f, indent=2, ensure_ascii=False) + f.write("\n") diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index bc31bb94..bec8cb4c 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -34,17 +34,23 @@ jobs: - name: Run markdown-code-runner run: markdown-code-runner --debug README.md - - name: Commit updated README.md + - name: Run update strings.json + run: python .github/update-strings.py + + - name: Run update services.yaml + run: python .github/update-services.py + + - name: Commit updated README.md, strings.json, and services.yaml id: commit run: | - git add README.md + git add -u . git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" if git diff --quiet && git diff --staged --quiet; then - echo "No changes in README.md, skipping commit." + echo "No changes in README.md, strings.json, and services.yaml, skipping commit." echo "commit_status=skipped" >> $GITHUB_ENV else - git commit -m "Update README.md" + git commit -m "Update README.md, strings.json, and services.yaml" echo "commit_status=committed" >> $GITHUB_ENV fi diff --git a/README.md b/README.md index 5b98de8b..c7a873d0 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,11 @@ The YAML and frontend configuration methods support all of the options listed be | Variable name | Description | Default | Type | |:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| -| `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s | +| `lights` | List of light entity_ids to be controlled (may be empty). 🌟 | `[]` | list of `entity_id`s | | `prefer_rgb_color` | Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 | `False` | `bool` | | `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 | `False` | `bool` | | `initial_transition` | Duration of the first transition when lights turn from `off` to `on` in seconds. â˛ī¸ | `1` | `float` 0-6553 | -| `sleep_transition` | Duration of transition when 'sleep mode' is toggled in seconds. 😴 | `1` | `float` 0-6553 | +| `sleep_transition` | Duration of transition when "sleep mode" is toggled in seconds. 😴 | `1` | `float` 0-6553 | | `transition` | Duration of transition when lights change, in seconds. 🕑 | `45` | `float` 0-6553 | | `transition_until_sleep` | When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙 | `False` | `bool` | | `interval` | Frequency to adapt the lights, in seconds. 🔄 | `90` | `int > 0` | @@ -103,9 +103,9 @@ The YAML and frontend configuration methods support all of the options listed be | `min_color_temp` | Warmest color temperature in Kelvin. đŸ”Ĩ | `2000` | `int` 1000-10000 | | `max_color_temp` | Coldest color temperature in Kelvin. â„ī¸ | `5500` | `int` 1000-10000 | | `sleep_brightness` | Brightness percentage of lights in sleep mode. 😴 | `1` | `int` 1-100 | -| `sleep_rgb_or_color_temp` | Use either `'rgb_color'` or `'color_temp'` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | +| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 | `color_temp` | one of `['color_temp', 'rgb_color']` | | `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 | `1000` | `int` 1000-10000 | -| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is 'rgb_color'). 🌈 | `[255, 56, 0]` | RGB color | +| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 | `[255, 56, 0]` | RGB color | | `sunrise_time` | Set a fixed time (HH:MM:SS) for sunrise. 🌅 | `None` | `str` | | `max_sunrise_time` | Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 | `None` | `str` | | `sunrise_offset` | Adjust sunrise time with a positive or negative offset in seconds. ⏰ | `0` | `int` | @@ -116,8 +116,8 @@ The YAML and frontend configuration methods support all of the options listed be | `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 | `True` | `bool` | | `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. đŸ•ĩī¸ | `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` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. â˛ī¸ | `0` | `int` 0-10000 | -| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. â˛ī¸ | `0` | `float > 0` | +| `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` | | `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. â˛ī¸ | `0` | `int` 0-31536000 | @@ -188,7 +188,7 @@ adaptive_lighting: |:-------------------------|:-----------------------------------------------------------------------------------------------|:-----------|:---------------------| | `entity_id` | The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 | ✅ | list of `entity_id`s | | `lights` | entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 | ❌ | list of `entity_id`s | -| `manual_control` | Whether to add ('true') or remove ('false') the light from the 'manual_control' list. 🔒 | ❌ | bool | +| `manual_control` | Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 | ❌ | bool | #### `adaptive_lighting.change_switch_settings` diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index bb0c07cd..64620927 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -22,9 +22,7 @@ CONF_NAME, DEFAULT_NAME = "name", "default" DOCS[CONF_NAME] = "Display name for this switch. 📝" CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] -DOCS[CONF_LIGHTS] = ( - "List of light entities to be controlled by Adaptive " "Lighting (may be empty). 🌟" -) +DOCS[CONF_LIGHTS] = "List of light entity_ids to be controlled (may be empty). 🌟" CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( "detect_non_ha_changes", @@ -52,7 +50,7 @@ DOCS[CONF_INITIAL_TRANSITION] = ( CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1 DOCS[CONF_SLEEP_TRANSITION] = ( - "Duration of transition when 'sleep mode' is toggled " "in seconds. 😴" + 'Duration of transition when "sleep mode" is toggled ' "in seconds. 😴" ) CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 @@ -102,7 +100,7 @@ DOCS[CONF_SLEEP_COLOR_TEMP] = ( CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0] DOCS[CONF_SLEEP_RGB_COLOR] = ( - "RGB color in sleep mode (used when " "`sleep_rgb_or_color_temp` is 'rgb_color'). 🌈" + "RGB color in sleep mode (used when " '`sleep_rgb_or_color_temp` is "rgb_color"). 🌈' ) CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = ( @@ -110,7 +108,7 @@ CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = ( "color_temp", ) DOCS[CONF_SLEEP_RGB_OR_COLOR_TEMP] = ( - "Use either `'rgb_color'` or `'color_temp'` " "in sleep mode. 🌙" + 'Use either `"rgb_color"` or `"color_temp"` ' "in sleep mode. 🌙" ) CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 @@ -163,13 +161,13 @@ DOCS[CONF_ADAPT_UNTIL_SLEEP] = ( CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 DOCS[CONF_ADAPT_DELAY] = ( "Wait time (seconds) between light turn on and Adaptive Lighting applying " - "changes. Helps avoid flickering. â˛ī¸" + "changes. Might help to avoid flickering. â˛ī¸" ) CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0 DOCS[CONF_SEND_SPLIT_DELAY] = ( - "Wait time (milliseconds) between commands when using `separate_turn_on_commands`. " - "Helps ensure correct handling. â˛ī¸" + "Delay (ms) between `separate_turn_on_commands` for lights that don't support " + "simultaneous brightness and color setting. â˛ī¸" ) CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0 @@ -197,7 +195,11 @@ CONF_TURN_ON_LIGHTS = "turn_on_lights" DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆" SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings" CONF_USE_DEFAULTS = "use_defaults" -DOCS[CONF_USE_DEFAULTS] = "Whether to use default settings for the switches. âš™ī¸" +DOCS[CONF_USE_DEFAULTS] = ( + "Sets the default values not specified in this service call. Options: " + '"current" (default, retains current values), "factory" (resets to ' + 'documented defaults), or "configuration" (reverts to switch config defaults). âš™ī¸' +) TURNING_OFF_DELAY = 5 @@ -206,8 +208,8 @@ DOCS_MANUAL_CONTROL = { "light as being `manually controlled`. 📝", CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the " "switch are selected. 💡", - CONF_MANUAL_CONTROL: "Whether to add ('true') or remove ('false') the " - "light from the 'manual_control' list. 🔒", + CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the ' + 'light from the "manual_control" list. 🔒', } DOCS_APPLY = { diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml old mode 100755 new mode 100644 index c22d1f76..cd25811b --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -1,187 +1,186 @@ +# This file is auto-generated by .github/update-services.py. apply: description: Applies the current Adaptive Lighting settings to lights. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. + description: The `entity_id` of the switch with the settings to apply. 📝 selector: entity: integration: adaptive_lighting domain: switch multiple: false lights: - description: entity_id(s) of lights, if not specified, all lights in the switch are selected. + description: A light (or list of lights) to apply the settings to. 💡 selector: entity: domain: light multiple: true transition: - description: Transition of the lights. + description: Duration of transition when lights change, in seconds. 🕑 example: 10 selector: - text: + text: null adapt_brightness: - description: "Adapt the 'brightness', default: true" + description: Whether to adapt the brightness of the light. 🌞 example: true selector: - boolean: + boolean: null adapt_color: - description: "Adapt the color_temp/color_rgb, default: true" + description: Whether to adapt the color on supporting lights. 🌈 example: true selector: - boolean: + boolean: null prefer_rgb_color: - description: "Prefer to use color_rgb over color_temp if possible, default: false" + description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 example: false selector: - boolean: + boolean: null turn_on_lights: - description: "Turn on the lights that are off, default: false" + description: Whether to turn on lights that are currently off. 🔆 example: false selector: - boolean: - + boolean: null set_manual_control: description: Mark whether a light is 'manually controlled'. fields: entity_id: - description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used. + description: The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝 selector: entity: integration: adaptive_lighting domain: switch multiple: false lights: - description: entity_id(s) of lights, if not specified, all lights in the switch are selected. + description: entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡 selector: entity: domain: light multiple: true manual_control: - description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true" + description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒 example: true default: true selector: - boolean: - + boolean: null change_switch_settings: - description: "Change any settings you'd like in the switch. All options here are the same as in the config flow." + description: Change any settings you'd like in the switch. All options here are the same as in the config flow. fields: entity_id: - description: "entity_id of the Adaptive Lighting switch." + description: Entity ID of the switch. 📝 required: true selector: entity: domain: switch use_defaults: - description: "(default: 'current' for current settings) You can set this to 'factory', 'configuration', or 'current' to reset the variables not being set with this service call. 'current' leaves them as is, 'configuration' resets to whatever already initializes at startup, 'factory' resets to the default values listed in the documentation." - example: "current" + description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). âš™ī¸' + example: current required: false - default: "current" + default: current selector: select: options: - - "current" - - "configuration" - - "factory" + - current + - configuration + - factory include_config_in_attributes: - description: "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)" + description: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝 required: false selector: - boolean: + boolean: null turn_on_lights: - description: "Turn on the lights that are off, default: false" + description: Whether to turn on lights that are currently off. 🔆 example: false required: false selector: - boolean: + boolean: null initial_transition: - description: "initial_transition: When lights turn 'off' to 'on'. (seconds)" + description: Duration of the first transition when lights turn from `off` to `on` in seconds. â˛ī¸ example: 1 required: false selector: - text: + text: null sleep_transition: - description: "sleep_transition: When 'sleep_state' changes. (seconds)" + description: Duration of transition when "sleep mode" is toggled in seconds. 😴 example: 1 required: false selector: - text: + text: null max_brightness: - description: "max_brightness: Highest brightness of lights during a cycle. (%)" + description: Maximum brightness percentage. 💡 required: false example: 100 selector: - text: + text: null max_color_temp: - description: "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)" + description: Coldest color temperature in Kelvin. â„ī¸ required: false example: 5500 selector: - text: + text: null min_brightness: - description: "min_brightness: Lowest brightness of lights during a cycle. (%)" + description: Minimum brightness percentage. 💡 required: false example: 1 selector: - text: + text: null min_color_temp: - description: "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)" + description: Warmest color temperature in Kelvin. đŸ”Ĩ required: false example: 2000 selector: - text: + text: null only_once: - description: "only_once: Only adapt the lights when turning them on." + description: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄 example: false required: false selector: - boolean: + boolean: null prefer_rgb_color: - description: "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible." + description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈 required: false example: false selector: - boolean: + boolean: null separate_turn_on_commands: - description: "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights)." + description: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀 required: false example: false selector: - boolean: + boolean: null send_split_delay: - description: "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly." + description: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. â˛ī¸ required: false example: 0 selector: - boolean: + boolean: null sleep_brightness: - description: "sleep_brightness, Brightness setting for Sleep Mode. (%)" + description: Brightness percentage of lights in sleep mode. 😴 required: false example: 1 selector: - text: + text: null sleep_rgb_or_color_temp: - description: "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'" + description: Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙 required: false - example: "color_temp" + example: color_temp selector: select: options: - - "rgb_color" - - "color_temp" + - rgb_color + - color_temp sleep_rgb_color: - description: "sleep_rgb_color, in RGB" + description: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈 required: false selector: - color_rgb: + color_rgb: null sleep_color_temp: - description: "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)" + description: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴 required: false example: 1000 selector: - text: + text: null sunrise_offset: - description: sunrise_offset, in +/- seconds (integer) + description: Adjust sunrise time with a positive or negative offset in seconds. ⏰ required: false example: 0 selector: @@ -189,64 +188,64 @@ change_switch_settings: min: 0 max: 86300 sunrise_time: - description: sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location) + description: Set a fixed time (HH:MM:SS) for sunrise. 🌅 required: false - example: "" + example: '' selector: - time: + time: null sunset_offset: - description: sunset_offset, in +/- seconds (integer) + description: Adjust sunset time with a positive or negative offset in seconds. ⏰ required: false - example: "" + example: '' selector: number: min: 0 max: 86300 sunset_time: - description: sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location) - example: "" + description: Set a fixed time (HH:MM:SS) for sunset. 🌇 + example: '' required: false selector: - time: + time: null max_sunrise_time: - description: "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)" - example: "" + description: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅 + example: '' required: false selector: - time: + time: null min_sunset_time: - description: "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)" - example: "" + description: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇 + example: '' required: false selector: - time: + time: null take_over_control: - description: "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on." + description: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒 required: false example: true selector: - boolean: + boolean: null detect_non_ha_changes: - description: "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)" + description: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. đŸ•ĩī¸ required: false example: false selector: - boolean: + boolean: null transition: - description: "Transition time when applying a change to the lights (seconds)" + description: Duration of transition when lights change, in seconds. 🕑 required: false example: 45 selector: - text: + text: null adapt_delay: - description: "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." + description: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. â˛ī¸ required: false example: 0 selector: - text: + text: null autoreset_control_seconds: - description: "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" + description: Automatically reset the manual control after a number of seconds. Set to 0 to disable. â˛ī¸ required: false example: 0 selector: - text: + text: null diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 2ebd96d0..54af5e11 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "title": "Choose a name for the Adaptive Lighting", + "title": "Choose a name for the Adaptive Lighting instance", "description": "Every instance can contain multiple lights!", "data": { "name": "Name" @@ -19,35 +19,35 @@ "title": "Adaptive Lighting options", "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.", "data": { - "lights": "lights", - "initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)", - "include_config_in_attributes": "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)", - "adapt_until_sleep": "adapt_until_sleep: When checked, Adaptive Lighting will use the sleep settings as the minimum, and transition to these values past the sunset (default: false)", - "sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)", - "interval": "interval: Time between switch updates. (seconds)", - "max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)", - "max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)", - "min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)", - "min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)", - "only_once": "only_once: Only adapt the lights when turning them on.", - "prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.", - "separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).", - "send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.", - "sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)", - "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'", - "sleep_rgb_color": "sleep_rgb_color, in RGB", - "sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)", - "sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)", - "sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)", - "sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", - "min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", - "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", - "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", - "transition": "Transition time when applying a change to the lights (seconds)", - "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering.", - "autoreset_control_seconds": "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" + "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", + "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. â˛ī¸", + "sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "transition": "transition: Duration of transition when lights change, in seconds. 🕑", + "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "interval": "interval: Frequency to adapt the lights, in seconds. 🔄", + "min_brightness": "min_brightness: Minimum brightness percentage. 💡", + "max_brightness": "max_brightness: Maximum brightness percentage. 💡", + "min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. đŸ”Ĩ", + "max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. â„ī¸", + "sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅", + "sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇", + "min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇", + "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", + "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. đŸ•ĩī¸", + "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: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. â˛ī¸", + "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. â˛ī¸", + "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. â˛ī¸" } } }, diff --git a/custom_components/adaptive_lighting/translations/en.json b/custom_components/adaptive_lighting/translations/en.json index 51311d64..38fb7b0e 100644 --- a/custom_components/adaptive_lighting/translations/en.json +++ b/custom_components/adaptive_lighting/translations/en.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Choose a name for the Adaptive Lighting instance", - "description": "Pick a name for this instance. You can run several instances of Adaptive lighting, each of these can contain multiple lights!", + "description": "Every instance can contain multiple lights!", "data": { "name": "Name" } @@ -20,33 +20,35 @@ "title": "Adaptive Lighting options", "description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have the adaptive_lighting entry defined in your YAML configuration.", "data": { - "lights": "lights", - "initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)", - "include_config_in_attributes": "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)", - "sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)", - "interval": "interval: Time between switch updates. (seconds)", - "max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)", - "max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)", - "min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)", - "min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)", - "only_once": "only_once: Only adapt the lights when turning them on.", - "prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.", - "separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).", - "send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.", - "sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)", - "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'", - "sleep_rgb_color": "sleep_rgb_color, in RGB", - "sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)", - "sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)", - "sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)", - "sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)", - "sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", - "min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)", - "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", - "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", - "transition": "Transition time when applying a change to the lights (seconds)", - "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." + "lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟", + "prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈", + "include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝", + "initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. â˛ī¸", + "sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴", + "transition": "transition: Duration of transition when lights change, in seconds. 🕑", + "transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙", + "interval": "interval: Frequency to adapt the lights, in seconds. 🔄", + "min_brightness": "min_brightness: Minimum brightness percentage. 💡", + "max_brightness": "max_brightness: Maximum brightness percentage. 💡", + "min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. đŸ”Ĩ", + "max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. â„ī¸", + "sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴", + "sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙", + "sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴", + "sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈", + "sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅", + "max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅", + "sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰", + "sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇", + "min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇", + "sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰", + "only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄", + "take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒", + "detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. đŸ•ĩī¸", + "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: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. â˛ī¸", + "adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. â˛ī¸", + "autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. â˛ī¸" } } }, From c7f44e472ff672512e027f7e8bbe43dbd095558f Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Mon, 3 Apr 2023 19:04:06 -0500 Subject: [PATCH 12/26] Correctly wait for transitions (#510) * Add auto_reset_manual_control with async timer * cherry-pick wait for transition stuff * Update switch.py * not renamed in this branch yet. * Update switch.py * update tests * Update switch.py * merge related fix * cleanup * Revert "cleanup" This reverts commit 3aa2f3242b06370c0f84248d2c2c21a2556bb5ec. * Update switch.py * Update switch.py * Update switch.py * Small refactor * Move test to old position for better diffs * Revert "Small refactor" This reverts commit b986b3f77cba921cbfb07647dea1d6f876883dd4. * Update README.md * fix the test last_state_change isn't updated quick enough. * #510 changes (#516) * Change (WIP) * Update test_switch.py * Refactor * Revert "Revert "Small refactor"" This reverts commit 3731c9993676b0a831af12da25bfd8c07ff2a148. * Update README.md * Fix the test * Bump to 1.9.0 (#518) * Basic community fixes PR (#460) * Fixes #423 #423 * Do not adapt lights turned on with custom payloads. * Update switch.py * Issue fixes #423, #378, #403, #449 * quickly test #274 * Revert feature requests, this branch only has fixes. Reverted FR 274 * pre-commit fix * Create automerge.yaml * test * Delete automerge.yaml My bad. * Fix #460 and #408 * see @basnijholt 's comment in #450. * @basnijholt requested changes. --------- Co-authored-by: Bas Nijholt * Undo accidental changes introduced in #509, but adds the changes from #460 (#521) * Release 1.9.1 (#522) * Bump to 1.9.1 * Add CODEOWNERS --------- Co-authored-by: Benjamin Auquite Co-authored-by: github-actions[bot] * No need to wrap the reset * Remove unused attrs * Shorter log message * revert unrelated tests change * remove unused function * Use patch * Bump to 1.10.0 --------- Co-authored-by: Bas Nijholt Co-authored-by: github-actions[bot] Co-authored-by: Bas Nijholt --- .../adaptive_lighting/manifest.json | 2 +- custom_components/adaptive_lighting/switch.py | 261 +++++++++++------- tests/test_switch.py | 67 ++++- 3 files changed, 211 insertions(+), 119 deletions(-) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index cb7ba6e1..33564f23 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": [], - "version": "1.9.1" + "version": "1.10.0" } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 031396fe..52821774 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import base64 import bisect -from collections import defaultdict +from collections.abc import Callable, Coroutine from copy import deepcopy from dataclasses import dataclass import datetime @@ -802,10 +802,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._only_once = data[CONF_ONLY_ONCE] self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR] self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS] - self._take_over_control = data[CONF_TAKE_OVER_CONTROL] self._transition = data[CONF_TRANSITION] self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._take_over_control = data[CONF_TAKE_OVER_CONTROL] + self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] + if not data[CONF_TAKE_OVER_CONTROL] and data[CONF_DETECT_NON_HA_CHANGES]: + _LOGGER.warning( + "%s: Config mismatch: 'detect_non_ha_changes: 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 self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] self._expand_light_groups() # updates manual control timers _loc = get_astral_location(self.hass) @@ -1128,11 +1137,23 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) ) self.async_write_ha_state() + if lights is None: lights = self._lights - if (self._only_once and not force) or not lights: + + if not force and self._only_once: return - await self._adapt_lights(lights, transition, force, context) + + filtered_lights = [] + for light in lights: + # Don't adapt lights that haven't finished prior transitions. + if force or not self.turn_on_off_listener.transition_timers.get(light): + filtered_lights.append(light) + + if not filtered_lights: + return + + await self._adapt_lights(filtered_lights, transition, force, context) async def _adapt_lights( self, @@ -1532,8 +1553,6 @@ class TurnOnOffListener: self.sleep_tasks: dict[str, asyncio.Task] = {} # Tracks which lights are manually controlled self.manual_control: dict[str, bool] = {} - # Counts the number of times (in a row) a light had a changed state. - self.cnt_significant_changes: dict[str, int] = defaultdict(int) # Track 'state_changed' events of self.lights resulting from this integration self.last_state_change: dict[str, list[State]] = {} # Track last 'service_data' to 'light.turn_on' resulting from this integration @@ -1543,9 +1562,8 @@ class TurnOnOffListener: self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} self.auto_reset_manual_control_times: dict[str, float] = {} - # When a state is different `max_cnt_significant_changes` times in a row, - # mark it as manually_controlled. - self.max_cnt_significant_changes = 2 + # Track light transitions + self.transition_timers: dict[str, _AsyncSingleShotTimer] = {} self.remove_listener = self.hass.bus.async_listen( EVENT_CALL_SERVICE, self.turn_on_off_event_listener @@ -1554,6 +1572,56 @@ class TurnOnOffListener: EVENT_STATE_CHANGED, self.state_changed_event_listener ) + def _handle_timer( + self, + light: str, + timers_dict: dict[str, _AsyncSingleShotTimer], + delay: float | None, + reset_coroutine: Callable[[], Coroutine[Any, Any, None]], + ) -> None: + timer = timers_dict.get(light) + if timer is not None: + if delay is None: # Timer object exists, but should not anymore + timer.cancel() + timers_dict.pop(light) + else: # Timer object already exists, just update the delay and restart it + timer.delay = delay + timer.start() + elif delay is not None: # Timer object does not exist, create it + timer = _AsyncSingleShotTimer(delay, reset_coroutine) + timers_dict[light] = timer + timer.start() + + def start_transition_timer(self, light: str) -> None: + """Mark a light as manually controlled.""" + _LOGGER.debug("Start transition timer for %s", light) + last_service_data = self.last_service_data + if ( + not last_service_data + or light not in last_service_data + or ATTR_TRANSITION not in last_service_data[light] + ): + return + + delay = last_service_data[light][ATTR_TRANSITION] + + async def reset(): + _LOGGER.debug( + "Transition finished for light %s", + light, + ) + switches = _get_switches_with_lights(self.hass, [light]) + for switch in switches: + if not switch.is_on: + continue + await switch._update_attrs_and_maybe_adapt_lights( + [light], + force=False, + context=switch.create_context("transit"), + ) + + self._handle_timer(light, self.transition_timers, delay, reset) + def set_auto_reset_manual_control_times(self, lights: list[str], time: float): """Set the time after which the lights are automatically reset.""" if time == 0: @@ -1576,40 +1644,28 @@ class TurnOnOffListener: _LOGGER.debug("Marking '%s' as manually controlled.", light) self.manual_control[light] = True delay = self.auto_reset_manual_control_times.get(light) - timer = self.auto_reset_manual_control_timers.get(light) - if timer is not None: - if delay is None: # Timer object exists, but should not anymore - timer.cancel() - self.auto_reset_manual_control_timers.pop(light) - else: # Timer object already exists, just update the delay and restart it - timer.delay = delay - timer.start() - elif delay is not None: # Timer object does not exist, create it - async def reset(): - self.reset(light) - switches = _get_switches_with_lights(self.hass, [light]) - for switch in switches: - if not switch.is_on: - continue - # pylint: disable=protected-access - await switch._update_attrs_and_maybe_adapt_lights( - [light], - transition=switch._initial_transition, - force=True, - context=switch.create_context("autoreset"), - ) - _LOGGER.debug( - "Auto resetting 'manual_control' status of '%s' because" - " it was not manually controlled for %s seconds.", - light, - delay, + async def reset(): + self.reset(light) + switches = _get_switches_with_lights(self.hass, [light]) + for switch in switches: + if not switch.is_on: + continue + await switch._update_attrs_and_maybe_adapt_lights( + [light], + transition=switch._initial_transition, + force=True, + context=switch.create_context("autoreset"), ) - assert not self.manual_control[light] + _LOGGER.debug( + "Auto resetting 'manual_control' status of '%s' because" + " it was not manually controlled for %s seconds.", + light, + delay, + ) + assert not self.manual_control[light] - timer = _AsyncSingleShotTimer(delay, reset) - self.auto_reset_manual_control_timers[light] = timer - timer.start() + self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset) def reset(self, *lights, reset_manual_control=True) -> None: """Reset the 'manual_control' status of the lights.""" @@ -1621,7 +1677,6 @@ class TurnOnOffListener: timer.cancel() self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) - self.cnt_significant_changes[light] = 0 async def turn_on_off_event_listener(self, event: Event) -> None: """Track 'light.turn_off' and 'light.turn_on' service calls.""" @@ -1700,11 +1755,7 @@ class TurnOnOffListener: new_state.context.id, ) - if ( - new_state is not None - and new_state.state == STATE_ON - and is_our_context(new_state.context) - ): + if new_state is not None and new_state.state == STATE_ON: # It is possible to have multiple state change events with the same context. # This can happen because a `turn_on.light(brightness_pct=100, transition=30)` # event leads to an instant state change of @@ -1717,21 +1768,29 @@ class TurnOnOffListener: # incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for # Philips Hue White GU10 Bluetooth lights). old_state: list[State] | None = self.last_state_change.get(entity_id) - if ( - old_state is not None - and old_state[0].context.id == new_state.context.id - ): - # If there is already a state change event from this event (with this - # context) then append it to the already existing list. - _LOGGER.debug( - "State change event of '%s' is already in 'self.last_state_change' (%s)" - " adding this state also", - entity_id, - new_state.context.id, - ) + if is_our_context(new_state.context): + if ( + old_state is not None + and old_state[0].context.id == new_state.context.id + ): + _LOGGER.debug( + "TurnOnOffListener: State change event of '%s' is already" + " in 'self.last_state_change' (%s)" + " adding this state also", + entity_id, + new_state.context.id, + ) + self.last_state_change[entity_id].append(new_state) + else: + _LOGGER.debug( + "TurnOnOffListener: New adapt '%s' found for %s", + new_state, + entity_id, + ) + self.last_state_change[entity_id] = [new_state] + self.start_transition_timer(entity_id) + elif old_state is not None: self.last_state_change[entity_id].append(new_state) - else: - self.last_state_change[entity_id] = [new_state] def is_manually_controlled( self, @@ -1786,64 +1845,58 @@ class TurnOnOffListener: detected, we mark the light as 'manually controlled' until the light or switch is turned 'off' and 'on' again. """ - if light not in self.last_state_change: - return False - old_states: list[State] = self.last_state_change[light] - await self.hass.helpers.entity_component.async_update_entity(light) - new_state = self.hass.states.get(light) + last_service_data = self.last_service_data.get(light) + if last_service_data is None: + return compare_to = functools.partial( _attributes_have_changed, light=light, - new_attributes=new_state.attributes, adapt_brightness=adapt_brightness, adapt_color=adapt_color, context=context, ) - for index, old_state in enumerate(old_states): - changed = compare_to(old_attributes=old_state.attributes) - if not changed: - _LOGGER.debug( - "State of '%s' didn't change wrt change event nr. %s (context.id=%s)", - light, - index, - context.id, - ) - break - - last_service_data = self.last_service_data.get(light) - if changed and last_service_data is not None: - # It can happen that the state change events that are associated - # with the last 'light.turn_on' call by this integration were not - # final states. Possibly a later EVENT_STATE_CHANGED happened, where - # the correct target brightness/color was reached. - changed = compare_to(old_attributes=last_service_data) - if not changed: + # Update state and check for a manual change not done in HA. + # Ensure HASS is correctly updating your light's state with + # light.turn_on calls if any problems arise. This + # can happen e.g. using zigbee2mqtt with 'report: false' in device settings. + if switch._detect_non_ha_changes: + _LOGGER.debug( + "%s: 'detect_non_ha_changes: true', calling update_entity(%s)" + " and check if it's last adapt succeeded.", + switch._name, + light, + ) + # This update_entity probably isn't necessary now that we're checking + # if transitions finished from our last adapt. + await self.hass.helpers.entity_component.async_update_entity(light) + refreshed_state = self.hass.states.get(light) + _LOGGER.debug( + "%s: Current state of %s: %s", + switch._name, + light, + refreshed_state, + ) + changed = compare_to( + old_attributes=last_service_data, + new_attributes=refreshed_state.attributes, + ) + if changed: _LOGGER.debug( "State of '%s' didn't change wrt 'last_service_data' (context.id=%s)", light, context.id, ) - - n_changes = self.cnt_significant_changes[light] - if changed: - self.cnt_significant_changes[light] += 1 - if n_changes >= self.max_cnt_significant_changes: - # Only mark a light as significantly changing, if changed==True - # N times in a row. We do this because sometimes a state changes - # happens only *after* a new update interval has already started. self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, context, is_async=False) - else: - if n_changes > 1: - _LOGGER.debug( - "State of '%s' had 'cnt_significant_changes=%s' but the state" - " changed to the expected settings now", - light, - n_changes, - ) - self.cnt_significant_changes[light] = 0 - - return changed + return True + _LOGGER.debug( + "%s: Light '%s' correctly matches our last adapt's service data, continuing..." + " context.id=%s.", + switch._name, + light, + context.id, + ) + return False async def maybe_cancel_adjusting( self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None diff --git a/tests/test_switch.py b/tests/test_switch.py index 212cea82..9e88b332 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -56,6 +56,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, CONF_LIGHTS, CONF_NAME, CONF_PLATFORM, @@ -769,25 +770,63 @@ async def test_significant_change(hass): ) await hass.async_block_till_done() - switch, (bed_light_instance, *_) = await setup_lights_and_switch(hass) + async def set_brightness(val: int): + hass.states.async_set( + ENTITY_LIGHT, "on", {ATTR_BRIGHTNESS: val, ATTR_SUPPORTED_FEATURES: 1} + ) + await hass.async_block_till_done() + + switch, _ = await setup_lights_and_switch(hass) + _LOGGER.debug("Test detect_non_ha_changes:") + switch._take_over_control = True + assert switch._take_over_control + switch._detect_non_ha_changes = True + assert switch._detect_non_ha_changes + + # build last service data + await update(force=False) + + # force=True should not reset manual control. + await turn_light(True, brightness=40) + await turn_light(True, brightness=20) + await update(force=False) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await update(force=True) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # turn light off then on should reset manual control. + await turn_light(False) await turn_light(True) - await update(force=True) # removes manual control assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # Change brightness by setting state (not using 'light.turn_on') - attributes = hass.states.get(ENTITY_LIGHT).attributes - new_attributes = attributes.copy() - new_brightness = (attributes[ATTR_BRIGHTNESS] + 100) % 255 - new_attributes[ATTR_BRIGHTNESS] = new_brightness - bed_light_instance._brightness = new_brightness + # Assert last_service_data got filled from update() + await update(force=True) assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None - for _ in range(switch.turn_on_off_listener.max_cnt_significant_changes): + + # Simulate a transition to 255 where the update() is already using brightness 255. + await set_brightness(240) + await set_brightness(244) + await set_brightness(247) + await set_brightness(250) + + # last_state_change should have our state changes. + # Change brightness by async_set (not using 'light.turn_on') + new_brightness = 50 + await set_brightness(new_brightness) + _LOGGER.debug("Test: Brightness set to %s", new_brightness) + + # mock homeassistant.core.HomeAssistant.helpers.entity_component.async_update_entity + # Otherwise what happens is update_entity() refreshes the state to the last call of + # light.turn_on(). This is because we are not using hass.states.async_set() to + # set the brightness of the light. We mock `async_update_ha_state` because + # `async_update_entity` calls it. + with patch("homeassistant.helpers.entity.Entity.async_update_ha_state"): + # On next update ENTITY_LIGHT should be marked as manually controlled await update(force=False) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # On next update the light should be marked as manually controlled - await update(force=False) - # TODO: the state should be `bool(manual_control) is True` - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + assert ( + switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None + ) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] def test_color_difference_redmean(): From 981287edb96d04e012c5ab689bc15bb981135b89 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 17:30:07 -0700 Subject: [PATCH 13/26] [pre-commit.ci] pre-commit autoupdate (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2f8938a7..462e0dc2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black - repo: https://github.com/asottile/pyupgrade From be0735002c5814ec822ca5435d530569dafa62e4 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 21:59:14 -0700 Subject: [PATCH 14/26] docs: add th3w1zard1 as a contributor for bug (#534) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 344b3112..3872e09b 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -400,7 +400,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/2219836?v=4", "profile": "https://github.com/th3w1zard1", "contributions": [ - "code" + "code", + "bug" ] }, { diff --git a/README.md b/README.md index c7a873d0..65fc0d74 100644 --- a/README.md +++ b/README.md @@ -445,7 +445,7 @@ These graphs were generated using the values calculated by the Adaptive Lighting TomÃĄÅĄ Valigura
TomÃĄÅĄ Valigura

🌍 - Benjamin Auquite
Benjamin Auquite

đŸ’ģ + Benjamin Auquite
Benjamin Auquite

đŸ’ģ 🐛 Skyler Carlson
Skyler Carlson

📖 Chris
Chris

đŸ’ģ Raman Gupta
Raman Gupta

đŸ’ģ From e4d06476fd0eb3fa6f90c368edc30860a85d844b Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 4 Apr 2023 01:47:08 -0500 Subject: [PATCH 15/26] Add windows command for Docker test instructions (#536) * ( Tiny Change ) Add windows command for dockertest You said it earlier but the correct command for running the Docker image on windows is: ```bash docker run -v %cd%:/app basnijholt/adaptive-lighting:latest ``` * Update README.md --------- Co-authored-by: Bas Nijholt --- tests/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/README.md b/tests/README.md index adedcc4c..a576d518 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,10 +5,18 @@ Alternatively, you can use the provided Docker image to run the tests locally. To run the tests using the Docker image, navigate to the `adaptive-lighting` repo folder and execute the following command: +Linux or MacOS: + ```bash docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest ``` +Windows: + +```bash +docker run -v %cd%:/app basnijholt/adaptive-lighting:latest +``` + This command will download the Docker image from [the adaptive-lighting Docker Hub repo](https://hub.docker.com/r/basnijholt/adaptive-lighting) and run the tests. If you prefer to build the image yourself, use the following command: From 744e43f4bf057775125de2ffc576d9902df8e530 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 4 Apr 2023 08:54:54 -0700 Subject: [PATCH 16/26] docs: add th3w1zard1 as a contributor for maintenance (#538) * docs: update README.md * docs: update .all-contributorsrc --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 3 ++- README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3872e09b..6c825dad 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -401,7 +401,8 @@ "profile": "https://github.com/th3w1zard1", "contributions": [ "code", - "bug" + "bug", + "maintenance" ] }, { diff --git a/README.md b/README.md index 65fc0d74..f013d6c5 100644 --- a/README.md +++ b/README.md @@ -445,7 +445,7 @@ These graphs were generated using the values calculated by the Adaptive Lighting TomÃĄÅĄ Valigura
TomÃĄÅĄ Valigura

🌍 - Benjamin Auquite
Benjamin Auquite

đŸ’ģ 🐛 + Benjamin Auquite
Benjamin Auquite

đŸ’ģ 🐛 🚧 Skyler Carlson
Skyler Carlson

📖 Chris
Chris

đŸ’ģ Raman Gupta
Raman Gupta

đŸ’ģ From 4fcf238360f9cd4528d89f3c17c86f44ac61ec3a Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Tue, 4 Apr 2023 22:52:54 -0500 Subject: [PATCH 17/26] Update README.md on transition_until_sleep parameter (#539) * Update README.md I believe you changed the config option's name after I posted the graph, so I renamed the config option there too. There was also a deleted user on the contributions list so I went ahead and removed that too. * chore(docs): update TOC * Update README.md --------- Co-authored-by: th3w1zard1 --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f013d6c5..9ba7cc03 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ ![](https://github.com/home-assistant/brands/raw/b4a168b9af282ef916e120d31091ecd5e3c35e66/core_integrations/adaptive_lighting/icon.png) -Adaptive Lighting is a custom component for Home Assistant that intelligently adjusts the brightness and color of your lights 💡 based on the sun's position, while still allowing for manual control. Try it out now by finding it in HACS (Home Assistant Community Store) and installing it! +Adaptive Lighting is a custom component for [Home Assistant](https://www.home-assistant.io/) that intelligently adjusts the brightness and color of your lights 💡 based on the sun's position, while still allowing for manual control. + +Download and install directly through [HACS (Home Assistant Community Store)](https://hacs.xyz/) By automatically adapting the settings of your lights throughout the day, Adaptive Lighting helps maintain your natural circadian rhythm 😴, which can lead to improved sleep, mood, and overall well-being. Experience cooler color temperatures at noon, gradually transitioning to warmer colors at sunset and sunrise. @@ -58,7 +60,7 @@ The `adaptive_lighting.manual_control` event is fired when a light is marked as - [:sunny: Sun Position](#sunny-sun-position) - [:thermometer: Color Temperature](#thermometer-color-temperature) - [:high_brightness: Brightness](#high_brightness-brightness) - - [While using `adapt_until_sleep: true`](#while-using-adapt_until_sleep-true) + - [While using `transition_until_sleep: true`](#while-using-transition_until_sleep-true) - [:busts_in_silhouette: Contributors](#busts_in_silhouette-contributors) @@ -379,7 +381,7 @@ These graphs were generated using the values calculated by the Adaptive Lighting #### :high_brightness: Brightness ![cl_brightness|690x130](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/5/8/58ebd994b62a8b1abfb3497a5288d923ff4e2330.PNG) -#### While using `adapt_until_sleep: true` +#### While using `transition_until_sleep: true` ![image](https://user-images.githubusercontent.com/2219836/228949675-f9699624-8abc-466c-bb04-250ce0f495b8.png) @@ -421,7 +423,6 @@ These graphs were generated using the values calculated by the Adaptive Lighting Hudson Brendon
Hudson Brendon

🌍 Gabriel Visser
Gabriel Visser

📖 Gleb
Gleb

🌍 - Deleted user
Deleted user

🌍 Avi Miller
Avi Miller

📖 đŸ’ģ Denys Dovhan
Denys Dovhan

🌍 David Stenbeck
David Stenbeck

📖 From f5abf034c4653777d8c8b0e4e51b556caeb2660c Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Wed, 5 Apr 2023 11:13:57 -0500 Subject: [PATCH 18/26] Fix the docker tests instructions (#543) * Tested on multiple hardware Turns out windows 10 and 11 can't use `$(pwd):/app` OR `%cd%:/app` in PowerShell (which replaced cmd prompt), so I looked up the docs and made the necessary changes (again, sorry!) These changes have been tested on all terminal environments except macOS (the docs say it'll work there) * allow use of --exitfirst for faster debug * Remove install in actions --------- Co-authored-by: Bas Nijholt Co-authored-by: Bas Nijholt --- tests/README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/README.md b/tests/README.md index a576d518..1c472541 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,17 +5,15 @@ Alternatively, you can use the provided Docker image to run the tests locally. To run the tests using the Docker image, navigate to the `adaptive-lighting` repo folder and execute the following command: -Linux or MacOS: - +Linux / MacOS / Windows PowerShell: ```bash -docker run -v $(pwd):/app basnijholt/adaptive-lighting:latest +docker run -v ${PWD}:/app basnijholt/adaptive-lighting:latest ``` -Windows: - -```bash -docker run -v %cd%:/app basnijholt/adaptive-lighting:latest -``` +- In windows command prompt, the command is: + ```bash + docker run -v %cd%:/app basnijholt/adaptive-lighting:latest + ``` This command will download the Docker image from [the adaptive-lighting Docker Hub repo](https://hub.docker.com/r/basnijholt/adaptive-lighting) and run the tests. From 03a2d9cbf67964ec3cae59f4f9d6ace32d113d99 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Wed, 5 Apr 2023 18:39:31 -0500 Subject: [PATCH 19/26] Fix RGB Color Temp Swaps (#514) * cherry pick from 486 * Refactor `_add_missing_attributes` --------- Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/switch.py | 56 ++++++++++++++----- tests/test_switch.py | 27 +++++++-- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 52821774..347ee4cb 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -85,6 +85,7 @@ from homeassistant.util.color import ( color_RGB_to_xy, color_temperature_to_rgb, color_xy_to_hs, + color_xy_to_RGB, ) import homeassistant.util.dt as dt_util import voluptuous as vol @@ -628,6 +629,41 @@ def color_difference_redmean( return math.sqrt(red_term + green_term + blue_term) +# All comparisons should be done with RGB since +# converting anything to color temp is inaccurate. +def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]: + if ATTR_RGB_COLOR in attributes: + return attributes + + rgb = None + if ATTR_COLOR_TEMP_KELVIN in attributes: + rgb = color_temperature_to_rgb(attributes[ATTR_COLOR_TEMP_KELVIN]) + elif ATTR_XY_COLOR in attributes: + rgb = color_xy_to_RGB(*attributes[ATTR_XY_COLOR]) + + if rgb is not None: + attributes[ATTR_RGB_COLOR] = rgb + _LOGGER.debug(f"Converted {attributes} to rgb {rgb}") + else: + _LOGGER.debug("No suitable conversion found") + + return attributes + + +def _add_missing_attributes( + old_attributes: dict[str, Any], + new_attributes: dict[str, Any], +) -> dict[str, Any]: + if not any( + attr in old_attributes and attr in new_attributes + for attr in [ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR] + ): + old_attributes = _convert_attributes(old_attributes) + new_attributes = _convert_attributes(new_attributes) + + return old_attributes, new_attributes + + def _attributes_have_changed( light: str, old_attributes: dict[str, Any], @@ -636,6 +672,11 @@ def _attributes_have_changed( adapt_color: bool, context: Context, ) -> bool: + if adapt_color: + old_attributes, new_attributes = _add_missing_attributes( + old_attributes, new_attributes + ) + if ( adapt_brightness and ATTR_BRIGHTNESS in old_attributes @@ -690,21 +731,6 @@ def _attributes_have_changed( context.id, ) return True - - switched_color_temp = ( - ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes - ) - switched_to_rgb_color = ( - ATTR_COLOR_TEMP_KELVIN in old_attributes - and ATTR_COLOR_TEMP_KELVIN not in new_attributes - ) - if switched_color_temp or switched_to_rgb_color: - # Light switched from RGB mode to color_temp or visa versa - _LOGGER.debug( - "'%s' switched from RGB mode to color_temp or visa versa", - light, - ) - return True return False diff --git a/tests/test_switch.py b/tests/test_switch.py index 9e88b332..db4900dc 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -47,6 +47,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, + ATTR_XY_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.light import SERVICE_TURN_OFF @@ -873,10 +874,28 @@ def test_attributes_have_changed(): assert _attributes_have_changed( old_attributes=attributes_1, new_attributes=attrs, **kwargs ) - # Switch from rgb_color to color_temp - assert _attributes_have_changed( - old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 100}, - new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (0, 0, 0)}, + _LOGGER.debug("Test switch from color_temp to rgb_color") + assert not _attributes_have_changed( + old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702}, + new_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (255, 166, 87)}, + **kwargs, + ) + _LOGGER.debug("Test switch from rgb_color to color_temp") + assert not _attributes_have_changed( + old_attributes={ATTR_BRIGHTNESS: 1, ATTR_RGB_COLOR: (255, 166, 87)}, + new_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702}, + **kwargs, + ) + _LOGGER.debug("Test switch from color_temp to color_xy") + assert not _attributes_have_changed( + old_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702}, + new_attributes={ATTR_BRIGHTNESS: 1, ATTR_XY_COLOR: (0.526, 0.387)}, + **kwargs, + ) + _LOGGER.debug("Test switch from color_xy to color_temp") + assert not _attributes_have_changed( + old_attributes={ATTR_BRIGHTNESS: 1, ATTR_XY_COLOR: (0.526, 0.387)}, + new_attributes={ATTR_BRIGHTNESS: 1, ATTR_COLOR_TEMP_KELVIN: 2702}, **kwargs, ) From cb967aeeb7ab7e5ee3818587da8975a5ad3439cd Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Thu, 6 Apr 2023 13:53:23 -0500 Subject: [PATCH 20/26] Create intentionally over-redundant `state_change` tests and fix #541 (#544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add transition_timer test and debug * syntax error * test * Update switch.py * Revert "test" This reverts commit b8009e0a1a4c419ddb026a4545b38453158d9367. * Update test_switch.py * add `create_transition_events` to tests. nearly done * tests are done! * pop is for dictionaries * Update test_switch.py * combine the tests * pin markdown-code-runner * Pin with '==' * Update test_switch.py * pin in the correct place 😅 * Update test_switch.py * Use timer.is_running * Update test_switch.py * ensure timer is running in tests * this passes the test * Update test_switch.py * Do not create new list when not needed * Remove empty deps * Remove CONF_ULID_MAX_LENGTH (which is not configurable) * this shouldn't pass the test but it does. --------- Co-authored-by: Bas Nijholt Co-authored-by: Bas Nijholt --- .github/workflows/update-readme.yml | 2 +- custom_components/adaptive_lighting/switch.py | 64 +-- tests/test_switch.py | 396 ++++++++++++++---- 3 files changed, 346 insertions(+), 116 deletions(-) diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index bec8cb4c..1f661333 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -24,7 +24,7 @@ jobs: - name: Install markdown-code-runner and README code dependencies run: | - pip install markdown-code-runner pandas tabulate + pip install markdown-code-runner==1.0.0 pandas tabulate - name: Link custom_components/adaptive_lighting run: | diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 347ee4cb..dbf3c42b 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1102,7 +1102,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): return # See #80. Doesn't check if transitions differ but it does the job. last_service_data = self.turn_on_off_listener.last_service_data - if last_service_data.get(light) == service_data: + if not force and last_service_data.get(light) == service_data: _LOGGER.debug( "%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')", self._name, @@ -1167,14 +1167,23 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lights is None: lights = self._lights - if not force and self._only_once: - return - filtered_lights = [] - for light in lights: - # Don't adapt lights that haven't finished prior transitions. - if force or not self.turn_on_off_listener.transition_timers.get(light): - filtered_lights.append(light) + if not force: + if self._only_once: + return + for light in lights: + # Don't adapt lights that haven't finished prior transitions. + timer = self.turn_on_off_listener.transition_timers.get(light) + if timer is not None and timer.is_running(): + _LOGGER.debug( + "%s: Light '%s' is still transitioning", + self._name, + light, + ) + else: + filtered_lights.append(light) + else: + filtered_lights = lights if not filtered_lights: return @@ -1620,33 +1629,28 @@ class TurnOnOffListener: def start_transition_timer(self, light: str) -> None: """Mark a light as manually controlled.""" - _LOGGER.debug("Start transition timer for %s", light) - last_service_data = self.last_service_data - if ( - not last_service_data - or light not in last_service_data - or ATTR_TRANSITION not in last_service_data[light] - ): + last_service_data = self.last_service_data.get(light) + if not last_service_data: + _LOGGER.debug("This should not ever happen. Please report to the devs.") return - - delay = last_service_data[light][ATTR_TRANSITION] + last_transition = last_service_data.get(ATTR_TRANSITION) + if not last_transition: + _LOGGER.debug( + "No transition in last adapt for light %s, continuing...", light + ) + return + _LOGGER.debug( + "Start transition timer of %s seconds for light %s", last_transition, light + ) async def reset(): + ValueError("TEST") _LOGGER.debug( "Transition finished for light %s", light, ) - switches = _get_switches_with_lights(self.hass, [light]) - for switch in switches: - if not switch.is_on: - continue - await switch._update_attrs_and_maybe_adapt_lights( - [light], - force=False, - context=switch.create_context("transit"), - ) - self._handle_timer(light, self.transition_timers, delay, reset) + self._handle_timer(light, self.transition_timers, last_transition, reset) def set_auto_reset_manual_control_times(self, lights: list[str], time: float): """Set the time after which the lights are automatically reset.""" @@ -1769,7 +1773,7 @@ class TurnOnOffListener: async def state_changed_event_listener(self, event: Event) -> None: """Track 'state_changed' events.""" entity_id = event.data.get(ATTR_ENTITY_ID, "") - if entity_id not in self.lights or entity_id.split(".")[0] != LIGHT_DOMAIN: + if entity_id not in self.lights: return new_state = event.data.get("new_state") @@ -1814,6 +1818,10 @@ class TurnOnOffListener: entity_id, ) self.last_state_change[entity_id] = [new_state] + _LOGGER.debug( + "Last transition: %s", + self.last_service_data[entity_id].get(ATTR_TRANSITION), + ) self.start_transition_timer(entity_id) elif old_state is not None: self.last_state_change[entity_id].append(new_state) diff --git a/tests/test_switch.py b/tests/test_switch.py index db4900dc..6ba54a99 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1,9 +1,12 @@ """Tests for Adaptive Lighting switches.""" # pylint: disable=protected-access import asyncio +from copy import deepcopy import datetime import logging +from random import choices as random_choices from random import randint +import string from unittest.mock import patch from homeassistant.components.adaptive_lighting.const import ( @@ -47,6 +50,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR, + ATTR_TRANSITION, ATTR_XY_COLOR, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN @@ -61,6 +65,7 @@ from homeassistant.const import ( CONF_LIGHTS, CONF_NAME, CONF_PLATFORM, + EVENT_STATE_CHANGED, SERVICE_TURN_ON, STATE_OFF, STATE_ON, @@ -107,6 +112,11 @@ ENTITY_ADAPT_COLOR_SWITCH = f"{_SWITCH_FMT}_adapt_color_{DEFAULT_NAME}" ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE +GLOBAL_TEST_DEPENDENCIES = [ + "test_adaptive_lighting_switches", + "test_light_settings", +] + @pytest.fixture def reset_time_zone(): @@ -209,6 +219,69 @@ async def setup_lights_and_switch(hass, extra_conf=None): return switch, lights_instances +def create_random_context() -> str: + ulid_max_length = 26 # changed from 36->26 in core2023.4.0 + return Context( + id="".join( + random_choices(string.ascii_uppercase + string.digits, k=ulid_max_length) + ), + parent_id=None, + ) + + +# see https://github.com/home-assistant/core/blob/dev/homeassistant/scripts/benchmark/__init__.py +# basically just search the repo for EVENT_STATE_CHANGED look for how it's fired. +def create_transition_events( + light: str, + state: State, + last: dict | None = None, + current: dict | None = None, + total_events: int = 4, +) -> list[dict]: + assert light is not None + all_events = [] + for i in range(1, total_events): + # Build basic event data. + attributes = {} + + # The first state change always has the context from our integration. + # That one will not be in all_events. + # It's very possible it stores the parent_id though. + # If it stores the parent_id in all situations, there's a great improvement + # that could added in future updates. + + # Simulate the events the bulb would send to HASS. + last_brightness = last.get(ATTR_BRIGHTNESS) or state[ATTR_BRIGHTNESS] + current_brightness = current.get(ATTR_BRIGHTNESS) + if ( + last_brightness + and current_brightness + and last_brightness != current_brightness + ): + diff = (current_brightness - last_brightness) * (i / total_events) + attributes[ATTR_BRIGHTNESS] = last_brightness + diff + elif current_brightness: + attributes[ATTR_BRIGHTNESS] = current_brightness + current_kelvin = current.get(ATTR_COLOR_TEMP_KELVIN) + last_kelvin = last.get(ATTR_COLOR_TEMP_KELVIN) or state[ATTR_COLOR_TEMP_KELVIN] + if last_kelvin and current_kelvin and last_kelvin != current_kelvin: + diff = (current_kelvin - last_kelvin) * (i / total_events) + attributes[ATTR_COLOR_TEMP_KELVIN] = last_kelvin + diff + elif current_kelvin: + attributes[ATTR_COLOR_TEMP_KELVIN] = current_kelvin + + # Pack event + event_data = { + ATTR_ENTITY_ID: light, + "old_state": State(light, "on", attributes=last), + "new_state": State( + light, "on", attributes=attributes, context=create_random_context() + ), + } + all_events.append(event_data) + return all_events + + async def test_adaptive_lighting_switches(hass): """Test switches created for adaptive_lighting integration.""" entry, _ = await setup_switch(hass, {}) @@ -236,6 +309,7 @@ async def test_adaptive_lighting_switches(hass): @pytest.mark.parametrize("lat,long,timezone", LAT_LONG_TZS) +@pytest.mark.dependency("test_adaptive_lighting_switches") async def test_adaptive_lighting_time_zones_with_default_settings( hass, lat, long, timezone, reset_time_zone # pylint: disable=redefined-outer-name ): @@ -428,6 +502,7 @@ async def test_light_settings(hass): assert_expected_color_temp(state) +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_turn_on_off_listener_not_tracking_untracked_lights(hass): """Test that lights that are not in a Adaptive Lighting switch aren't tracked.""" switch, _ = await setup_lights_and_switch(hass) @@ -447,6 +522,7 @@ async def test_turn_on_off_listener_not_tracking_untracked_lights(hass): assert light not in switch.turn_on_off_listener.lights +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_manual_control(hass): """Test the 'manual control' tracking.""" switch, (light, *_) = await setup_lights_and_switch(hass) @@ -594,6 +670,7 @@ async def test_manual_control(hass): assert all([not manual_control[eid] for eid in switch._lights]) +@pytest.mark.dependency(depends=[*GLOBAL_TEST_DEPENDENCIES, "test_manual_control"]) async def test_auto_reset_manual_control(hass): switch, (light, *_) = await setup_lights_and_switch( hass, {CONF_AUTORESET_CONTROL: 0.1} @@ -638,6 +715,7 @@ async def test_auto_reset_manual_control(hass): assert not manual_control[light.entity_id] +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_apply_service(hass): """Test adaptive_lighting.apply service.""" switch, (_, _, light) = await setup_lights_and_switch(hass) @@ -701,6 +779,9 @@ async def test_apply_service(hass): assert old_state[ATTR_COLOR_TEMP_KELVIN] == new_state[ATTR_COLOR_TEMP_KELVIN] +@pytest.mark.dependency( + depends=[*GLOBAL_TEST_DEPENDENCIES, "test_apply_service", "test_manual_control"] +) async def test_switch_off_on_off(hass): """Test switch rapid off_on_off.""" @@ -751,85 +832,7 @@ async def test_switch_off_on_off(hass): assert state == STATE_OFF -async def test_significant_change(hass): - """Test significant change.""" - - async def turn_light(state, **kwargs): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON if state else SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, - blocking=True, - ) - await hass.async_block_till_done() - - async def update(force): - await switch._update_attrs_and_maybe_adapt_lights( - transition=0, - context=switch.create_context("test"), - force=force, - ) - await hass.async_block_till_done() - - async def set_brightness(val: int): - hass.states.async_set( - ENTITY_LIGHT, "on", {ATTR_BRIGHTNESS: val, ATTR_SUPPORTED_FEATURES: 1} - ) - await hass.async_block_till_done() - - switch, _ = await setup_lights_and_switch(hass) - _LOGGER.debug("Test detect_non_ha_changes:") - switch._take_over_control = True - assert switch._take_over_control - switch._detect_non_ha_changes = True - assert switch._detect_non_ha_changes - - # build last service data - await update(force=False) - - # force=True should not reset manual control. - await turn_light(True, brightness=40) - await turn_light(True, brightness=20) - await update(force=False) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - await update(force=True) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - - # turn light off then on should reset manual control. - await turn_light(False) - await turn_light(True) - assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - - # Assert last_service_data got filled from update() - await update(force=True) - assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None - - # Simulate a transition to 255 where the update() is already using brightness 255. - await set_brightness(240) - await set_brightness(244) - await set_brightness(247) - await set_brightness(250) - - # last_state_change should have our state changes. - # Change brightness by async_set (not using 'light.turn_on') - new_brightness = 50 - await set_brightness(new_brightness) - _LOGGER.debug("Test: Brightness set to %s", new_brightness) - - # mock homeassistant.core.HomeAssistant.helpers.entity_component.async_update_entity - # Otherwise what happens is update_entity() refreshes the state to the last call of - # light.turn_on(). This is because we are not using hass.states.async_set() to - # set the brightness of the light. We mock `async_update_ha_state` because - # `async_update_entity` calls it. - with patch("homeassistant.helpers.entity.Entity.async_update_ha_state"): - # On next update ENTITY_LIGHT should be marked as manually controlled - await update(force=False) - assert ( - switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None - ) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - - +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) def test_color_difference_redmean(): """Test color_difference_redmean function.""" for _ in range(10): @@ -839,14 +842,6 @@ def test_color_difference_redmean(): color_difference_redmean((0, 0, 0), (255, 255, 255)) -def test_is_our_context(): - """Test is_our_context function.""" - context = create_context(DOMAIN, "test", 0) - assert is_our_context(context) - assert not is_our_context(None) - assert not is_our_context(Context()) - - def test_attributes_have_changed(): """Test _attributes_have_changed function.""" attributes_1 = { @@ -900,6 +895,229 @@ def test_attributes_have_changed(): ) +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) +async def test_state_change_handlers(hass): + """ + Test TurnOnOffListener's EVENT_STATE_CHANGED listener. + ====================== + Sequence of events: + 1. Transition from sleep mode to normal. + 2. Create simulated transition events for that adapt. + 3. Fire all simulated transition events. + 4. Assert all possible problems that would result. + Also tests significant changes. + """ + switch, (light, *_) = await setup_lights_and_switch(hass) + context = switch.create_context("test") # needs to be passed to update method + + # [Config options]: + transition_used = 2 + total_events = 5 + + async def set_brightness(val: int): + # 'Unsafe' set but we know what we're doing. + hass.states.async_set( + ENTITY_LIGHT, "on", {ATTR_BRIGHTNESS: val, ATTR_SUPPORTED_FEATURES: 1} + ) + await hass.async_block_till_done() + # Call code in TurnOnOffListener + hass.bus.async_fire( + EVENT_STATE_CHANGED, + { + "new_state": { + ATTR_ENTITY_ID: ENTITY_LIGHT, + "state": "on", + ATTR_BRIGHTNESS: val, + } + }, + ) + await hass.async_block_till_done() + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + + async def update(force: bool = False): + await switch._update_attrs_and_maybe_adapt_lights( + force=force, transition=0, context=context + ) + await hass.async_block_till_done() + + # 1. Adapt to sleep without a transition. + # Should only be one state change. + _LOGGER.debug('test_state_change_handling: Turn on "sleep mode"') + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_SLEEP_MODE_SWITCH}, + blocking=True, + ) + await hass.async_block_till_done() + assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) + assert len(switch.turn_on_off_listener.last_state_change[ENTITY_LIGHT]) == 1 + assert not switch.turn_on_off_listener.transition_timers.get(ENTITY_LIGHT) + last_service_data = deepcopy(switch.turn_on_off_listener.last_service_data) + assert last_service_data.get(ENTITY_LIGHT) + + # 2 Adapt from sleep with a 'transition'. + await switch.sleep_mode_switch.async_turn_off() + await switch._update_attrs_and_maybe_adapt_lights( + force=False, transition=0, context=context + ) + await hass.async_block_till_done() + current_service_data = switch.turn_on_off_listener.last_service_data + assert current_service_data != last_service_data + + for light in switch._lights: + # current_service_data should have changed after the last update. + assert current_service_data.get(light) + assert last_service_data.get(light) + assert current_service_data[light] != last_service_data[light] + + # Test same context id events. + current_service_data[light][ATTR_TRANSITION] = transition_used + hass.bus.async_fire( + EVENT_STATE_CHANGED, + { + ATTR_ENTITY_ID: light, + "old_state": State(light, "on", attributes=last_service_data), + "new_state": State( + light, "on", attributes=current_service_data, context=context + ), + }, + ) + assert not switch.turn_on_off_listener.transition_timers.get(light) + + # 2.3 Refire and overwrite the original state_changed event with our 'transition' + hass.bus.async_fire( + EVENT_STATE_CHANGED, + { + ATTR_ENTITY_ID: light, + "old_state": State(light, "on", attributes=last_service_data), + "new_state": State( + light, + "on", + attributes=current_service_data, + # We need to overwrite the old context_id + context=switch.create_context("test"), + ), + }, + ) + await hass.async_block_till_done() + # Assert our transition timer was created. + assert switch.turn_on_off_listener.transition_timers.get(light) + # 2.5 Simulate a transition. There's no other way to do this in the demo. + events = create_transition_events( + light=light, + state=hass.states.get(light), + last=last_service_data[light], + current=current_service_data[light], + total_events=total_events, + ) + # 3. Fire simulated events for our TurnOnOffListener + for event in events: + _LOGGER.debug("Test EVENT_STATE_CHANGED listener") + hass.bus.async_fire(EVENT_STATE_CHANGED, event) + await hass.async_block_till_done() + # On real systems HA fires transition state changes every ~3 seconds. + # asyncio.sleep(3) + # 4. Assert the transition timer started and everything was filled. + listener = switch.turn_on_off_listener + assert listener.last_state_change.get(ENTITY_LIGHT) + assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events + assert listener.transition_timers.get(ENTITY_LIGHT) + + # 5. Execute some checks during a transition + _LOGGER.debug("Test detect_non_ha_changes:") + switch._take_over_control = True + assert switch._take_over_control + switch._detect_non_ha_changes = True + assert switch._detect_non_ha_changes + await asyncio.sleep(transition_used / 3) + # Ensure the timer still exists + timer = listener.transition_timers.get(ENTITY_LIGHT) + assert timer and timer.is_running() + last_service_data = deepcopy(current_service_data) + await update() + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await update() + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + timer = listener.transition_timers.get(ENTITY_LIGHT) + assert timer and timer.is_running() + # Ensure the light did not adapt during the transition. + assert last_service_data == current_service_data + + # 6. Assert everything after the transition finishes. + await asyncio.sleep(transition_used) + assert listener.last_state_change.get(ENTITY_LIGHT) + assert len(listener.last_state_change[ENTITY_LIGHT]) == total_events + # Timer should be done and reset now. + # This is the assert that I can't fix. + timer = listener.transition_timers.get(ENTITY_LIGHT) + assert not timer or not timer.is_running() + + # build last service data + await update(force=False) + + # force=True should not reset manual control. + await turn_light(True, brightness=40) + await turn_light(True, brightness=20) + await update(force=False) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + await update(force=True) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # turn light off then on should reset manual control. + await turn_light(False) + await turn_light(True) + assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + # last_state_change should have our state changes. + # Change brightness by async_set (not using 'light.turn_on') + new_brightness = 50 + await set_brightness(new_brightness) + _LOGGER.debug("Test: Brightness set to %s", new_brightness) + + # mock homeassistant.core.HomeAssistant.helpers.entity_component.async_update_entity + # Otherwise what happens is update_entity() refreshes the state to the last call of + # light.turn_on(). This is because we are not using hass.states.async_set() to + # set the brightness of the light. We mock `async_update_ha_state` because + # `async_update_entity` calls it. + with patch("homeassistant.helpers.entity.Entity.async_update_ha_state"): + # On next update ENTITY_LIGHT should be marked as manually controlled + await update(force=False) + assert ( + switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None + ) + assert ( + switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None + ) + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + + +@pytest.mark.dependency( + depends=[ + *GLOBAL_TEST_DEPENDENCIES, + "test_manual_control", + "test_apply_service", + "test_attributes_have_changed", + "test_state_change_handling", + ] +) +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) +def test_is_our_context(): + """Test is_our_context function.""" + context = create_context(DOMAIN, "test", 0) + assert is_our_context(context) + assert not is_our_context(None) + assert not is_our_context(Context()) + + async def test_unload_switch(hass): """Test removing Adaptive Lighting.""" entry, _ = await setup_switch(hass, {}) @@ -966,6 +1184,7 @@ async def test_turn_on_and_off_when_already_at_that_state(hass): await hass.async_block_till_done() +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_async_update_at_interval(hass): """Test '_async_update_at_interval' method.""" _, switch = await setup_switch(hass, {}) @@ -973,6 +1192,7 @@ async def test_async_update_at_interval(hass): @pytest.mark.parametrize("separate_turn_on_commands", (True, False)) +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_separate_turn_on_commands(hass, separate_turn_on_commands): """Test 'separate_turn_on_commands' argument.""" switch, (light, *_) = await setup_lights_and_switch( @@ -1009,6 +1229,7 @@ async def test_separate_turn_on_commands(hass, separate_turn_on_commands): assert sleep_color_temp != color_temp +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_area(hass): switch, (light, *_) = await setup_lights_and_switch(hass) @@ -1045,6 +1266,7 @@ async def test_area(hass): assert light.entity_id not in switch.turn_on_off_listener.last_service_data +@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES) async def test_change_switch_settings_service(hass): """Test adaptive_lighting.change_switch_settings service.""" switch, (_, _, light) = await setup_lights_and_switch(hass) From 59877a034340793ec1c8d70d42916ed5116d174d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 6 Apr 2023 12:37:02 -0700 Subject: [PATCH 21/26] Make sure context_id is 26 chars and partially conform to ULID standard (#550) --- .../adaptive_lighting/manifest.json | 2 +- custom_components/adaptive_lighting/switch.py | 72 +++++++++++++++---- tests/test_switch.py | 17 ++--- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 33564f23..166b3695 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://github.com/basnijholt/adaptive-lighting#readme", "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", - "requirements": [], + "requirements": ["ulid-transform"], "version": "1.10.0" } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index dbf3c42b..5b816b67 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -88,6 +88,7 @@ from homeassistant.util.color import ( color_xy_to_RGB, ) import homeassistant.util.dt as dt_util +import ulid_transform import voluptuous as vol from .const import ( @@ -182,21 +183,58 @@ BRIGHTNESS_ATTRS = { } # Keep a short domain version for the context instances (which can only be 36 chars) -_DOMAIN_SHORT = "adapt_lgt" +_DOMAIN_SHORT = "al" -def _int_to_bytes(i: int, signed: bool = False) -> bytes: - bits = i.bit_length() - if signed: - # Make room for the sign bit. - bits += 1 - return i.to_bytes((bits + 7) // 8, "little", signed=signed) +def _int_to_base36(num: int) -> str: + """ + Convert an integer to its base-36 representation using numbers and uppercase letters. + + Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive + alphanumeric representation. The function takes an integer `num` as input and returns + its base-36 representation as a string. + + Parameters + ---------- + num + The integer to convert to base-36. + + Returns + ------- + str + The base-36 representation of the input integer. + + Examples + -------- + >>> num = 123456 + >>> base36_num = int_to_base36(num) + >>> print(base36_num) + '2N9' + """ + ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + if num == 0: + return ALPHANUMERIC_CHARS[0] + + base36_str = "" + base = len(ALPHANUMERIC_CHARS) + + while num: + num, remainder = divmod(num, base) + base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str + + return base36_str def _short_hash(string: str, length: int = 4) -> str: """Create a hash of 'string' with length 'length'.""" - str_hash_bytes = _int_to_bytes(hash(string), signed=True) - return base64.b85encode(str_hash_bytes)[:length] + return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length] + + +def _remove_vowels(input_str: str, length: int = 4) -> str: + vowels = "aeiouAEIOU" + output_str = "".join([char for char in input_str if char not in vowels]) + return output_str.zfill(length)[:length] def create_context( @@ -204,12 +242,16 @@ def create_context( ) -> Context: """Create a context that can identify this integration.""" # Use a hash for the name because otherwise the context might become - # too long (max len == 36) to fit in the database. - name_hash = _short_hash(name) + # too long (max len == 26) to fit in the database. # Pack index with base85 to maximize the number of contexts we can create - # before we exceed the 36-character limit and are forced to wrap. - index_packed = base64.b85encode(_int_to_bytes(index, signed=False)) - context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36] + # before we exceed the 26-character limit and are forced to wrap. + time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID + name_hash = _short_hash(name) + which_short = _remove_vowels(which) + context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:" + chars_left = 26 - len(context_id_start) + index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:] + context_id = context_id_start + index_packed parent_id = parent.id if parent else None return Context(id=context_id, parent_id=parent_id) @@ -218,7 +260,7 @@ def is_our_context(context: Context | None) -> bool: """Check whether this integration created 'context'.""" if context is None: return False - return context.id.startswith(_DOMAIN_SHORT) + return f":{_DOMAIN_SHORT}:" in context.id def _split_service_data(service_data, adapt_brightness, adapt_color): diff --git a/tests/test_switch.py b/tests/test_switch.py index 6ba54a99..ae722b0d 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -4,9 +4,7 @@ import asyncio from copy import deepcopy import datetime import logging -from random import choices as random_choices from random import randint -import string from unittest.mock import patch from homeassistant.components.adaptive_lighting.const import ( @@ -76,6 +74,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.color import color_temperature_mired_to_kelvin import homeassistant.util.dt as dt_util import pytest +import ulid_transform import voluptuous.error from tests.common import MockConfigEntry, mock_area_registry @@ -118,6 +117,10 @@ GLOBAL_TEST_DEPENDENCIES = [ ] +def create_random_context() -> str: + return Context(id=ulid_transform.ulid_now(), parent_id=None) + + @pytest.fixture def reset_time_zone(): """Reset time zone.""" @@ -219,16 +222,6 @@ async def setup_lights_and_switch(hass, extra_conf=None): return switch, lights_instances -def create_random_context() -> str: - ulid_max_length = 26 # changed from 36->26 in core2023.4.0 - return Context( - id="".join( - random_choices(string.ascii_uppercase + string.digits, k=ulid_max_length) - ), - parent_id=None, - ) - - # see https://github.com/home-assistant/core/blob/dev/homeassistant/scripts/benchmark/__init__.py # basically just search the repo for EVENT_STATE_CHANGED look for how it's fired. def create_transition_events( From a01fec02113c46404d155205c9778913d640c3f0 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Thu, 6 Apr 2023 15:29:32 -0500 Subject: [PATCH 22/26] Bump to 1.10.1 (#551) * Update manifest.json * undo merge mistake * Version 1.10.1 --- custom_components/adaptive_lighting/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 166b3695..92b27dfa 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": ["ulid-transform"], - "version": "1.10.0" + "version": "1.10.1" } From c0c363136bcc90190081f1f6a6eff52ec6e25c21 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 6 Apr 2023 16:36:57 -0700 Subject: [PATCH 23/26] Test multiple Home Assistant releases and the dev branch (#552) * Test for multiple Home Assistant versions * Install ulid-transform * remove unnecessary unsafe `async_set` from test * Skip test_state_change_handlers in <2023.4 * Revert "Skip test_state_change_handlers in <2023.4" This reverts commit 8d01b6ec4ea8b97feb8dabf8b737be604755dffd. --------- Co-authored-by: Benjamin Auquite --- .../workflows/install_dependencies/action.yml | 12 ++++++--- .github/workflows/pytest.yaml | 5 +++- .github/workflows/update-readme.yml | 2 +- tests/test_switch.py | 27 +++++-------------- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml index 75820072..df0bee8a 100644 --- a/.github/workflows/install_dependencies/action.yml +++ b/.github/workflows/install_dependencies/action.yml @@ -1,10 +1,14 @@ name: 'Install Dependencies' description: 'Install Home Assistant and test dependencies' inputs: - python_version: + python-version: description: 'Python version' required: true default: '3.10' + core-version: + description: 'Home Assistant core version' + required: false + default: 'dev' runs: using: "composite" @@ -21,11 +25,12 @@ runs: with: repository: home-assistant/core path: core - - name: Set up Python ${{ inputs.python_version }} + ref: ${{ inputs.core-version }} + - name: Set up Python ${{ inputs.python-version }} id: python uses: actions/setup-python@v4.1.0 with: - python-version: ${{ inputs.python_version }} + python-version: ${{ inputs.python-version }} - name: Install dependencies shell: bash run: | @@ -33,4 +38,5 @@ runs: pip install -r core/requirements.txt --use-pep517 pip install -r core/requirements_test.txt --use-pep517 pip install -e core/ --use-pep517 + pip install ulid-transform # this is in Adaptive-lighting's manifest.json pip install $(python test_dependencies.py) --use-pep517 diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 02bf93e3..cea75602 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -11,8 +11,10 @@ jobs: runs-on: ubuntu-20.04 timeout-minutes: 60 strategy: + fail-fast: false matrix: python-version: ["3.10"] + core-version: ["2023.2.5", "2023.3.6", "2023.4.0", "dev"] steps: - name: Check out code from GitHub uses: actions/checkout@v3 @@ -20,7 +22,8 @@ jobs: - name: Install Home Assistant uses: ./.github/workflows/install_dependencies with: - python_version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} + core-version: ${{ matrix.core-version }} - name: Click here for troubleshooting steps if tests break again. run: | diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index 1f661333..2c2754fb 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -20,7 +20,7 @@ jobs: - name: Install Home Assistant uses: ./.github/workflows/install_dependencies with: - python_version: "3.10" + python-version: "3.10" - name: Install markdown-code-runner and README code dependencies run: | diff --git a/tests/test_switch.py b/tests/test_switch.py index ae722b0d..f6d671f4 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1070,27 +1070,14 @@ async def test_state_change_handlers(hass): await turn_light(True) assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] - # last_state_change should have our state changes. - # Change brightness by async_set (not using 'light.turn_on') - new_brightness = 50 - await set_brightness(new_brightness) - _LOGGER.debug("Test: Brightness set to %s", new_brightness) + await turn_light(True, brightness=50) + _LOGGER.debug("Test: Brightness set to %s", 50) - # mock homeassistant.core.HomeAssistant.helpers.entity_component.async_update_entity - # Otherwise what happens is update_entity() refreshes the state to the last call of - # light.turn_on(). This is because we are not using hass.states.async_set() to - # set the brightness of the light. We mock `async_update_ha_state` because - # `async_update_entity` calls it. - with patch("homeassistant.helpers.entity.Entity.async_update_ha_state"): - # On next update ENTITY_LIGHT should be marked as manually controlled - await update(force=False) - assert ( - switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None - ) - assert ( - switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None - ) - assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] + # On next update ENTITY_LIGHT should be marked as manually controlled + await update(force=False) + assert switch.turn_on_off_listener.last_service_data.get(ENTITY_LIGHT) is not None + assert switch.turn_on_off_listener.last_state_change.get(ENTITY_LIGHT) is not None + assert switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] @pytest.mark.dependency( From 39e9d0e74fde95c9d35505a63316c8728ae895a4 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Fri, 7 Apr 2023 17:40:36 -0700 Subject: [PATCH 24/26] Update issue templates (#557) --- .github/ISSUE_TEMPLATE/bug-report.md | 68 ++++++++++++++++++++++----- .github/ISSUE_TEMPLATE/doc.md | 7 ++- .github/ISSUE_TEMPLATE/enhancement.md | 7 ++- .github/ISSUE_TEMPLATE/feature.md | 7 ++- 4 files changed, 72 insertions(+), 17 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index c5bcb3a4..385259a6 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,17 +1,63 @@ --- -name: 'Bug Report' -about: 'Report a bug in adaptive-lighting.' -labels: kind/bug, need/triage +name: Bug Report +about: Report a bug in adaptive-lighting. +title: '' +labels: kind/bug, kind/feature, need/triage +assignees: '' + --- -#### Version information: +# Home Assistant Adaptive Lighting Issue Template + +## Bug Reports + +If you need help with using or configuring Adaptive Lighting, please [open a Q&A discussion thread here](https://github.com/basnijholt/adaptive-lighting/discussions/new?category=q-a) instead. + +### Before submitting a bug report, please follow these troubleshooting steps: + +Please confirm that you have completed the following steps: + +- [ ] I have updated to the [latest Adaptive Lighting version](https://github.com/basnijholt/adaptive-lighting/releases) available in [HACS](https://hacs.xyz/). +- [ ] I have reviewed the [Troubleshooting Section](https://github.com/basnijholt/adaptive-lighting#troubleshooting) in the [README](https://github.com/basnijholt/adaptive-lighting#readme). +- [ ] (If using Zigbee2MQTT) I have read the [Zigbee2MQTT troubleshooting guide](https://github.com/basnijholt/adaptive-lighting#zigbee2mqtt) in the [README](https://github.com/basnijholt/adaptive-lighting#readme). +- [ ] I have checked the [V2 Roadmap](https://github.com/basnijholt/adaptive-lighting/discussions/291) and [open issues](https://github.com/basnijholt/adaptive-lighting/issues) to ensure my issue isn't a duplicate. -#### Description: - +Please include the following information in your issue. + +*Issues missing this information may not be addressed.* + +1. **Debug logs** captured while the issue occurred. [See here for instructions on enabling debug logging](https://github.com/basnijholt/adaptive-lighting#troubleshooting): + +``` + +``` + +2. [Your Adaptive Lighting configuration](https://github.com/basnijholt/adaptive-lighting#gear-configuration): + +``` + +``` + +3. (If using Zigbee2MQTT), provide your configuration files (**remove all personal information before posting**): + - `devices.yaml` + - `groups.yaml` + - `configuration.yaml` âš ī¸; **Warning** _**REMOVE ALL of the PERSONAL INFORMATION BELOW before posting**_ âš ī¸; + - mqtt: `server`: + - mqtt: `user`: + - mqtt: `password`: + - advanced: `pan_id`: + - advanced: `network_key`: + - anything in `log_syslog` if you use this + - Brand and model number of problematic light(s) +``` + +``` + +4. Describe the bug and how to reproduce it: + + + +5. Steps to reproduce the behavior: diff --git a/.github/ISSUE_TEMPLATE/doc.md b/.github/ISSUE_TEMPLATE/doc.md index 98c9a008..f6458d77 100644 --- a/.github/ISSUE_TEMPLATE/doc.md +++ b/.github/ISSUE_TEMPLATE/doc.md @@ -1,7 +1,10 @@ --- -name: 'Documentation Issue' -about: 'Report missing, erroneous docs, broken links or propose new docs' +name: Documentation Issue +about: Report missing, erroneous docs, broken links or propose new docs +title: '' labels: kind/docs_issue, need/triage +assignees: '' + --- #### Location diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md index cc515a20..d25a9689 100644 --- a/.github/ISSUE_TEMPLATE/enhancement.md +++ b/.github/ISSUE_TEMPLATE/enhancement.md @@ -1,5 +1,8 @@ --- -name: 'Enhancement' -about: 'Suggest an improvement to an existing feature.' +name: Enhancement +about: Suggest an improvement to an existing feature. +title: '' labels: kind/enhancement, need/triage +assignees: '' + --- diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md index c4b787df..088b35e4 100644 --- a/.github/ISSUE_TEMPLATE/feature.md +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -1,5 +1,8 @@ --- -name: 'Feature' -about: 'Suggest a new feature' +name: Feature +about: Suggest a new feature +title: '' labels: kind/feature, need/triage +assignees: '' + --- From fe7bdd394014df77785bdf598a37e41bd3d3ae44 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 8 Apr 2023 03:39:08 -0700 Subject: [PATCH 25/26] Add auto_reset_time_remaining attribute (#558) * Add auto_reset_time_remaining attribute * fix attr * Add test --- custom_components/adaptive_lighting/switch.py | 13 +++++++++++++ tests/test_switch.py | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 5b816b67..884f74cd 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1014,6 +1014,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if self.turn_on_off_listener.manual_control.get(light) ] extra_state_attributes.update(self._settings) + timers = self.turn_on_off_listener.auto_reset_manual_control_timers + extra_state_attributes["autoreset_time_remaining"] = { + light: time + for light in self._lights + if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0 + } return extra_state_attributes def create_context( @@ -2106,3 +2112,10 @@ class _AsyncSingleShotTimer: if self.task: self.task.cancel() self.callback = None + + def remaining_time(self): + """Return the remaining time before the timer expires.""" + if self.start_time is not None: + elapsed_time = (dt_util.utcnow() - self.start_time).total_seconds() + return max(0, self.delay - elapsed_time) + return 0 diff --git a/tests/test_switch.py b/tests/test_switch.py index f6d671f4..476e83cc 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -692,9 +692,15 @@ async def test_auto_reset_manual_control(hass): await turn_light(True, brightness=1) await turn_light(True, brightness=10) assert manual_control[light.entity_id] + assert ( + switch.extra_state_attributes["autoreset_time_remaining"][light.entity_id] > 0 + ) await asyncio.sleep(0.3) # Should be enough time for auto reset await update() assert not manual_control[light.entity_id], (light, manual_control) + assert ( + light.entity_id not in switch.extra_state_attributes["autoreset_time_remaining"] + ) # Do a couple of quick changes and check that light is not reset for i in range(3): From e30b7debe551e58cc6a738f7d15a4bb75d9bb545 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 8 Apr 2023 20:54:06 -0500 Subject: [PATCH 26/26] Refactor `_adapt_lights` into `_update_manual_control_and_maybe_adapt` (#513) * Add auto_reset_manual_control with async timer * cherry-pick wait for transition stuff * Update switch.py * merge wait_for_transition * Update switch.py * not renamed in this branch yet. * Update switch.py * update tests * Update switch.py * Update switch.py * merge related fix * cleanup * Revert "cleanup" This reverts commit 3aa2f3242b06370c0f84248d2c2c21a2556bb5ec. * 0.1 sometimes fails the test * not in this pr yet * Update switch.py * Update switch.py * Update switch.py * Update switch.py * Update README.md, strings.json, and services.yaml * slight refactor * Update switch.py * Update switch.py * Possible refactor of _update_attrs_and_maybe_adapt_lights * cleaned up * Revert "cleaned up" This reverts commit 441cb1ff5cc45eb3438f7e367e8f22cea061ba73. * Possible refactor of _update_attrs_and_maybe_adapt_lights (#537) Co-authored-by: Benjamin Auquite * Revert "Revert "cleaned up"" This reverts commit 11b268148b098af4f790a6f41c8b87b14ea0748a. * remove bad merge conflict * revert permissions * Bump to 1.11.0 * Undo unrelated test changes * 'else' instead of 'continue' --------- Co-authored-by: Bas Nijholt Co-authored-by: github-actions[bot] Co-authored-by: Bas Nijholt --- .../adaptive_lighting/manifest.json | 2 +- custom_components/adaptive_lighting/switch.py | 91 ++++++++++--------- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 92b27dfa..2dec4c83 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": ["ulid-transform"], - "version": "1.10.1" + "version": "1.11.0" } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 884f74cd..98747a17 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -424,6 +424,7 @@ def _fire_manual_control_event( switch.entity_id, light, ) + switch.turn_on_off_listener.mark_as_manual_control(light) fire( f"{DOMAIN}.manual_control", {ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id}, @@ -519,7 +520,6 @@ async def async_setup_entry( all_lights = _expand_light_groups(switch.hass, lights) if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - switch.turn_on_off_listener.mark_as_manual_control(light) _fire_manual_control_event(switch, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) @@ -1088,9 +1088,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if lock is not None and lock.locked(): _LOGGER.debug("%s: '%s' is locked", self._name, light) return - service_data = {ATTR_ENTITY_ID: light} - features = _supported_features(self.hass, light) - if transition is None: transition = self._transition if adapt_brightness is None: @@ -1100,15 +1097,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if prefer_rgb_color is None: prefer_rgb_color = self._prefer_rgb_color - # Check transition == 0 to fix #378 - if "transition" in features and transition > 0: - service_data[ATTR_TRANSITION] = transition - # The switch might be off and not have _settings set. self._settings = self._sun_light_settings.get_settings( self.sleep_mode_switch.is_on, transition ) + # Build service data. + service_data = {ATTR_ENTITY_ID: light} + features = _supported_features(self.hass, light) + + # Check transition == 0 to fix #378 + if "transition" in features and transition > 0: + service_data[ATTR_TRANSITION] = transition if "brightness" in features and adapt_brightness: brightness = round(255 * self._settings["brightness_pct"] / 100) service_data[ATTR_BRIGHTNESS] = brightness @@ -1135,19 +1135,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"] context = context or self.create_context("adapt_lights") - if ( - self._take_over_control - and self._detect_non_ha_changes - and not force - and await self.turn_on_off_listener.significant_change( - self, - light, - adapt_brightness, - adapt_color, - context, - ) - ): - return + # See #80. Doesn't check if transitions differ but it does the job. last_service_data = self.turn_on_off_listener.last_service_data if not force and last_service_data.get(light) == service_data: @@ -1236,9 +1224,11 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): if not filtered_lights: return - await self._adapt_lights(filtered_lights, transition, force, context) + await self._update_manual_control_and_maybe_adapt( + filtered_lights, transition, force, context + ) - async def _adapt_lights( + async def _update_manual_control_and_maybe_adapt( self, lights: list[str], transition: int | None, @@ -1247,34 +1237,53 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): ) -> None: assert context is not None _LOGGER.debug( - "%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called", + "%s: '_update_manual_control_and_maybe_adapt(%s, %s, force=%s, context.id=%s)' called", self.name, lights, transition, force, context.id, ) + + adapt_brightness = self.adapt_brightness_switch.is_on + adapt_color = self.adapt_color_switch.is_on + for light in lights: if not is_on(self.hass, light): continue - if ( - self._take_over_control - and self.turn_on_off_listener.is_manually_controlled( + + manually_controlled = self.turn_on_off_listener.is_manually_controlled( + self, + light, + force, + adapt_brightness, + adapt_color, + ) + + significant_change = ( + self._detect_non_ha_changes + and not force + and await self.turn_on_off_listener.significant_change( self, light, - force, - self.adapt_brightness_switch.is_on, - self.adapt_color_switch.is_on, + adapt_brightness, + adapt_color, + context, ) - ): - _LOGGER.debug( - "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", - self._name, - light, - context.id, - ) - continue - await self._adapt_light(light, transition, force=force, context=context) + ) + + if self._take_over_control and (manually_controlled or significant_change): + if manually_controlled: + _LOGGER.debug( + "%s: '%s' is being manually controlled, stop adapting, context.id=%s.", + self._name, + light, + context.id, + ) + else: + _fire_manual_control_event(self, light, context) + else: + await self._adapt_light(light, transition, force=force, context=context) async def _sleep_mode_switch_state_event(self, event: Event) -> None: if not match_switch_state_event(event, (STATE_ON, STATE_OFF)): @@ -1900,7 +1909,7 @@ class TurnOnOffListener: ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manual_control = self.mark_as_manual_control(light) + manual_control = True _fire_manual_control_event(switch, light, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" @@ -1968,8 +1977,6 @@ class TurnOnOffListener: light, context.id, ) - self.mark_as_manual_control(light) - _fire_manual_control_event(switch, light, context, is_async=False) return True _LOGGER.debug( "%s: Light '%s' correctly matches our last adapt's service data, continuing..."