From 2de4b7415b71c5fc9b7e5098f146ca4f15a815ed Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 29 Mar 2023 21:47:57 -0700 Subject: [PATCH 1/9] Refactor find_switch_for_lights and more small refactors (#488) * Refactor find_switch_for_lights * Refactor * style * renames --- custom_components/adaptive_lighting/switch.py | 153 ++++++++---------- 1 file changed, 65 insertions(+), 88 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index 895b4908..ef8b42aa 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -239,52 +239,50 @@ def _split_service_data(service_data, adapt_brightness, adapt_color): return service_datas -def _find_switch_with_any_of_lights( - hass: HomeAssistant, - lights: list[str], - service_call: ServiceCall, -) -> AdaptiveSwitch: - """Find the switch that controls the lights in 'lights'.""" +def _get_switches_with_lights( + hass: HomeAssistant, lights: list[str] +) -> list[AdaptiveSwitch]: + """Get all switches that control at least one of the lights passed.""" config_entries = hass.config_entries.async_entries(DOMAIN) data = hass.data[DOMAIN] - switches = {} + switches = [] for config in config_entries: - # this check is necessary as there seems to always be an extra config - # entry that doesn't contain any data. I believe this happens when the - # integration exists, but is disabled by the user in HASS. - if config.entry_id in data: - switch = data[config.entry_id]["instance"] - all_check_lights = _expand_light_groups(hass, lights) - switch._expand_light_groups() - if set(switch._lights) & set(all_check_lights): - switches[config.entry_id] = switch + entry = data.get(config.entry_id) + if entry is None: # entry might be disabled and therefore missing + continue + switch = data[config.entry_id]["instance"] + all_check_lights = _expand_light_groups(hass, lights) + switch._expand_light_groups() + # Check if any of the lights are in the switch's lights + if set(switch._lights) & set(all_check_lights): + switches.append(switch) + return switches + +def find_switch_for_lights( + hass: HomeAssistant, + lights: list[str], + is_on: bool = False, +) -> AdaptiveSwitch: + """Find the switch that controls the lights in 'lights'.""" + switches = _get_switches_with_lights(hass, lights, is_on) if len(switches) == 1: - return next(iter(switches.values())) - - if len(switches) > 1: - _LOGGER.error( - "Invalid service data: Light(s) %s found in multiple switch configs (%s)." - " You must pass a switch under 'entity_id'. See the README for" - " details. Got %s", - lights, - list(switches.keys()), - service_call.data, - ) + return switches[0] + elif len(switches) > 1: + on_switches = [s for s in switches if s.is_on] + if len(on_switches) == 1: + # Of the multiple switches, only one is on + return on_switches[0] raise ValueError( - "adaptive-lighting: Light(s) %s found in multiple switch configs.", - lights, + f"find_switch_for_lights: Light(s) {lights} found in multiple switch configs" + f" ({[s.entity_id for s in switches]}). You must pass a switch under" + f" 'entity_id'." ) else: - _LOGGER.error( - "Invalid service data: Light was not found in any of your switch's configs." - " You must either include the light(s) that is/are in the integration config, or" - " pass a switch under 'entity_id'. See the README for details. Got %s", - service_call.data, - ) raise ValueError( - "adaptive-lighting: Light(s) %s not found in any switch's configuration.", - lights, + f"find_switch_for_lights: Light(s) {lights} not found in any switch's" + f" configuration. You must either include the light(s) that is/are" + f" in the integration config, or pass a switch under 'entity_id'." ) @@ -293,38 +291,24 @@ def _find_switch_with_any_of_lights( def _get_switches_from_service_call( hass: HomeAssistant, service_call: ServiceCall ) -> list[AdaptiveSwitch]: - _LOGGER.debug( - "Function '_get_switches_from_service_call' called with service data:\n'%s'", - service_call.data, - ) data = service_call.data lights = data[CONF_LIGHTS] switch_entity_ids: list[str] | None = data.get("entity_id") + if not lights and not switch_entity_ids: - _LOGGER.debug( - "If you intended to adapt every single light on every single switch, please inform the" - " developers at https://github.com/basnijholt/adaptive-lighting of your use case." - " Currently, you must pass either an adaptive-lighting switch or the lights to" - " an `adaptive_lighting` service call." - ) - _LOGGER.error( - "Invalid service data passed to adaptive-lighting service call -" - " you must pass either a switch or a light's entity ID. Service data:\n%s", - service_call.data, - ) raise ValueError( - "adaptive-lighting: No switch or light was passed to service call." + "adaptive-lighting: Neither a switch nor a light was provided in the service call." + " If you intend to adapt all lights on all switches, please inform the developers at" + " https://github.com/basnijholt/adaptive-lighting about your use case." + " Currently, you must pass either an adaptive-lighting switch or the lights to an" + " `adaptive_lighting` service call." ) if switch_entity_ids is not None: if len(switch_entity_ids) > 1 and lights: - _LOGGER.error( - "Invalid service data: cannot pass multiple switch entities while also passing" - " lights. Service data received: %s", - service_call.data, - ) raise ValueError( - "adaptive-lighting: Multiple switches were passed with lights argument" + f"adaptive-lighting: Cannot pass multiple switches with lights argument." + f" Invalid service data received: {service_call.data}" ) switches = [] ent_reg = entity_registry.async_get(hass) @@ -335,20 +319,13 @@ def _get_switches_from_service_call( return switches if lights: - switch = _find_switch_with_any_of_lights(hass, lights, service_call) - _LOGGER.debug( - "Switch '%s' found for lights '%s'", - switch.entity_id, - lights, - ) + switch = find_switch_for_lights(hass, lights, service_call) return [switch] - _LOGGER.error( - "Invalid service data passed to adaptive-lighting service call -" - " entities were not found in the integration. Service data:\n%s", - service_call.data, + raise ValueError( + f"adaptive-lighting: Incorrect data provided in service call." + f" Entities not found in the integration. Service data: {service_call.data}" ) - raise ValueError("adaptive-lighting: User sent incorrect data to service call") async def handle_change_switch_settings( @@ -457,24 +434,24 @@ async def async_setup_entry( "Called 'adaptive_lighting.apply' service with '%s'", data, ) - these_switches = _get_switches_from_service_call(hass, service_call) + switches = _get_switches_from_service_call(hass, service_call) lights = data[CONF_LIGHTS] - for this_switch in these_switches: + for switch in switches: if not lights: - all_lights = this_switch._lights # pylint: disable=protected-access + all_lights = switch._lights # pylint: disable=protected-access else: - all_lights = _expand_light_groups(this_switch.hass, lights) - this_switch.turn_on_off_listener.lights.update(all_lights) + all_lights = _expand_light_groups(switch.hass, lights) + switch.turn_on_off_listener.lights.update(all_lights) for light in all_lights: if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light): - await this_switch._adapt_light( # pylint: disable=protected-access + await switch._adapt_light( # pylint: disable=protected-access light, data[CONF_TRANSITION], data[ATTR_ADAPT_BRIGHTNESS], data[ATTR_ADAPT_COLOR], data[CONF_PREFER_RGB_COLOR], force=True, - context=this_switch.create_context( + context=switch.create_context( "service", parent=service_call.context ), ) @@ -487,26 +464,26 @@ async def async_setup_entry( "Called 'adaptive_lighting.set_manual_control' service with '%s'", data, ) - these_switches = _get_switches_from_service_call(hass, service_call) + switches = _get_switches_from_service_call(hass, service_call) lights = data[CONF_LIGHTS] - for this_switch in these_switches: + for switch in switches: if not lights: - all_lights = this_switch._lights # pylint: disable=protected-access + all_lights = switch._lights # pylint: disable=protected-access else: - all_lights = _expand_light_groups(this_switch.hass, lights) + all_lights = _expand_light_groups(switch.hass, lights) if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - this_switch.turn_on_off_listener.manual_control[light] = True - _fire_manual_control_event(this_switch, light, service_call.context) + switch.turn_on_off_listener.manual_control[light] = True + _fire_manual_control_event(switch, light, service_call.context) else: - this_switch.turn_on_off_listener.reset(*all_lights) - if this_switch.is_on: + switch.turn_on_off_listener.reset(*all_lights) + if switch.is_on: # pylint: disable=protected-access - await this_switch._update_attrs_and_maybe_adapt_lights( + await switch._update_attrs_and_maybe_adapt_lights( all_lights, - transition=this_switch._initial_transition, + transition=switch._initial_transition, force=True, - context=this_switch.create_context( + context=switch.create_context( "service", parent=service_call.context ), ) From 26c19525cd40e27ac5129fb689da6162cfe6293c Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 30 Mar 2023 13:58:29 -0700 Subject: [PATCH 2/9] Rewrite the README (#492) * Rewrite the README * More changes * More * Rewrites * Test collapse * test * Fix * backticks * Rewrite options * emoji headings * chore(docs): update TOC --------- Co-authored-by: basnijholt --- .github/workflows/toc.yaml | 10 ++ README.md | 275 +++++++++++++++++++++---------------- 2 files changed, 167 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/toc.yaml diff --git a/.github/workflows/toc.yaml b/.github/workflows/toc.yaml new file mode 100644 index 00000000..28dac912 --- /dev/null +++ b/.github/workflows/toc.yaml @@ -0,0 +1,10 @@ +on: push +name: TOC Generator +jobs: + generateTOC: + name: TOC Generator + runs-on: ubuntu-latest + steps: + - uses: technote-space/toc-generator@v4 + with: + TOC_TITLE: "" diff --git a/README.md b/README.md index c4e3c66e..ac7897e0 100644 --- a/README.md +++ b/README.md @@ -4,43 +4,67 @@ [![All Contributors](https://img.shields.io/badge/all_contributors-46-orange.svg?style=flat-square)](#contributors-) -# Automatically adapt the brightness and color of lights based on the sun position and take over manual control +# 🌞 Adaptive Lighting: Enhance Your Home's Atmosphere with Smart, Sun-Synchronized Lighting πŸŒ™ ![](https://github.com/home-assistant/brands/raw/b4a168b9af282ef916e120d31091ecd5e3c35e66/core_integrations/adaptive_lighting/icon.png) -_Try out this code by adding https://github.com/basnijholt/adaptive-lighting to your custom repos in [HACS (Home Assistant Community Store)](https://hacs.xyz/) and install it!_ +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! +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. -The `adaptive_lighting` platform changes the settings of your lights throughout the day. -It uses the position of the sun to calculate the color temperature and brightness that is most fitting for that time of the day. -Scientific research has shown that this helps to maintain your natural circadian rhythm (your biological clock) and might lead to improved sleep, mood, and general well-being. +In addition to its regular mode, Adaptive Lighting also offers a "sleep mode" 🌜 which sets your lights to minimal brightness and a very warm color, perfect for winding down at night. -In practical terms, this means that after the sun sets, the brightness of your lights will decrease to a certain minimum brightness, while the color temperature will be at its coolest color temperature at noon, after which it will decrease and reach its warmest color at sunset. -Around sunrise, the opposite will happen. +[[ToC](#books-table-of-contents)] -Additionally, the integration provides a way to define and set your lights in "sleep mode". -When "sleep mode" is enabled, the lights will be at a minimal brightness and have a very warm color. +## :bulb: Features -The integration creates 4 switches (in this example the component's name is `"living_room"`): -1. `switch.adaptive_lighting_living_room`, which turns the Adaptive Lighting integration on or off. It has several attributes that show the current light settings. -2. `switch.adaptive_lighting_sleep_mode_living_room`, which when activated, turns on "sleep mode" (you can set a specific `sleep_brightness` and `sleep_color_temp`). -3. `switch.adaptive_lighting_adapt_brightness_living_room`, which sets whether the integration should adapt the brightness of the lights (if supported by the light). -4. `switch.adaptive_lighting_adapt_color_living_room`, which sets whether the integration should adapt the color of the lights (if supported by the light). +Adaptive Lighting provides four switches (using "living_room" as an example component name): -## Taking back control +- `switch.adaptive_lighting_living_room`: Turn Adaptive Lighting on or off and view current light settings through its attributes. +- `switch.adaptive_lighting_sleep_mode_living_room`: Activate "sleep mode" 😴 and set custom sleep_brightness and sleep_color_temp. +- `switch.adaptive_lighting_adapt_brightness_living_room`: Enable or disable brightness adaptation πŸ”† for supported lights. +- `switch.adaptive_lighting_adapt_color_living_room`: Enable or disable color adaptation 🌈 for supported lights. -Although having your lights automatically adapt is great most of the time, there might be times at which you want to set the lights to a different color/brightness and keep it that way. -For this purpose, the integration (when `take_over_control` is enabled) automatically detects whether someone (e.g., person toggling the light switch) or something (automation) changes the lights. -If this happens *and* the light is already on, the light that was changed gets marked as "manually controlled" and the Adaptive Lighting component will stop adapting that light until it turns off and on again (or if you use the service call `adaptive_lighting.set_manual_control`). -This mechanism works by listening to all `light.turn_on` calls that change the color or brightness and by noting that the component did not make the call. -Additionally, there is an option to detect all state changes (when `detect_non_ha_changes` is enabled), so also changes to the lights that were not made by a `light.turn_on` call (e.g., through an app or via something outside of Home Assistant.) -It does this by comparing a light's state to Adaptive Lighting's previously used settings. -Whenever a light gets marked as "manually controlled", an `adaptive_lighting.manual_control` event is fired, such that one can use this information in automations. +### :control_knobs: Regain Manual Control -## Configuration +Adaptive Lighting is designed to automatically detect when you or another source (e.g., automation) manually changes light settings πŸ•ΉοΈ. +When this occurs, the affected light is marked as "manually controlled," and Adaptive Lighting will not make further adjustments until the light is turned off and back on or reset using the `adaptive_lighting.set_manual_control` service call. +This feature is available when take_over_control is enabled. -This integration is both fully configurable through YAML _and_ the frontend. (**Configuration** -> **Integrations** -> **Adaptive Lighting**, **Adaptive Lighting** -> **Options**) -Here, the options in the frontend and in YAML have the same names. +Additionally, enabling detect_non_ha_changes allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings. +The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations πŸ€–. + +## :books: Table of Contents + + + + + - [:gear: Configuration](#gear-configuration) + - [:memo: Options](#memo-options) + - [:hammer_and_wrench: Services](#hammer_and_wrench-services) + - [`adaptive_lighting.apply`](#adaptive_lightingapply) + - [`adaptive_lighting.set_manual_control`](#adaptive_lightingset_manual_control) + - [`adaptive_lighting.change_switch_settings`](#adaptive_lightingchange_switch_settings) + - [:robot: Automation examples](#robot-automation-examples) +- [Additional Information](#additional-information) +- [Troubleshooting](#troubleshooting) + - [:exclamation: Common Problems & Solutions](#exclamation-common-problems--solutions) + - [:bulb: Lights Not Responding or Turning On by Themselves](#bulb-lights-not-responding-or-turning-on-by-themselves) + - [:signal_strength: WiFi Networks](#signal_strength-wifi-networks) + - [:spider_web: Zigbee, Z-Wave, and Other Mesh Networks](#spider_web-zigbee-z-wave-and-other-mesh-networks) + - [:rainbow: Light Colors Not Matching](#rainbow-light-colors-not-matching) + - [:bulb: Bulb-Specific Issues](#bulb-bulb-specific-issues) + - [:bar_chart: Graphs!](#bar_chart-graphs) + - [:sunny: Sun Position](#sunny-sun-position) + - [:thermometer: Color Temperature](#thermometer-color-temperature) + - [:high_brightness: Brightness](#high_brightness-brightness) + - [:busts_in_silhouette: Contributors](#busts_in_silhouette-contributors) + + + +## :gear: Configuration + +Adaptive Lighting supports configuration through both YAML and the frontend (**Configuration** -> **Integrations** -> **Adaptive Lighting**, **Adaptive Lighting** -> **Options**), with identical option names in both methods. ```yaml # Example configuration.yaml entry @@ -49,37 +73,40 @@ adaptive_lighting: - light.living_room_lights ``` -### Options -| option | description | required | default | type | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------- | ------- | -| `name` | The name to use when displaying this switch. | False | default | string | -| `include_config_in_attributes` | When set to `true`, will list all of the below options as attributes on the switch in Home Assistant. | False | False | boolean | -| `lights` | List of light entities for Adaptive Lighting to control (may be empty). | False | list | [] | -| `prefer_rgb_color` | Whether to use RGB color adjustment instead of native light color temperature. | False | False | boolean | -| `initial_transition` | How long the first transition is when the lights go from `off` to `on`. | False | 1 | time | -| `sleep_transition` | How long the transition is when when "sleep mode" is toggled | False | 1 | time | -| `transition` | How long the transition is when the lights change, in seconds. | False | 45 | integer | -| `interval` | How often to adapt the lights, in seconds. | False | 90 | integer | -| `min_brightness` | The minimum percent of brightness to set the lights to. | False | 1 | integer | -| `max_brightness` | The maximum percent of brightness to set the lights to. | False | 100 | integer | -| `min_color_temp` | The warmest color temperature to set the lights to, in Kelvin. | False | 2000 | integer | -| `max_color_temp` | The coldest color temperature to set the lights to, in Kelvin. | False | 5500 | integer | -| `sleep_brightness` | Brightness of lights while the sleep mode is enabled. | False | 1 | integer | -| `sleep_rgb_or_color_temp` | Use either 'rgb_color' or 'color_temp' when in sleep mode. | False | 'color_temp' | string | -| `sleep_rgb_color` | List of three numbers between 0-255, indicating the RGB color in sleep mode (only used when sleep_rgb_or_color_temp is 'rgb_color'). | False | `[255, 56, 0]` | list | -| `sleep_color_temp` | Color temperature of lights while the sleep mode is enabled (only used when sleep_rgb_or_color_temp is 'color_temp'). | False | 1000 | integer | -| `sunrise_time` | Override the sunrise time with a fixed time. | False | None | time | -| `max_sunrise_time` | Make the virtual sun always rise at at most a specific time while still allowing for even earlier times based on the real sun | False | None | time | -| `sunrise_offset` | Change the sunrise time with a positive or negative offset. | False | 0 | time | -| `sunset_time` | Override the sunset time with a fixed time. | False | None | time | -| `min_sunset_time` | Make the virtual sun always set at at least a specific time while still allowing for even later times based on the real sun | False | None | time | -| `sunset_offset` | Change the sunset time with a positive or negative offset. | False | 0 | time | -| `only_once` | Whether to keep adapting the lights (false) or to only adapt the lights as soon as they are turned on (true). | False | False | boolean | -| `take_over_control` | If another source calls `light.turn_on` while the lights are on and being adapted, disable Adaptive Lighting. | False | True | boolean | -| `detect_non_ha_changes` | Whether to detect state changes and stop adapting lights, even not from `light.turn_on`. Needs `take_over_control` to be enabled. Note that by enabling this option, it calls 'homeassistant.update_entity' every 'interval'! | False | False | boolean | -| `separate_turn_on_commands` | Whether to use separate `light.turn_on` calls for color and brightness, needed for some types of lights | False | False | boolean | -| `send_split_delay` | Wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly. | False | 0 | integer | -| `adapt_delay` | Wait time in seconds between light turn on, and Adaptive Lights applying changes to the light state. May avoid flickering. | False | 0 | integer | +Transform your home's atmosphere with Adaptive Lighting 🏠, and experience the benefits of intelligent, sun-synchronized lighting today! + +### :memo: Options + +| Option | Description | Required | Default | Type | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------- | --------- | +| `name` | Display name for this switch. | ❌ | `default` | `string` | +| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. | ❌ | `False` | `boolean` | +| `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | ❌ | `list` | `list` | +| `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | ❌ | `False` | `boolean` | +| `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. ⏲️ | ❌ | `1` | `time` | +| `sleep_transition` | Duration of transition when "sleep mode" is toggled. 😴 | ❌ | `1` | `time` | +| `transition` | Duration of transition when lights change, in seconds. | ❌ | `45` | `integer` | +| `interval` | Frequency to adapt the lights, in seconds. | ❌ | `90` | `integer` | +| `min_brightness` | Minimum brightness percentage. πŸ’‘ | ❌ | `1` | `integer` | +| `max_brightness` | Maximum brightness percentage. πŸ’‘ | ❌ | `100` | `integer` | +| `min_color_temp` | Warmest color temperature in Kelvin. πŸ”₯ | ❌ | `2000` | `integer` | +| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | ❌ | `5500` | `integer` | +| `sleep_brightness` | Brightness of lights in sleep mode. 😴 | ❌ | `1` | `integer` | +| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. | ❌ | `'color_temp'` | `string` | +| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is `"rgb_color"`). 🌈 | ❌ | `[255, 56, 0]` | `list` | +| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`). 😴 | ❌ | `1000` | `integer` | +| `sunrise_time` | Set a fixed time for sunrise. πŸŒ… | ❌ | `None` | `time` | +| `max_sunrise_time` | Set the latest virtual sunrise time, allowing for earlier real sunrises. πŸŒ… | ❌ | `None` | `time` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset. ⏰ | ❌ | `0` | `time` | +| `sunset_time` | Set a fixed time for sunset. πŸŒ‡ | ❌ | `None` | `time` | +| `min_sunset_time` | Set the earliest virtual sunset time, allowing for later real sunsets. πŸŒ‡ | ❌ | `None` | `time` | +| `sunset_offset` | Adjust sunset time with a positive or negative offset. ⏰ | ❌ | `0` | `time` | +| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | ❌ | `False` | `boolean` | +| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! πŸ”’ | ❌ | `True` | `boolean` | +| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. πŸ•΅οΈ | ❌ | `False` | `boolean` | +| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | ❌ | `False` | `boolean` | +| `send_split_delay` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. ⏲️ | ❌ | `0` | `integer` | +| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. ⏲️ | ❌ | `0` | `integer` | Full example: @@ -108,46 +135,54 @@ adaptive_lighting: ``` -### Services +### :hammer_and_wrench: Services + +#### `adaptive_lighting.apply` `adaptive_lighting.apply` applies Adaptive Lighting settings to lights on demand. -| Service data attribute | Optional | Description | +| Service data attribute | Required | Description | | ---------------------- | -------- | -------------------------------------------------------------------------------------------- | -| `entity_id` | no | The `entity_id` of the switch with the settings to apply. | -| `lights` | yes | A light (or list of lights) to apply the settings to. | -| `transition` | yes | The number of seconds for the transition. | -| `adapt_brightness` | yes | Whether to change the brightness of the light or not. | -| `adapt_color` | yes | Whether to adapt the color on supporting lights. | -| `prefer_rgb_color` | yes | Whether to prefer RGB color adjustment over of native light color temperature when possible. | -| `turn_on_lights` | yes | Whether to turn on lights that are currently off. | +| `entity_id` | βœ… | The `entity_id` of the switch with the settings to apply. | +| `lights` | ❌ | A light (or list of lights) to apply the settings to. | +| `transition` | ❌ | The number of seconds for the transition. | +| `adapt_brightness` | ❌ | Whether to change the brightness of the light or not. | +| `adapt_color` | ❌ | Whether to adapt the color on supporting lights. | +| `prefer_rgb_color` | ❌ | Whether to prefer RGB color adjustment over of native light color temperature when possible. | +| `turn_on_lights` | ❌ | Whether to turn on lights that are currently off. | + +#### `adaptive_lighting.set_manual_control` `adaptive_lighting.set_manual_control` can mark (or unmark) whether a light is "manually controlled", meaning that when a light has `manual_control`, the light is not adapted. -| Service data attribute | Optional | Description | +| Service data attribute | Required | Description | | ---------------------- | -------- | --------------------------------------------------------------------------------------------------- | -| `entity_id` | no | The `entity_id` of the switch in which to (un)mark the light as being "manually controlled". | -| `lights` | yes | entity_id(s) of lights, if not specified, all lights in the switch are selected. | -| `manual_control` | yes | Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true | +| `entity_id` | βœ… | The `entity_id` of the switch in which to (un)mark the light as being "manually controlled". | +| `lights` | ❌ | entity_id(s) of lights, if not specified, all lights in the switch are selected. | +| `manual_control` | ❌ | Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true | + +#### `adaptive_lighting.change_switch_settings` `adaptive_lighting.change_switch_settings` (new in 1.7.0) Change any of the above configuration options of Adaptive Lighting (such as `sunrise_time` or `prefer_rgb_color`) with a service call directly from your script/automation. -| Service data attribute | Description | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `use_defaults` | (default: 'current' for current settings) You can set this to 'factory', 'configuration', or 'current' to reset the variables not being set with this service call. 'current' leaves them as is, 'configuration' resets to whatever already initializes at startup, 'factory' resets to the default values listed in the documentation. | -| all other keys except the ones in the table below | See above, you may call `adaptive_lighting.apply` with your lights or create a new config instead | +| Service data attribute | Required | Description | +| --------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `use_defaults` | ❌ | (default: `current` for current settings) Choose from `factory`, `configuration`, or `current` to reset variables not being set with this service call. `current` leaves them as they are, `configuration` resets to initial startup values, `factory` resets to default values listed in the documentation. | +| **all other keys** (except the ones in the table below ⚠️) | ❌ | See the table below for disallowed keys. | +The following keys are disallowed: -| **DISALLOWED** service data | Description | -| --------------------------- | ------------------------------------------------------------------------------------------------- | -| `entity_id` | You cannot change the switch's `entity_id`, it's already been registered | -| `lights` | See above, you may call `adaptive_lighting.apply` with your lights or create a new config instead | -| `name` | See above. You can already rename your switch's display name in Home Assistant's UI. | -| `interval` | The interval is only used once when the config loads. A config change and restart is required | +| **DISALLOWED** service data | Description | +| --------------------------- | ----------------------------------------------------------------------------------------------- | +| `entity_id` | You cannot change the switch's `entity_id`, as it has already been registered. | +| `lights` | You may call `adaptive_lighting.apply` with your lights or create a new config instead. | +| `name` | You can rename your switch's display name in Home Assistant's UI. | +| `interval` | The interval is used only once when the config loads. A config change and restart are required. | -## Automation examples +## :robot: Automation examples -Reset the `manual_control` status of a light after an hour. +
+Reset the manual_control status of a light after an hour. ```yaml - alias: "Adaptive lighting: reset manual_control after 1 hour" @@ -169,7 +204,10 @@ Reset the `manual_control` status of a light after an hour. manual_control: false ``` -Toggle multiple Adaptive Lighting switches to "sleep mode" using an `input_boolean.sleep_mode`. +
+ +
+Toggle multiple Adaptive Lighting switches to "sleep mode" using an input_boolean.sleep_mode. ```yaml - alias: "Adaptive lighting: toggle 'sleep mode'" @@ -189,7 +227,7 @@ Toggle multiple Adaptive Lighting switches to "sleep mode" using an `input_boole Set your sunrise and sunset time based on your alarm. The below script sets sunset_time exactly 12 hours after the custom sunrise time. -``` +```yaml iphone_carly_wakeup: alias: iPhone Carly Wakeup sequence: @@ -238,79 +276,80 @@ iphone_carly_wakeup: max: 10 ``` -# Other +
-See the documentation of the PR at https://deploy-preview-14877--home-assistant-docs.netlify.app/integrations/adaptive_lighting/ and [this video on Reddit](https://www.reddit.com/r/homeassistant/comments/jabhso/ha_has_it_before_apple_has_even_finished_it_i/) to see how to add the integration and set the options. +# Additional Information -This integration was originally based of the great work of @claytonjn https://github.com/claytonjn/hass-circadian_lighting, but has been 100% rewritten and extended with new features. +For more details on adding the integration and setting options, refer to the [documentation of the PR](https://deploy-preview-14877--home-assistant-docs.netlify.app/integrations/adaptive_lighting/) and [this video tutorial on Reddit](https://www.reddit.com/r/homeassistant/comments/jabhso/ha_has_it_before_apple_has_even_finished_it_i/). -# Having problems? +Adaptive Lighting was initially inspired by @claytonjn's [hass-circadian\_lighting](https://github.com/claytonjn/hass-circadian_lighting), but has since been entirely rewritten and expanded with new features. + +# Troubleshooting + +Encountering issues? Enable debug logging in your `configuration.yaml`: -Please enable debug logging by putting this in `configuration.yaml`: ```yaml logger: default: warning logs: custom_components.adaptive_lighting: debug ``` -and after the problem occurs please create an issue with the log (`/config/home-assistant.log`). -## Lights are not responding or turning on by themselves +After the issue occurs, create a new issue report with the log (`/config/home-assistant.log`). -This addon sends many more commands to lights compared to what humans would typically send. If the network used to send light commands is not healthy: +## :exclamation: Common Problems & Solutions -- Manual commands like turning lights on or off may feel laggy. -- Lights may not respond to commands at all. -- Home Assistant may think a light is on, when it's actually off. Adaptive Lights will send it's regular adjustments causing the light to turn on after it's turned off. +### :bulb: Lights Not Responding or Turning On by Themselves -What's important is that many bugs that seem to be caused by this integration are really due to other unrelated issues. Fixing those will make your Home Assistant experience much better. Consider this integration a great stress test of your Home Assistant setup! +Adaptive Lighting sends more commands to lights than a typical human user would. If your light control network is unhealthy, you may experience: -### Wifi networks +- Laggy manual commands (e.g., turning lights on or off). +- Unresponsive lights. +- Home Assistant reporting incorrect light states, causing Adaptive Lighting to inadvertently turn lights back on. -Make sure bulbs have a solid connection to your Wifi network. In general, if the signal is less than -70dBm, the connection is weak and may drop messages. +Most issues that appear to be caused by Adaptive Lighting are actually due to unrelated problems. Addressing these issues will significantly improve your Home Assistant experience. -### Zigbee, Z-Wave, and other mesh networks +#### :signal_strength: WiFi Networks -These types of mesh networks usually need powered devices that act as routers (that repeat messages) back to the central coordinator (the radio connected to Home Assistant). Most Philips lights are routers, but Ikea, Sengled, and generic Tuya bulbs often are not. If devices become unavailable or miss responding to commands, Adaptive Lighting will only make things worse. Use reporting tools such as network maps (available in ZHA, zigbee2mqtt, deCONZ, and ZWaveJS UI) to check your network. Smart plugs are often a cost-effective way to add additional routers to your network. +Ensure your light bulbs have a strong WiFi connection. If the signal strength is less than -70dBm, the connection may be weak and prone to dropping messages. -For most Zigbee networks, groups are **absolutely required for good performance**. For example, imagine you want to use Adaptive Lighting in a hallway with 6 bulbs. If you add each individual bulb in the Adaptive Lighting configuration, then six individual commands will be sent to adjust them, which can eventually overwhelm a network. Instead, create a group in your Zigbee software (but _not_ a regular Home Assistant group), and add the one group to the Adaptive Lighting configuration. This will send only a single broadcast command to adjust the bulbs, giving much better response times and keeping the bulbs adjusting in sync with each other. +#### :spider_web: Zigbee, Z-Wave, and Other Mesh Networks -A good rule to follow is that if you always control lights together (like bulbs in a ceiling fixture), then they should be in a Zigbee group. Then, only expose the group (and not individual bulbs) in Home Assistant Dashboards and external systems like Google Home or Apple HomeKit. +Mesh networks typically require powered devices to act as routers, relaying messages back to the central coordinator (the radio connected to Home Assistant). Philips lights usually function as routers, while Ikea, Sengled, and generic Tuya bulbs often do not. If devices become unresponsive or fail to respond to commands, Adaptive Lighting can exacerbate the issue. Use network maps (available in ZHA, zigbee2mqtt, deCONZ, and ZWaveJS UI) to evaluate your network health. Smart plugs can be an affordable way to add more routers to your network. -### Light colors are not matching +For most Zigbee networks, **using groups is essential for optimal performance**. For example, if you want to use Adaptive Lighting in a hallway with six bulbs, adding each bulb individually to the Adaptive Lighting configuration could overwhelm the network with commands. Instead, create a group in your Zigbee software (not a regular Home Assistant group) and add that single group to the Adaptive Lighting configuration. This sends a single broadcast command to adjust all bulbs, improving response times and keeping the bulbs in sync. -Bulbs made by different manufacturers or of different models may have different specifications for the color temperatures they support. For example you have two Adaptive Lighting configurations: +As a rule of thumb, if you always control lights together (e.g., bulbs in a ceiling fixture), they should be in a Zigbee group. Expose only the group (not individual bulbs) in Home Assistant Dashboards and external systems like Google Home or Apple HomeKit. -- The first configuration has only Philips Hue White Ambiance bulbs. -- The second has the a few of the same model of White Ambiance bulbs as well as a few Sengled bulbs. +### :rainbow: Light Colors Not Matching -Even with identical settings, the Philips Hue bulbs may appear to have different color temperatures set at the same time. +Bulbs from different manufacturers or models may have varying color temperature specifications. For instance, if you have two Adaptive Lighting configurationsβ€”one with only Philips Hue White Ambiance bulbs and another with a mix of Philips Hue White Ambiance and Sengled bulbsβ€”the Philips Hue bulbs may appear to have different color temperatures despite having identical settings. -To avoid this: +To resolve this: -1. Only put bulbs of the same make and model in a single Adaptive Lighting configuration. -2. Move where bulbs are installed so you can't see different light temperatures at the same time. +1. Include only bulbs of the same make and model in a single Adaptive Lighting configuration. +2. Rearrange bulbs so that different color temperatures are not visible simultaneously. -### Bulb-specific issues +### :bulb: Bulb-Specific Issues -Some bulbs have buggy behaviour with long light transition commands. +Certain bulbs may have issues with long light transition commands: -- [Sengled Z01-A19NAE26](https://www.zigbee2mqtt.io/devices/Z01-A19NAE26.html#sengled-z01-a19nae26): If Adaptive lighting sends a long transition time (like the default 45 seconds), and the bulb is turned off in that time, it will turn itself back on after 10 seconds or so to continue the transition command. Since the bulb is turning itself on, there will be no obvious trigger in Home Assistant or other logs showing what caused the light to turn on. Fix this by setting a much shorter transition time such as 1 second. -- As well, the same bulbs peform poorly when in typical enclosed "dome" style ceiling lights. When hot, their performance becomes marginal at best. While most LEDs (even non-smart ones) say in the small print that they do not support working in enclosed fixtures, in practice more expensive bulbs like Philips Hue perform better. Fix this by moving suspect bulbs to open-air fixtures. +- [Sengled Z01-A19NAE26](https://www.zigbee2mqtt.io/devices/Z01-A19NAE26.html#sengled-z01-a19nae26): If Adaptive Lighting sends a long transition time (like the default 45 seconds), and the bulb is turned off during that time, it may turn back on after approximately 10 seconds to continue the transition command. Since the bulb is turning itself on, there will be no obvious trigger in Home Assistant or other logs indicating the cause of the light turning on. To fix this, set a much shorter transition time, such as 1 second. +- Additionally, these bulbs may perform poorly in enclosed "dome" style ceiling lights, particularly when hot. While most LEDs (even non-smart ones) state in the fine print that they do not support working in enclosed fixtures, in practice, more expensive bulbs like Philips Hue generally perform better. To resolve this issue, move the problematic bulbs to open-air fixtures. -## Graphs! +## :bar_chart: Graphs! These graphs were generated using the values calculated by the Adaptive Lighting sensor/switch(es). -#### Sun Position: +#### :sunny: Sun Position ![cl_percent|690x131](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/6/5/657ff98beb65a94598edeb4bdfd939095db1a22c.PNG) -#### Color Temperature: +#### :thermometer: Color Temperature ![cl_color_temp|690x129](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/5/9/59e84263cbecd8e428cb08777a0413672c48dfcd.PNG) -#### Brightness: +#### :high_brightness: Brightness ![cl_brightness|690x130](https://community-home-assistant-assets.s3.dualstack.us-west-2.amazonaws.com/original/3X/5/8/58ebd994b62a8b1abfb3497a5288d923ff4e2330.PNG) -## Contributors +## :busts_in_silhouette: Contributors From ec558e5616dd659ed30915643f4f55ea3896fe87 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sat, 1 Apr 2023 18:35:01 -0500 Subject: [PATCH 3/9] Move `include_config_in_attributes` --- custom_components/adaptive_lighting/switch.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index ef8b42aa..c94ab32a 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -771,16 +771,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): # Set in self._update_attrs_and_maybe_adapt_lights self._settings: dict[str, Any] = {} - self._config: dict[str, Any] = {} - if self._include_config_in_attributes: - attrdata = deepcopy(data) - for k, v in attrdata.items(): - if isinstance(v, (datetime.date, datetime.datetime)): - attrdata[k] = v.isoformat() - if isinstance(v, (datetime.timedelta)): - attrdata[k] = v.total_seconds() - self._config.update(attrdata) - # Set and unset tracker in async_turn_on and async_turn_off self.remove_listeners = [] _LOGGER.debug( @@ -811,6 +801,16 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES] self._include_config_in_attributes = data[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] + self._config: dict[str, Any] = {} + if self._include_config_in_attributes: + attrdata = deepcopy(data) + for k, v in attrdata.items(): + if isinstance(v, (datetime.date, datetime.datetime)): + attrdata[k] = v.isoformat() + if isinstance(v, (datetime.timedelta)): + attrdata[k] = v.total_seconds() + self._config.update(attrdata) + self._initial_transition = data[CONF_INITIAL_TRANSITION] self._sleep_transition = data[CONF_SLEEP_TRANSITION] self._only_once = data[CONF_ONLY_ONCE] From cc8ce29a8a096251888f2e69536a1c7363b43376 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sat, 1 Apr 2023 23:43:20 -0700 Subject: [PATCH 4/9] Auto-generate the configuration options documentation (#498) * Autogenerate the documentation and strings * Line limit * Split * Happy pre-commit * Split up workflows * Generate table function * raise * Add gen script * chore(docs): update TOC * Add gen script * path * Run - name: Install Home Assistant * chore(docs): update TOC * add copyright * Fix branch name * Run on PRs * dev * remove steps * use matrix * Run script * chore(docs): update TOC * Try fixing CI * fix * test * refactor * chore(docs): update TOC * fix * update * use v3 * rename * Fix input * fix pytest * fix path * paths * Change to github.head_ref * More backticks * Add extra text * chore(docs): update TOC * backticks * Update README.md --------- Co-authored-by: basnijholt Co-authored-by: github-actions[bot] --- .github/update-readme.py | 195 ++++++++++++++++++ .github/workflows/docker-build.yml | 8 +- .../workflows/install_dependencies/action.yml | 36 ++++ .github/workflows/pytest.yaml | 24 +-- .github/workflows/update-readme.yml | 44 ++++ README.md | 74 ++++--- custom_components/adaptive_lighting/const.py | 157 +++++++++++++- 7 files changed, 486 insertions(+), 52 deletions(-) create mode 100644 .github/update-readme.py create mode 100644 .github/workflows/install_dependencies/action.yml create mode 100644 .github/workflows/update-readme.yml diff --git a/.github/update-readme.py b/.github/update-readme.py new file mode 100644 index 00000000..ebeb9401 --- /dev/null +++ b/.github/update-readme.py @@ -0,0 +1,195 @@ +# Copyright (c) 2023, Bas Nijholt +# All rights reserved. +# When using this code, please cite the original source. +# and include the LICENSE file in your project. +"""Automatically update Markdown files with code block output. + +Add code blocks between and in your Markdown file. +The output will be inserted between and . + +Example: +------- +``` + + + + +This will be replaced by the output of the code block above. + + +``` +""" +from __future__ import annotations + +import contextlib +import io +from pathlib import Path + + +def md_comment(text: str) -> str: + """Format a string as a Markdown comment.""" + return f"" + + +MARKERS = { + "warning": md_comment("THIS CONTENT IS AUTOMATICALLY GENERATED"), + "start_code": md_comment("START_CODE"), + "end_code": md_comment("END_CODE"), + "start_output": md_comment("START_OUTPUT"), + "end_output": md_comment("END_OUTPUT"), +} + + +def remove_md_comment(commented_text: str) -> str: + """Remove Markdown comment tags from a string.""" + if not (commented_text.startswith("")): + raise ValueError("Invalid Markdown comment format") + return commented_text[5:-4] + + +def execute_code_block(code: list[str]) -> list[str]: + """Execute a code block and return its output as a list of strings.""" + f = io.StringIO() + with contextlib.redirect_stdout(f): + exec("\n".join(code)) # noqa: S102 + return f.getvalue().split("\n") + + +def process_markdown(content: list[str]) -> list[str]: + """Executes code blocks in a list of Markdown-formatted strings and returns the modified list. + + Parameters + ---------- + content + A list of Markdown-formatted strings. + + Returns + ------- + list[str] + A modified list of Markdown-formatted strings with code block output inserted. + """ + assert isinstance(content, list), "Input must be a list" + new_lines = [] + code = [] + in_code_block = in_output_block = False + output = None + + for line in content: + if MARKERS["start_code"] in line: + in_code_block = True + elif MARKERS["start_output"] in line: + in_output_block = True + new_lines.extend([line, MARKERS["warning"]] + output) + output = None + elif MARKERS["end_output"] in line: + in_output_block = False + elif in_code_block: + if MARKERS["end_code"] in line: + in_code_block = False + output = execute_code_block(code) + code = [] + else: + code.append(remove_md_comment(line)) + + if not in_output_block: + new_lines.append(line) + + return new_lines + + +def update_markdown_file(filepath: Path) -> None: + """Rewrite a Markdown file by executing and updating code blocks.""" + with filepath.open() as f: + original_lines = [line.rstrip("\n") for line in f.readlines()] + + new_lines = process_markdown(original_lines) + updated_content = "\n".join(new_lines).rstrip() + "\n" + + with filepath.open("w") as f: + f.write(updated_content) + + +def test_process_markdown(): + def assert_process(input_lines, expected_output): + output = process_markdown(input_lines) + assert output == expected_output, f"Expected {expected_output}, got {output}" + + # Test case 1: Single code block + input_lines = [ + "Some text", + MARKERS["start_code"], + md_comment("print('Hello, world!')"), + MARKERS["end_code"], + MARKERS["start_output"], + "This content will be replaced", + MARKERS["end_output"], + "More text", + ] + expected_output = [ + "Some text", + MARKERS["start_code"], + md_comment("print('Hello, world!')"), + MARKERS["end_code"], + MARKERS["start_output"], + MARKERS["warning"], + "Hello, world!", + "", + MARKERS["end_output"], + "More text", + ] + assert_process(input_lines, expected_output) + + # Test case 2: Two code blocks + input_lines = [ + "Some text", + MARKERS["start_code"], + md_comment("print('Hello, world!')"), + MARKERS["end_code"], + MARKERS["start_output"], + "This content will be replaced", + MARKERS["end_output"], + "More text", + MARKERS["start_code"], + md_comment("print('Hello again!')"), + MARKERS["end_code"], + MARKERS["start_output"], + "This content will also be replaced", + MARKERS["end_output"], + ] + expected_output = [ + "Some text", + MARKERS["start_code"], + md_comment("print('Hello, world!')"), + MARKERS["end_code"], + MARKERS["start_output"], + MARKERS["warning"], + "Hello, world!", + "", + MARKERS["end_output"], + "More text", + MARKERS["start_code"], + md_comment("print('Hello again!')"), + MARKERS["end_code"], + MARKERS["start_output"], + MARKERS["warning"], + "Hello again!", + "", + MARKERS["end_output"], + ] + assert_process(input_lines, expected_output) + + # Test case 3: No code blocks + input_lines = [ + "Some text", + "More text", + ] + expected_output = [ + "Some text", + "More text", + ] + assert_process(input_lines, expected_output) + + +if __name__ == "__main__": + test_process_markdown() + update_markdown_file(Path(__file__).parent.parent / "README.md") diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index cf1866fb..7326175a 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -9,6 +9,11 @@ on: jobs: docker: runs-on: ubuntu-latest + strategy: + matrix: + platform: + - linux/amd64 + - linux/arm64 steps: - name: Set up QEMU uses: docker/setup-qemu-action@v2 @@ -24,6 +29,5 @@ jobs: with: # Only push on the master branch push: ${{ github.ref == 'refs/heads/master' }} - # TODO: fix builds on linux/arm/v7 - platforms: linux/amd64,linux/arm64 + platforms: ${{ matrix.platform }} tags: ${{ secrets.DOCKERHUB_USERNAME }}/adaptive-lighting:latest diff --git a/.github/workflows/install_dependencies/action.yml b/.github/workflows/install_dependencies/action.yml new file mode 100644 index 00000000..75820072 --- /dev/null +++ b/.github/workflows/install_dependencies/action.yml @@ -0,0 +1,36 @@ +name: 'Install Dependencies' +description: 'Install Home Assistant and test dependencies' +inputs: + python_version: + description: 'Python version' + required: true + default: '3.10' + +runs: + using: "composite" + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3 + with: + repository: ${{ github.repository }} + ref: ${{ github.ref }} + persist-credentials: false + fetch-depth: 0 + - name: Check out code from GitHub + uses: actions/checkout@v3 + with: + repository: home-assistant/core + path: core + - name: Set up Python ${{ inputs.python_version }} + id: python + uses: actions/setup-python@v4.1.0 + with: + python-version: ${{ inputs.python_version }} + - name: Install dependencies + shell: bash + run: | + echo "::warning::### WARNING! Deprecation warnings muted with option '--use-pep517' please address this at some point in pytest.yaml. ###" + 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 $(python test_dependencies.py) --use-pep517 diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 60d9577f..47d4df8a 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -6,7 +6,6 @@ on: pull_request: jobs: - pytest: name: Run pytest runs-on: ubuntu-20.04 @@ -16,17 +15,13 @@ jobs: python-version: ["3.10"] steps: - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 - - name: Check out code from GitHub - uses: actions/checkout@v3.0.2 + uses: actions/checkout@v3 + + - name: Install Home Assistant + uses: ./.github/workflows/install_dependencies with: - repository: home-assistant/core - path: core - - name: Set up Python ${{ matrix.python-version }} - id: python - uses: actions/setup-python@v4.1.0 - with: - python-version: ${{ matrix.python-version }} + python_version: ${{ matrix.python-version }} + - name: Click here for troubleshooting steps if tests break again. run: | echo "::notice::### If tests fail, try these debug steps: ###" @@ -36,13 +31,6 @@ jobs: echo "::notice::### 4. ERROR:homeassistant.setup:Setup failed for 'component': Unable to import component: No module named ''module'' ###" echo "::notice::### 5. add 'component'.'module' (without the '') from the above log into the 'required' list inside of 'test_dependencies.py' ###" echo "::notice::### 6. Try again! If more issues persist they should be easily solvable by reading the verbose logs now. ###" - - name: Install dependencies - run: | - echo "::warning::### WARNING! Deprecation warnings muted with option '--use-pep517' please address this at some point in pytest.yaml. ###" - 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 $(python test_dependencies.py) --use-pep517 - name: Run pytest timeout-minutes: 60 run: | diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml new file mode 100644 index 00000000..36a994a9 --- /dev/null +++ b/.github/workflows/update-readme.yml @@ -0,0 +1,44 @@ +name: Update README.md + +on: + push: + branches: + - master + paths: + - ".github/update-readme.py" + - "README.md" + - ".github/workflows/update-readme.yml" + - "custom_components/adaptive_lighting/const.py" + pull_request: + +jobs: + update_readme: + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3 + + - name: Install Home Assistant + uses: ./.github/workflows/install_dependencies + with: + python_version: "3.10" + + - name: Install pandas and tabulate + run: | + pip install pandas tabulate + + - name: Run update-readme.py + run: python ./.github/update-readme.py + + - name: Commit updated README.md + run: | + git add README.md + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git diff --quiet && git diff --staged --quiet || git commit -m "Update README.md" + + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ github.head_ref }} diff --git a/README.md b/README.md index ac7897e0..7cad61a3 100644 --- a/README.md +++ b/README.md @@ -77,36 +77,50 @@ Transform your home's atmosphere with Adaptive Lighting 🏠, and experience the ### :memo: Options -| Option | Description | Required | Default | Type | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------- | --------- | -| `name` | Display name for this switch. | ❌ | `default` | `string` | -| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. | ❌ | `False` | `boolean` | -| `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | ❌ | `list` | `list` | -| `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | ❌ | `False` | `boolean` | -| `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. ⏲️ | ❌ | `1` | `time` | -| `sleep_transition` | Duration of transition when "sleep mode" is toggled. 😴 | ❌ | `1` | `time` | -| `transition` | Duration of transition when lights change, in seconds. | ❌ | `45` | `integer` | -| `interval` | Frequency to adapt the lights, in seconds. | ❌ | `90` | `integer` | -| `min_brightness` | Minimum brightness percentage. πŸ’‘ | ❌ | `1` | `integer` | -| `max_brightness` | Maximum brightness percentage. πŸ’‘ | ❌ | `100` | `integer` | -| `min_color_temp` | Warmest color temperature in Kelvin. πŸ”₯ | ❌ | `2000` | `integer` | -| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | ❌ | `5500` | `integer` | -| `sleep_brightness` | Brightness of lights in sleep mode. 😴 | ❌ | `1` | `integer` | -| `sleep_rgb_or_color_temp` | Use either `"rgb_color"` or `"color_temp"` in sleep mode. | ❌ | `'color_temp'` | `string` | -| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is `"rgb_color"`). 🌈 | ❌ | `[255, 56, 0]` | `list` | -| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`). 😴 | ❌ | `1000` | `integer` | -| `sunrise_time` | Set a fixed time for sunrise. πŸŒ… | ❌ | `None` | `time` | -| `max_sunrise_time` | Set the latest virtual sunrise time, allowing for earlier real sunrises. πŸŒ… | ❌ | `None` | `time` | -| `sunrise_offset` | Adjust sunrise time with a positive or negative offset. ⏰ | ❌ | `0` | `time` | -| `sunset_time` | Set a fixed time for sunset. πŸŒ‡ | ❌ | `None` | `time` | -| `min_sunset_time` | Set the earliest virtual sunset time, allowing for later real sunsets. πŸŒ‡ | ❌ | `None` | `time` | -| `sunset_offset` | Adjust sunset time with a positive or negative offset. ⏰ | ❌ | `0` | `time` | -| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | ❌ | `False` | `boolean` | -| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! πŸ”’ | ❌ | `True` | `boolean` | -| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. πŸ•΅οΈ | ❌ | `False` | `boolean` | -| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | ❌ | `False` | `boolean` | -| `send_split_delay` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. ⏲️ | ❌ | `0` | `integer` | -| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. ⏲️ | ❌ | `0` | `integer` | +All of the configuration options are listed below, along with their default values. +The YAML and frontend configuration methods support all of the options listed below. + + + + + + + + + + + +| Variable name | Description | Default | Type | +|:-------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------|:-------------------------------------| +| `lights` | List of light entities to be controlled by Adaptive Lighting (may be empty). 🌟 | `[]` | list of `entity_id`s | +| `prefer_rgb_color` | Use RGB color adjustment instead of native light color temperature. 🌈 | `False` | `bool` | +| `include_config_in_attributes` | Show all options as attributes on the switch in Home Assistant when set to `true`. πŸ“ | `False` | `bool` | +| `initial_transition` | Duration of the first transition when lights turn from `off` to `on`. ⏲️ | `1` | `float` 0-6553 | +| `sleep_transition` | Duration of transition when 'sleep mode' is toggled. 😴 | `1` | `float` 0-6553 | +| `transition` | Duration of transition when lights change, in seconds. πŸ•‘ | `45` | `float` 0-6553 | +| `interval` | Frequency to adapt the lights, in seconds. πŸ”„ | `90` | `int > 0` | +| `min_brightness` | Minimum brightness percentage. πŸ’‘ | `1` | `int` 1-100 | +| `max_brightness` | Maximum brightness percentage. πŸ’‘ | `100` | `int` 1-100 | +| `min_color_temp` | Warmest color temperature in Kelvin. πŸ”₯ | `2000` | `int` 1000-10000 | +| `max_color_temp` | Coldest color temperature in Kelvin. ❄️ | `5500` | `int` 1000-10000 | +| `sleep_brightness` | Brightness of lights in sleep mode. 😴 | `1` | `int` 1-100 | +| `sleep_rgb_or_color_temp` | Use either `'rgb_color'` or `'color_temp'` in sleep mode. πŸŒ™ | `color_temp` | one of `['color_temp', 'rgb_color']` | +| `sleep_color_temp` | Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`). 😴 | `1000` | `int` 1000-10000 | +| `sleep_rgb_color` | RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is 'rgb_color'). 🌈 | `[255, 56, 0]` | RGB color | +| `sunrise_time` | Set a fixed time for sunrise. πŸŒ… | `None` | `str` | +| `max_sunrise_time` | Set the latest virtual sunrise time, allowing for earlier real sunrises. πŸŒ… | `None` | `str` | +| `sunrise_offset` | Adjust sunrise time with a positive or negative offset. ⏰ | `0` | `int` | +| `sunset_time` | Set a fixed time for sunset. πŸŒ‡ | `None` | `str` | +| `min_sunset_time` | Set the earliest virtual sunset time, allowing for later real sunsets. πŸŒ‡ | `None` | `str` | +| `sunset_offset` | Adjust sunset time with a positive or negative offset. ⏰ | `0` | `int` | +| `only_once` | Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). πŸ”„ | `False` | `bool` | +| `take_over_control` | Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! πŸ”’ | `True` | `bool` | +| `detect_non_ha_changes` | Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. πŸ•΅οΈ | `False` | `bool` | +| `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | +| `send_split_delay` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. ⏲️ | `0` | `int` 0-10000 | +| `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. ⏲️ | `0` | `float > 0` | + + Full example: diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index f4b751e5..421eadcc 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -14,44 +14,144 @@ DOMAIN = "adaptive_lighting" SUN_EVENT_NOON = "solar_noon" SUN_EVENT_MIDNIGHT = "solar_midnight" +DOCS = {} + + CONF_NAME, DEFAULT_NAME = "name", "default" +DOCS[CONF_NAME] = "Display name for this switch. πŸ“" + CONF_LIGHTS, DEFAULT_LIGHTS = "lights", [] +DOCS[CONF_LIGHTS] = ( + "List of light entities to be controlled by Adaptive " "Lighting (may be empty). 🌟" +) + CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = ( "detect_non_ha_changes", False, ) +DOCS[CONF_DETECT_NON_HA_CHANGES] = ( + "Detect non-`light.turn_on` state changes and stop adapting lights. " + "Requires `take_over_control`. πŸ•΅οΈ" +) + CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, DEFAULT_INCLUDE_CONFIG_IN_ATTRIBUTES = ( "include_config_in_attributes", False, ) +DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = ( + "Show all options as attributes on the switch in " + "Home Assistant when set to `true`. πŸ“" +) + CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1 +DOCS[CONF_INITIAL_TRANSITION] = ( + "Duration of the first transition when lights turn " "from `off` to `on`. ⏲️" +) + CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1 +DOCS[CONF_SLEEP_TRANSITION] = "Duration of transition when 'sleep mode' is toggled. 😴" + CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90 +DOCS[CONF_INTERVAL] = "Frequency to adapt the lights, in seconds. πŸ”„" + CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS = "max_brightness", 100 +DOCS[CONF_MAX_BRIGHTNESS] = "Maximum brightness percentage. πŸ’‘" + CONF_MAX_COLOR_TEMP, DEFAULT_MAX_COLOR_TEMP = "max_color_temp", 5500 +DOCS[CONF_MAX_COLOR_TEMP] = "Coldest color temperature in Kelvin. ❄️" + CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS = "min_brightness", 1 +DOCS[CONF_MIN_BRIGHTNESS] = "Minimum brightness percentage. πŸ’‘" + CONF_MIN_COLOR_TEMP, DEFAULT_MIN_COLOR_TEMP = "min_color_temp", 2000 +DOCS[CONF_MIN_COLOR_TEMP] = "Warmest color temperature in Kelvin. πŸ”₯" + CONF_ONLY_ONCE, DEFAULT_ONLY_ONCE = "only_once", False +DOCS[CONF_ONLY_ONCE] = ( + "Adapt lights only when they are turned on (`true`) or keep adapting them " + "(`false`). πŸ”„" +) + CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False +DOCS[ + CONF_PREFER_RGB_COLOR +] = "Use RGB color adjustment instead of native light color temperature. 🌈" + CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS = ( "separate_turn_on_commands", False, ) +DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = ( + "Use separate `light.turn_on` calls for color and brightness, needed for " + "some light types. πŸ”€" +) + CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1 +DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness of lights in sleep mode. 😴" + CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000 +DOCS[CONF_SLEEP_COLOR_TEMP] = ( + "Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is " + "`color_temp`). 😴" +) + CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0] +DOCS[CONF_SLEEP_RGB_COLOR] = ( + "RGB color in sleep mode (used when " "`sleep_rgb_or_color_temp` is 'rgb_color'). 🌈" +) + CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = ( "sleep_rgb_or_color_temp", "color_temp", ) +DOCS[ + CONF_SLEEP_RGB_OR_COLOR_TEMP +] = "Use either `'rgb_color'` or `'color_temp'` in sleep mode. πŸŒ™" + CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0 +DOCS[CONF_SUNRISE_OFFSET] = "Adjust sunrise time with a positive or negative offset. ⏰" + CONF_SUNRISE_TIME = "sunrise_time" +DOCS[CONF_SUNRISE_TIME] = "Set a fixed time for sunrise. πŸŒ…" + CONF_MAX_SUNRISE_TIME = "max_sunrise_time" +DOCS[CONF_MAX_SUNRISE_TIME] = ( + "Set the latest virtual sunrise time, allowing" " for earlier real sunrises. πŸŒ…" +) + CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0 +DOCS[CONF_SUNSET_OFFSET] = "Adjust sunset time with a positive or negative offset. ⏰" + CONF_SUNSET_TIME = "sunset_time" +DOCS[CONF_SUNSET_TIME] = "Set a fixed time for sunset. πŸŒ‡" + CONF_MIN_SUNSET_TIME = "min_sunset_time" +DOCS[CONF_MIN_SUNSET_TIME] = ( + "Set the earliest virtual sunset time, allowing" " for later real sunsets. πŸŒ‡" +) + CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True +DOCS[CONF_TAKE_OVER_CONTROL] = ( + "Disable Adaptive Lighting if another source calls `light.turn_on` while lights " + "are on and being adapted. Note that this calls `homeassistant.update_entity` " + "every `interval`! πŸ”’" +) + CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45 +DOCS[CONF_TRANSITION] = "Duration of transition when lights change, in seconds. πŸ•‘" + +CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 +DOCS[CONF_ADAPT_DELAY] = ( + "Wait time (seconds) between light turn on and Adaptive Lighting applying " + "changes. Helps avoid flickering. ⏲️" +) + +CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0 +DOCS[CONF_SEND_SPLIT_DELAY] = ( + "Wait time (milliseconds) between commands when using `separate_turn_on_commands`. " + "Helps ensure correct handling. ⏲️" +) + SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" @@ -69,9 +169,8 @@ CONF_TURN_ON_LIGHTS = "turn_on_lights" SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings" CONF_USE_DEFAULTS = "use_defaults" -CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0 + TURNING_OFF_DELAY = 5 -CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0 def int_between(min_int, max_int): @@ -169,3 +268,57 @@ _DOMAIN_SCHEMA = vol.Schema( for key, default, validation in _yaml_validation_tuples } ) + + +def _format_voluptuous_instance(instance): + coerce_type = None + min_val = None + max_val = None + + for validator in instance.validators: + if isinstance(validator, vol.Coerce): + coerce_type = validator.type.__name__ + elif isinstance(validator, (vol.Clamp, vol.Range)): + min_val = validator.min + max_val = validator.max + + if min_val is not None and max_val is not None: + return f"`{coerce_type}` {min_val}-{max_val}" + elif min_val is not None: + return f"`{coerce_type} > {min_val}`" + elif max_val is not None: + return f"`{coerce_type} < {max_val}`" + else: + return f"`{coerce_type}`" + + +def generate_markdown_table(): + import pandas as pd + + rows = [] + for k, default, type_ in VALIDATION_TUPLES: + description = DOCS[k] + if type_ == cv.entity_ids: + type_ = "list of `entity_id`s" + elif type_ in (bool, int, float, str): + type_ = f"`{type_.__name__}`" + elif isinstance(type_, vol.All): + type_ = _format_voluptuous_instance(type_) + elif isinstance(type_, vol.In): + type_ = f"one of `{type_.container}`" + elif isinstance(type_, selector.SelectSelector): + type_ = f"one of `{type_.config['options']}`" + elif isinstance(type_, selector.ColorRGBSelector): + type_ = "RGB color" + else: + raise ValueError(f"Unknown type: {type_}") + row = { + "Variable name": f"`{k}`", + "Description": description, + "Default": f"`{default}`", + "Type": type_, + } + rows.append(row) + + df = pd.DataFrame(rows) + return df.to_markdown(index=False) From 8108bd0a4b5d0813751f72399aa17f7dd12fd45f Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 2 Apr 2023 18:56:36 -0700 Subject: [PATCH 5/9] Use markdown-code-runner instead of packaged solution (#505) * Use markdown-code-runner instead of packaged solution * add debug --- .github/update-readme.py | 195 ---------------------------- .github/workflows/update-readme.yml | 9 +- README.md | 2 +- 3 files changed, 5 insertions(+), 201 deletions(-) delete mode 100644 .github/update-readme.py diff --git a/.github/update-readme.py b/.github/update-readme.py deleted file mode 100644 index ebeb9401..00000000 --- a/.github/update-readme.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) 2023, Bas Nijholt -# All rights reserved. -# When using this code, please cite the original source. -# and include the LICENSE file in your project. -"""Automatically update Markdown files with code block output. - -Add code blocks between and in your Markdown file. -The output will be inserted between and . - -Example: -------- -``` - - - - -This will be replaced by the output of the code block above. - - -``` -""" -from __future__ import annotations - -import contextlib -import io -from pathlib import Path - - -def md_comment(text: str) -> str: - """Format a string as a Markdown comment.""" - return f"" - - -MARKERS = { - "warning": md_comment("THIS CONTENT IS AUTOMATICALLY GENERATED"), - "start_code": md_comment("START_CODE"), - "end_code": md_comment("END_CODE"), - "start_output": md_comment("START_OUTPUT"), - "end_output": md_comment("END_OUTPUT"), -} - - -def remove_md_comment(commented_text: str) -> str: - """Remove Markdown comment tags from a string.""" - if not (commented_text.startswith("")): - raise ValueError("Invalid Markdown comment format") - return commented_text[5:-4] - - -def execute_code_block(code: list[str]) -> list[str]: - """Execute a code block and return its output as a list of strings.""" - f = io.StringIO() - with contextlib.redirect_stdout(f): - exec("\n".join(code)) # noqa: S102 - return f.getvalue().split("\n") - - -def process_markdown(content: list[str]) -> list[str]: - """Executes code blocks in a list of Markdown-formatted strings and returns the modified list. - - Parameters - ---------- - content - A list of Markdown-formatted strings. - - Returns - ------- - list[str] - A modified list of Markdown-formatted strings with code block output inserted. - """ - assert isinstance(content, list), "Input must be a list" - new_lines = [] - code = [] - in_code_block = in_output_block = False - output = None - - for line in content: - if MARKERS["start_code"] in line: - in_code_block = True - elif MARKERS["start_output"] in line: - in_output_block = True - new_lines.extend([line, MARKERS["warning"]] + output) - output = None - elif MARKERS["end_output"] in line: - in_output_block = False - elif in_code_block: - if MARKERS["end_code"] in line: - in_code_block = False - output = execute_code_block(code) - code = [] - else: - code.append(remove_md_comment(line)) - - if not in_output_block: - new_lines.append(line) - - return new_lines - - -def update_markdown_file(filepath: Path) -> None: - """Rewrite a Markdown file by executing and updating code blocks.""" - with filepath.open() as f: - original_lines = [line.rstrip("\n") for line in f.readlines()] - - new_lines = process_markdown(original_lines) - updated_content = "\n".join(new_lines).rstrip() + "\n" - - with filepath.open("w") as f: - f.write(updated_content) - - -def test_process_markdown(): - def assert_process(input_lines, expected_output): - output = process_markdown(input_lines) - assert output == expected_output, f"Expected {expected_output}, got {output}" - - # Test case 1: Single code block - input_lines = [ - "Some text", - MARKERS["start_code"], - md_comment("print('Hello, world!')"), - MARKERS["end_code"], - MARKERS["start_output"], - "This content will be replaced", - MARKERS["end_output"], - "More text", - ] - expected_output = [ - "Some text", - MARKERS["start_code"], - md_comment("print('Hello, world!')"), - MARKERS["end_code"], - MARKERS["start_output"], - MARKERS["warning"], - "Hello, world!", - "", - MARKERS["end_output"], - "More text", - ] - assert_process(input_lines, expected_output) - - # Test case 2: Two code blocks - input_lines = [ - "Some text", - MARKERS["start_code"], - md_comment("print('Hello, world!')"), - MARKERS["end_code"], - MARKERS["start_output"], - "This content will be replaced", - MARKERS["end_output"], - "More text", - MARKERS["start_code"], - md_comment("print('Hello again!')"), - MARKERS["end_code"], - MARKERS["start_output"], - "This content will also be replaced", - MARKERS["end_output"], - ] - expected_output = [ - "Some text", - MARKERS["start_code"], - md_comment("print('Hello, world!')"), - MARKERS["end_code"], - MARKERS["start_output"], - MARKERS["warning"], - "Hello, world!", - "", - MARKERS["end_output"], - "More text", - MARKERS["start_code"], - md_comment("print('Hello again!')"), - MARKERS["end_code"], - MARKERS["start_output"], - MARKERS["warning"], - "Hello again!", - "", - MARKERS["end_output"], - ] - assert_process(input_lines, expected_output) - - # Test case 3: No code blocks - input_lines = [ - "Some text", - "More text", - ] - expected_output = [ - "Some text", - "More text", - ] - assert_process(input_lines, expected_output) - - -if __name__ == "__main__": - test_process_markdown() - update_markdown_file(Path(__file__).parent.parent / "README.md") diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index 36a994a9..f2a62407 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -5,10 +5,9 @@ on: branches: - master paths: - - ".github/update-readme.py" - "README.md" - - ".github/workflows/update-readme.yml" - "custom_components/adaptive_lighting/const.py" + - "github/workflows/update-readme.yml" pull_request: jobs: @@ -25,10 +24,10 @@ jobs: - name: Install pandas and tabulate run: | - pip install pandas tabulate + pip install markdown-code-runner pandas tabulate - - name: Run update-readme.py - run: python ./.github/update-readme.py + - name: Run markdown-code-runner + run: markdown-code-runner --debug README.md - name: Commit updated README.md run: | diff --git a/README.md b/README.md index 7cad61a3..378c0bb6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The YAML and frontend configuration methods support all of the options listed be - + From 9e5e9a49e5e682592c0fb4170ee4a6173c8d4646 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 2 Apr 2023 18:56:56 -0700 Subject: [PATCH 6/9] Sync main branch to master (#507) --- .github/workflows/main-to-master-sync.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/main-to-master-sync.yml diff --git a/.github/workflows/main-to-master-sync.yml b/.github/workflows/main-to-master-sync.yml new file mode 100644 index 00000000..99cd85b3 --- /dev/null +++ b/.github/workflows/main-to-master-sync.yml @@ -0,0 +1,22 @@ +name: Sync Main to Master + +on: + push: + branches: + - main + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + ref: main + fetch-depth: 0 + + - name: Push changes to master + run: | + git checkout -b master + git push origin +master From b081d79b8631e32881e1b26c788e0de374339f28 Mon Sep 17 00:00:00 2001 From: Benjamin Auquite Date: Sun, 2 Apr 2023 20:57:11 -0500 Subject: [PATCH 7/9] Update services.yaml (#497) Co-authored-by: Bas Nijholt --- custom_components/adaptive_lighting/services.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index 8524dd64..5eb49149 100755 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -87,6 +87,11 @@ change_switch_settings: - "current" - "configuration" - "factory" + include_config_in_attributes: + description: "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)" + required: false + selector: + boolean: turn_on_lights: description: "Turn on the lights that are off, default: false" example: false From ccf18c38792da5bdaa1cc37e2707d416f8aa9b77 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 2 Apr 2023 19:17:35 -0700 Subject: [PATCH 8/9] Bump to 1.8.0 (#508) --- 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 8c4233f9..39276752 100644 --- a/custom_components/adaptive_lighting/manifest.json +++ b/custom_components/adaptive_lighting/manifest.json @@ -8,5 +8,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues", "requirements": [], - "version": "1.7.0" + "version": "1.8.0" } From ea2a6b0173240f98e50319f8a1db26d48ee1c5b8 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Sun, 2 Apr 2023 19:22:23 -0700 Subject: [PATCH 9/9] Add auto_reset_manual_control with async timer (#487) * Add auto_reset_manual_control with async timer * Add failing test * Debugging * Refactor find_switch_for_lights * Revert changes in is_manually_controlled * style * Fix test_manual_control * add types * fixes * dict * make all tests pass * text * Rework find_switch_for_lights * Style * Better check * revert, do in other test! * No need to log when raising * Add type hint * Suggestion https://github.com/basnijholt/adaptive-lighting/pull/488/files#r1152408541 by @th3w1zard1 * Small fixes * document new config everywhere (#496) * chore(docs): update TOC * undo change * fi * Update README.md * Update README.md * chore(docs): update TOC * Update README.md * Use markdown-code-runner * Remove * Use markdown-code-runner instead of packaged solution * fix comment * Only commit when needed --------- Co-authored-by: Benjamin Auquite Co-authored-by: basnijholt Co-authored-by: github-actions[bot] --- .github/workflows/update-readme.yml | 10 +- README.md | 7 +- custom_components/adaptive_lighting/const.py | 10 ++ .../adaptive_lighting/services.yaml | 6 + .../adaptive_lighting/strings.json | 3 +- custom_components/adaptive_lighting/switch.py | 122 +++++++++++++++++- tests/test_switch.py | 48 ++++++- 7 files changed, 195 insertions(+), 11 deletions(-) diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index f2a62407..4d4c4530 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -30,13 +30,21 @@ jobs: run: markdown-code-runner --debug README.md - name: Commit updated README.md + id: commit run: | git add README.md git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" - git diff --quiet && git diff --staged --quiet || git commit -m "Update README.md" + if git diff --quiet && git diff --staged --quiet; then + echo "No changes in README.md, skipping commit." + echo "commit_status=skipped" >> $GITHUB_ENV + else + git commit -m "Update README.md" + echo "commit_status=committed" >> $GITHUB_ENV + fi - name: Push changes + if: env.commit_status == 'committed' uses: ad-m/github-push-action@master with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 378c0bb6..6c03f1f7 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Adaptive Lighting provides four switches (using "living_room" as an example comp Adaptive Lighting is designed to automatically detect when you or another source (e.g., automation) manually changes light settings πŸ•ΉοΈ. When this occurs, the affected light is marked as "manually controlled," and Adaptive Lighting will not make further adjustments until the light is turned off and back on or reset using the `adaptive_lighting.set_manual_control` service call. -This feature is available when take_over_control is enabled. +This feature is available when `take_over_control` is enabled. Additionally, enabling detect_non_ha_changes allows Adaptive Lighting to detect all state changes, including those made outside of Home Assistant, by comparing the light's state to its previously used settings. The `adaptive_lighting.manual_control` event is fired when a light is marked as "manually controlled," allowing for integration with automations πŸ€–. @@ -47,7 +47,7 @@ The `adaptive_lighting.manual_control` event is fired when a light is marked as - [`adaptive_lighting.change_switch_settings`](#adaptive_lightingchange_switch_settings) - [:robot: Automation examples](#robot-automation-examples) - [Additional Information](#additional-information) -- [Troubleshooting](#troubleshooting) +- [:sos: Troubleshooting](#sos-troubleshooting) - [:exclamation: Common Problems & Solutions](#exclamation-common-problems--solutions) - [:bulb: Lights Not Responding or Turning On by Themselves](#bulb-lights-not-responding-or-turning-on-by-themselves) - [:signal_strength: WiFi Networks](#signal_strength-wifi-networks) @@ -119,6 +119,7 @@ The YAML and frontend configuration methods support all of the options listed be | `separate_turn_on_commands` | Use separate `light.turn_on` calls for color and brightness, needed for some light types. πŸ”€ | `False` | `bool` | | `send_split_delay` | Wait time (milliseconds) between commands when using `separate_turn_on_commands`. Helps ensure correct handling. ⏲️ | `0` | `int` 0-10000 | | `adapt_delay` | Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Helps avoid flickering. ⏲️ | `0` | `float > 0` | +| `autoreset_control_seconds` | Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️ | `0` | `int` 0-604800 | @@ -298,7 +299,7 @@ For more details on adding the integration and setting options, refer to the [do Adaptive Lighting was initially inspired by @claytonjn's [hass-circadian\_lighting](https://github.com/claytonjn/hass-circadian_lighting), but has since been entirely rewritten and expanded with new features. -# Troubleshooting +# :sos: Troubleshooting Encountering issues? Enable debug logging in your `configuration.yaml`: diff --git a/custom_components/adaptive_lighting/const.py b/custom_components/adaptive_lighting/const.py index 421eadcc..eb29fa13 100644 --- a/custom_components/adaptive_lighting/const.py +++ b/custom_components/adaptive_lighting/const.py @@ -152,6 +152,11 @@ DOCS[CONF_SEND_SPLIT_DELAY] = ( "Helps ensure correct handling. ⏲️" ) +CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0 +DOCS[CONF_AUTORESET_CONTROL] = ( + "Automatically reset the manual control after a number of seconds. " + "Set to 0 to disable. ⏲️" +) SLEEP_MODE_SWITCH = "sleep_mode_switch" ADAPT_COLOR_SWITCH = "adapt_color_switch" @@ -220,6 +225,11 @@ VALIDATION_TUPLES = [ (CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS, bool), (CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY, int_between(0, 10000)), (CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float), + ( + CONF_AUTORESET_CONTROL, + DEFAULT_AUTORESET_CONTROL, + int_between(0, 7 * 24 * 60 * 60), # 7 days max + ), ] diff --git a/custom_components/adaptive_lighting/services.yaml b/custom_components/adaptive_lighting/services.yaml index 5eb49149..373e796a 100755 --- a/custom_components/adaptive_lighting/services.yaml +++ b/custom_components/adaptive_lighting/services.yaml @@ -248,3 +248,9 @@ change_switch_settings: example: 0 selector: text: + autoreset_control_seconds: + description: "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" + required: false + example: 0 + selector: + text: diff --git a/custom_components/adaptive_lighting/strings.json b/custom_components/adaptive_lighting/strings.json index 73f70a2c..5a8ef6af 100644 --- a/custom_components/adaptive_lighting/strings.json +++ b/custom_components/adaptive_lighting/strings.json @@ -45,7 +45,8 @@ "take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.", "detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)", "transition": "Transition time when applying a change to the lights (seconds)", - "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering." + "adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering.", + "autoreset_control_seconds": "autoreset_control_seconds: wait time (seconds) before Adaptive Lighting resets `manual_control` status of any light (default: 0)" } } }, diff --git a/custom_components/adaptive_lighting/switch.py b/custom_components/adaptive_lighting/switch.py index c94ab32a..5cf17122 100644 --- a/custom_components/adaptive_lighting/switch.py +++ b/custom_components/adaptive_lighting/switch.py @@ -97,6 +97,7 @@ from .const import ( ATTR_ADAPT_COLOR, ATTR_TURN_ON_OFF_LISTENER, CONF_ADAPT_DELAY, + CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INCLUDE_CONFIG_IN_ATTRIBUTES, CONF_INITIAL_TRANSITION, @@ -265,7 +266,7 @@ def find_switch_for_lights( is_on: bool = False, ) -> AdaptiveSwitch: """Find the switch that controls the lights in 'lights'.""" - switches = _get_switches_with_lights(hass, lights, is_on) + switches = _get_switches_with_lights(hass, lights) if len(switches) == 1: return switches[0] elif len(switches) > 1: @@ -330,7 +331,7 @@ def _get_switches_from_service_call( async def handle_change_switch_settings( switch: AdaptiveSwitch, service_call: ServiceCall -): +) -> None: """Allows HASS to change config values via a service call.""" data = service_call.data @@ -473,7 +474,7 @@ async def async_setup_entry( all_lights = _expand_light_groups(switch.hass, lights) if service_call.data[CONF_MANUAL_CONTROL]: for light in all_lights: - switch.turn_on_off_listener.manual_control[light] = True + switch.turn_on_off_listener.mark_as_manual_control(light) _fire_manual_control_event(switch, light, service_call.context) else: switch.turn_on_off_listener.reset(*all_lights) @@ -820,6 +821,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): self._transition = data[CONF_TRANSITION] self._adapt_delay = data[CONF_ADAPT_DELAY] self._send_split_delay = data[CONF_SEND_SPLIT_DELAY] + self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL] _loc = get_astral_location(self.hass) if isinstance(_loc, tuple): # Astral v2.2 @@ -893,6 +895,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity): def _expand_light_groups(self) -> None: all_lights = _expand_light_groups(self.hass, self._lights) self.turn_on_off_listener.lights.update(all_lights) + self.turn_on_off_listener.set_auto_reset_manual_control_times( + all_lights, self._auto_reset_manual_control_time + ) self._lights = list(all_lights) async def _setup_listeners(self, _=None) -> None: @@ -1526,6 +1531,10 @@ class TurnOnOffListener: # Track last 'service_data' to 'light.turn_on' resulting from this integration self.last_service_data: dict[str, dict[str, Any]] = {} + # Track auto reset of manual_control + self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {} + self.auto_reset_manual_control_times: dict[str, float] = {} + # When a state is different `max_cnt_significant_changes` times in a row, # mark it as manually_controlled. self.max_cnt_significant_changes = 2 @@ -1537,11 +1546,71 @@ class TurnOnOffListener: EVENT_STATE_CHANGED, self.state_changed_event_listener ) + def set_auto_reset_manual_control_times(self, lights: list[str], time: float): + """Set the time after which the lights are automatically reset.""" + if time == 0: + return + for light in lights: + old_time = self.auto_reset_manual_control_times.get(light) + if (old_time is not None) and (old_time != time): + _LOGGER.info( + "Setting auto_reset_manual_control for '%s' from %s seconds to %s seconds." + " This might happen because the light is in multiple swiches" + " or because of a config change.", + light, + old_time, + time, + ) + self.auto_reset_manual_control_times[light] = time + + def mark_as_manual_control(self, light: str) -> None: + """Mark a light as manually controlled.""" + _LOGGER.debug("Marking '%s' as manually controlled.", light) + self.manual_control[light] = True + delay = self.auto_reset_manual_control_times.get(light) + timer = self.auto_reset_manual_control_timers.get(light) + if timer is not None: + if delay is None: # Timer object exists, but should not anymore + timer.cancel() + self.auto_reset_manual_control_timers.pop(light) + else: # Timer object already exists, just update the delay and restart it + timer.delay = delay + timer.start() + elif delay is not None: # Timer object does not exist, create it + + async def reset(): + self.reset(light) + switches = _get_switches_with_lights(self.hass, [light]) + for switch in switches: + if not switch.is_on: + continue + # pylint: disable=protected-access + await switch._update_attrs_and_maybe_adapt_lights( + [light], + transition=switch._initial_transition, + force=True, + context=switch.create_context("autoreset"), + ) + _LOGGER.debug( + "Auto resetting 'manual_control' status of '%s' because" + " it was not manually controlled for %s seconds.", + light, + delay, + ) + assert not self.manual_control[light] + + timer = _AsyncSingleShotTimer(delay, reset) + self.auto_reset_manual_control_timers[light] = timer + timer.start() + def reset(self, *lights, reset_manual_control=True) -> None: """Reset the 'manual_control' status of the lights.""" for light in lights: if reset_manual_control: self.manual_control[light] = False + timer = self.auto_reset_manual_control_timers.pop(light, None) + if timer is not None: + timer.cancel() self.last_state_change.pop(light, None) self.last_service_data.pop(light, None) self.cnt_significant_changes[light] = 0 @@ -1599,6 +1668,14 @@ class TurnOnOffListener: if task is not None: task.cancel() self.turn_on_event[eid] = event + timer = self.auto_reset_manual_control_timers.get(eid) + if ( + timer is not None + and timer.is_running() + and event.time_fired > timer.start_time + ): + # Restart the auto reset timer + timer.start() async def state_changed_event_listener(self, event: Event) -> None: """Track 'state_changed' events.""" @@ -1674,7 +1751,7 @@ class TurnOnOffListener: ): # Light was already on and 'light.turn_on' was not called by # the adaptive_lighting integration. - manual_control = self.manual_control[light] = True + manual_control = self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, turn_on_event.context) _LOGGER.debug( "'%s' was already on and 'light.turn_on' was not called by the" @@ -1746,7 +1823,7 @@ class TurnOnOffListener: # Only mark a light as significantly changing, if changed==True # N times in a row. We do this because sometimes a state changes # happens only *after* a new update interval has already started. - self.manual_control[light] = True + self.mark_as_manual_control(light) _fire_manual_control_event(switch, light, context, is_async=False) else: if n_changes > 1: @@ -1857,3 +1934,38 @@ class TurnOnOffListener: # other 'off' β†’ 'on' state switches resulting from polling. That # would mean we 'return True' here. return False + + +class _AsyncSingleShotTimer: + def __init__(self, delay, callback): + """Initialize the timer.""" + self.delay = delay + self.callback = callback + self.task = None + self.start_time: int | None = None + + async def _run(self): + """Run the timer. Don't call this directly, use start() instead.""" + self.start_time = dt_util.utcnow() + await asyncio.sleep(self.delay) + if self.callback: + if asyncio.iscoroutinefunction(self.callback): + await self.callback() + else: + self.callback() + + def is_running(self): + """Return whether the timer is running.""" + return self.task is not None and not self.task.done() + + def start(self): + """Start the timer.""" + if self.task is not None and not self.task.done(): + self.task.cancel() + self.task = asyncio.create_task(self._run()) + + def cancel(self): + """Cancel the timer.""" + if self.task: + self.task.cancel() + self.callback = None diff --git a/tests/test_switch.py b/tests/test_switch.py index 8584bb1f..5b74c94c 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -10,6 +10,7 @@ from homeassistant.components.adaptive_lighting.const import ( ADAPT_BRIGHTNESS_SWITCH, ADAPT_COLOR_SWITCH, ATTR_TURN_ON_OFF_LISTENER, + CONF_AUTORESET_CONTROL, CONF_DETECT_NON_HA_CHANGES, CONF_INITIAL_TRANSITION, CONF_MANUAL_CONTROL, @@ -184,7 +185,7 @@ async def setup_lights_and_switch(hass, extra_conf=None): # Setup switch lights = [ - "light.bed_light", + ENTITY_LIGHT, "light.ceiling_lights", ] assert all(hass.states.get(light) is not None for light in lights) @@ -584,6 +585,50 @@ async def test_manual_control(hass): assert all([not manual_control[eid] for eid in switch._lights]) +async def test_auto_reset_manual_control(hass): + switch, (light, *_) = await setup_lights_and_switch( + hass, {CONF_AUTORESET_CONTROL: 0.1} + ) + context = switch.create_context("test") # needs to be passed to update method + manual_control = switch.turn_on_off_listener.manual_control + + async def update(): + await switch._update_attrs_and_maybe_adapt_lights(transition=0, context=context) + await hass.async_block_till_done() + + async def turn_light(state, **kwargs): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON if state else SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: light.entity_id, **kwargs}, + blocking=True, + ) + await hass.async_block_till_done() + await update() + _LOGGER.debug( + "Turn light %s to state %s, to %s", light.entity_id, state, kwargs + ) + + _LOGGER.debug("Start test auto reset manual control") + await turn_light(True, brightness=1) + await turn_light(True, brightness=10) + assert manual_control[light.entity_id] + await asyncio.sleep(0.3) # Should be enough time for auto reset + await update() + assert not manual_control[light.entity_id], (light, manual_control) + + # Do a couple of quick changes and check that light is not reset + for i in range(3): + _LOGGER.debug("Quick change %s", i) + await turn_light(True, brightness=(i + 1) * 20) + await asyncio.sleep(0.05) # Less than 0.1 + assert manual_control[light.entity_id] + + await asyncio.sleep(0.3) # Wait the auto reset time + await update() + assert not manual_control[light.entity_id] + + async def test_apply_service(hass): """Test adaptive_lighting.apply service.""" switch, (_, _, light) = await setup_lights_and_switch(hass) @@ -734,6 +779,7 @@ async def test_significant_change(hass): assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT] # On next update the light should be marked as manually controlled await update(force=False) + # TODO: the state should be `bool(manual_control) is True` assert not switch.turn_on_off_listener.manual_control[ENTITY_LIGHT]