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: '' + --- 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 bec8cb4c..2c2754fb 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -20,11 +20,11 @@ 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: | - 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/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

📖 diff --git a/custom_components/adaptive_lighting/manifest.json b/custom_components/adaptive_lighting/manifest.json index 33564f23..92b27dfa 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": [], - "version": "1.10.0" + "requirements": ["ulid-transform"], + "version": "1.10.1" } diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 4817f984..5b42ee67 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -85,8 +85,10 @@ 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 ulid_transform import voluptuous as vol from .const import ( @@ -181,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( @@ -203,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) @@ -217,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): @@ -628,6 +671,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 +714,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 +773,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 @@ -1064,7 +1132,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # 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, @@ -1135,10 +1203,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): return for light in lights: # Don't adapt lights that haven't finished prior transitions. - if not self.turn_on_off_listener.transition_timers.get(light): + 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 @@ -1609,33 +1685,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.""" @@ -1758,7 +1829,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") @@ -1803,6 +1874,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/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. diff --git a/tests/test_switch.py b/tests/test_switch.py index dc4eed94..e750d206 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1,6 +1,7 @@ """Tests for Adaptive Lighting switches.""" # pylint: disable=protected-access import asyncio +from copy import deepcopy import datetime import logging from random import randint @@ -48,6 +49,8 @@ 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 from homeassistant.components.light import SERVICE_TURN_OFF @@ -61,6 +64,7 @@ from homeassistant.const import ( CONF_LIGHTS, CONF_NAME, CONF_PLATFORM, + EVENT_STATE_CHANGED, SERVICE_TURN_ON, STATE_OFF, STATE_ON, @@ -71,6 +75,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 @@ -107,6 +112,15 @@ 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", +] + + +def create_random_context() -> str: + return Context(id=ulid_transform.ulid_now(), parent_id=None) + @pytest.fixture def reset_time_zone(): @@ -210,6 +224,59 @@ async def setup_lights_and_switch(hass, extra_conf=None): return switch, lights_instances +# 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, {}) @@ -237,6 +304,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 ): @@ -429,6 +497,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) @@ -448,6 +517,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) @@ -595,6 +665,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.2} @@ -639,6 +710,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) @@ -702,6 +774,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.""" @@ -752,85 +827,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): @@ -840,14 +837,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 = { @@ -875,12 +864,240 @@ 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, + ) + + +@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] + + await turn_light(True, brightness=50) + _LOGGER.debug("Test: Brightness set to %s", 50) + + # 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): @@ -949,6 +1166,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, {}) @@ -956,6 +1174,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( @@ -992,6 +1211,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) @@ -1028,6 +1248,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)