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 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 10/14] 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 11/14] 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 12/14] 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 441cb1ff5cc45eb3438f7e367e8f22cea061ba73 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 8 Apr 2023 13:06:28 -0500 Subject: [PATCH 13/14] cleaned up --- custom_components/adaptive_lighting/switch.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d410f28e..d241ad31 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1234,8 +1234,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): transition: int | None, force: bool, context: Context | None, - adapt_brightness: bool | None = None, - adapt_color: bool | None = None, ) -> None: assert context is not None _LOGGER.debug( @@ -1247,10 +1245,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) - if adapt_brightness is None: - adapt_brightness = self.adapt_brightness_switch.is_on - if adapt_color is None: - adapt_color = self.adapt_color_switch.is_on + 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): @@ -1282,7 +1278,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) ): - _fire_manual_control_event(self, light, context, is_async=False) + _fire_manual_control_event(self, light, context) continue await self._adapt_light(light, transition, force=force, context=context) From 11b268148b098af4f790a6f41c8b87b14ea0748a Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 8 Apr 2023 13:11:54 -0500 Subject: [PATCH 14/14] Revert "cleaned up" This reverts commit 441cb1ff5cc45eb3438f7e367e8f22cea061ba73. --- custom_components/adaptive_lighting/switch.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index d241ad31..d410f28e 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -1234,6 +1234,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): transition: int | None, force: bool, context: Context | None, + adapt_brightness: bool | None = None, + adapt_color: bool | None = None, ) -> None: assert context is not None _LOGGER.debug( @@ -1245,8 +1247,10 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context.id, ) - adapt_brightness = self.adapt_brightness_switch.is_on - adapt_color = self.adapt_color_switch.is_on + if adapt_brightness is None: + adapt_brightness = self.adapt_brightness_switch.is_on + if adapt_color is None: + adapt_color = self.adapt_color_switch.is_on for light in lights: if not is_on(self.hass, light): @@ -1278,7 +1282,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): context, ) ): - _fire_manual_control_event(self, light, context) + _fire_manual_control_event(self, light, context, is_async=False) continue await self._adapt_light(light, transition, force=force, context=context)