mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-11 06:14:04 +02:00
Refactor, simplify code, rename, and set minimal HA core ≥2022.11 (#655)
* Rename TurnOnOffListener to AdaptiveLightingManager * Bump Python version * Refactor and unify methods that are called once * Simplify adaptation_utils.py * Improve readability in adaptation_utils.py * More renames * Simplify * More renames and simplifications * fix test * simplify _supported_features * rename * walrus * drop old astral support * Only support HA ≥2021.06 * Require 2016.06 * Try 2023.1 * even more old versions * test * more versions * verify that only ≥2022.11 works * named args * setdefault * no astral v1 * var * simplify * no need to pass adapt_brightness and adapt_color * Add comment
This commit is contained in:
parent
69592938db
commit
c1528ec10a
7 changed files with 166 additions and 208 deletions
6
.github/workflows/pytest.yaml
vendored
6
.github/workflows/pytest.yaml
vendored
|
|
@ -14,6 +14,12 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
core-version: "2022.11.5"
|
||||
- python-version: "3.10"
|
||||
core-version: "2022.12.9"
|
||||
- python-version: "3.10"
|
||||
core-version: "2023.1.7"
|
||||
- python-version: "3.10"
|
||||
core-version: "2023.2.5"
|
||||
- python-version: "3.10"
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]:
|
|||
|
||||
# Distribute the transition duration across all service calls
|
||||
if service_datas and (transition := service_data.get(ATTR_TRANSITION)) is not None:
|
||||
transition = service_data[ATTR_TRANSITION] / len(service_datas)
|
||||
transition /= len(service_datas)
|
||||
|
||||
for service_data in service_datas:
|
||||
service_data[ATTR_TRANSITION] = transition
|
||||
|
|
@ -69,23 +69,20 @@ def _split_service_call_data(service_data: ServiceData) -> list[ServiceData]:
|
|||
return service_datas
|
||||
|
||||
|
||||
def _filter_service_data(service_data: ServiceData, state: State | None) -> ServiceData:
|
||||
def _remove_redundant_attributes(
|
||||
service_data: ServiceData, state: State
|
||||
) -> ServiceData:
|
||||
"""Filter service data by removing attributes that already equal the given state.
|
||||
|
||||
Removes all attributes from service call data whose values are already present
|
||||
in the target entity's state."""
|
||||
|
||||
if not state:
|
||||
return service_data
|
||||
|
||||
filtered_service_data = {
|
||||
k: service_data[k]
|
||||
for k in service_data.keys()
|
||||
if k not in state.attributes or service_data[k] != state.attributes[k]
|
||||
return {
|
||||
k: v
|
||||
for k, v in service_data.items()
|
||||
if k not in state.attributes or v != state.attributes[k]
|
||||
}
|
||||
|
||||
return filtered_service_data
|
||||
|
||||
|
||||
def _has_relevant_service_data_attributes(service_data: ServiceData) -> bool:
|
||||
"""Determines whether the service data justifies an adaptation service call.
|
||||
|
|
@ -93,9 +90,8 @@ def _has_relevant_service_data_attributes(service_data: ServiceData) -> bool:
|
|||
A service call is not justified for data which does not contain any entries that
|
||||
change relevant attributes of an adapting entity, e.g., brightness or color."""
|
||||
common_attrs = {ATTR_ENTITY_ID, ATTR_TRANSITION}
|
||||
relevant_attrs = set(service_data) - common_attrs
|
||||
|
||||
return bool(relevant_attrs)
|
||||
return any(attr not in common_attrs for attr in service_data)
|
||||
|
||||
|
||||
async def _create_service_call_data_iterator(
|
||||
|
|
@ -118,8 +114,10 @@ async def _create_service_call_data_iterator(
|
|||
current_entity_state = hass.states.get(entity_id)
|
||||
|
||||
# Filter data to remove attributes that equal the current state
|
||||
if current_entity_state:
|
||||
service_data = _filter_service_data(service_data, current_entity_state)
|
||||
if current_entity_state is not None:
|
||||
service_data = _remove_redundant_attributes(
|
||||
service_data, current_entity_state
|
||||
)
|
||||
|
||||
# Emit service data if it still contains relevant attributes (else try next)
|
||||
if _has_relevant_service_data_attributes(service_data):
|
||||
|
|
@ -149,7 +147,7 @@ class NoColorOrBrightnessInServiceData(Exception):
|
|||
"""Exception raised when no color or brightness attributes are found in service data."""
|
||||
|
||||
|
||||
def is_color_brightness_or_both(
|
||||
def _identify_lighting_type(
|
||||
service_data: ServiceData,
|
||||
) -> Literal["brightness", "color", "both"]:
|
||||
"""Extract the 'which' attribute from the service data."""
|
||||
|
|
@ -181,23 +179,30 @@ def prepare_adaptation_data(
|
|||
entity_id,
|
||||
service_data,
|
||||
)
|
||||
service_datas = (
|
||||
[service_data] if not split else _split_service_call_data(service_data)
|
||||
)
|
||||
if split:
|
||||
service_datas = _split_service_call_data(service_data)
|
||||
else:
|
||||
service_datas = [service_data]
|
||||
|
||||
sleep_time = (
|
||||
transition / max(1, len(service_datas)) if transition is not None else 0
|
||||
) + split_delay
|
||||
service_datas_length = len(service_datas)
|
||||
|
||||
if transition is not None:
|
||||
transition_duration_per_data = transition / max(1, service_datas_length)
|
||||
sleep_time = transition_duration_per_data + split_delay
|
||||
else:
|
||||
sleep_time = split_delay
|
||||
|
||||
service_data_iterator = _create_service_call_data_iterator(
|
||||
hass, service_datas, filter_by_state
|
||||
)
|
||||
|
||||
lighting_type = _identify_lighting_type(service_data)
|
||||
|
||||
return AdaptationData(
|
||||
entity_id,
|
||||
context,
|
||||
entity_id=entity_id,
|
||||
context=context,
|
||||
sleep_time=sleep_time,
|
||||
service_call_datas=service_data_iterator,
|
||||
max_length=len(service_datas),
|
||||
which=is_color_brightness_or_both(service_data),
|
||||
max_length=service_datas_length,
|
||||
which=lighting_type,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import base64
|
||||
import bisect
|
||||
from collections.abc import Callable, Coroutine
|
||||
from collections.abc import Callable, Coroutine, Iterable
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
|
|
@ -186,8 +186,7 @@ _DOMAIN_SHORT = "al"
|
|||
|
||||
|
||||
def _int_to_base36(num: int) -> str:
|
||||
"""
|
||||
Convert an integer to its base-36 representation using numbers and uppercase letters.
|
||||
"""Convert an integer to its base-36 representation using numbers and uppercase letters.
|
||||
|
||||
Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
|
||||
alphanumeric representation. The function takes an integer `num` as input and returns
|
||||
|
|
@ -268,7 +267,7 @@ def is_our_context(context: Context | None) -> bool:
|
|||
return is_our_context_id(context.id)
|
||||
|
||||
|
||||
def _get_switches_with_lights(
|
||||
def _switches_with_lights(
|
||||
hass: HomeAssistant, lights: list[str]
|
||||
) -> list[AdaptiveSwitch]:
|
||||
"""Get all switches that control at least one of the lights passed."""
|
||||
|
|
@ -292,12 +291,12 @@ class NoSwitchFoundError(ValueError):
|
|||
"""No switches found for lights."""
|
||||
|
||||
|
||||
def find_switch_for_lights(
|
||||
def _switch_with_lights(
|
||||
hass: HomeAssistant,
|
||||
lights: list[str],
|
||||
) -> AdaptiveSwitch:
|
||||
"""Find the switch that controls the lights in 'lights'."""
|
||||
switches = _get_switches_with_lights(hass, lights)
|
||||
switches = _switches_with_lights(hass, lights)
|
||||
if len(switches) == 1:
|
||||
return switches[0]
|
||||
elif len(switches) > 1:
|
||||
|
|
@ -306,13 +305,13 @@ def find_switch_for_lights(
|
|||
# Of the multiple switches, only one is on
|
||||
return on_switches[0]
|
||||
raise NoSwitchFoundError(
|
||||
f"find_switch_for_lights: Light(s) {lights} found in multiple switch configs"
|
||||
f"_switch_with_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:
|
||||
raise NoSwitchFoundError(
|
||||
f"find_switch_for_lights: Light(s) {lights} not found in any switch's"
|
||||
f"_switch_with_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'."
|
||||
)
|
||||
|
|
@ -320,7 +319,7 @@ def find_switch_for_lights(
|
|||
|
||||
# For documentation on this function, see integration_entities() from HomeAssistant Core:
|
||||
# https://github.com/home-assistant/core/blob/dev/homeassistant/helpers/template.py#L1109
|
||||
def _get_switches_from_service_call(
|
||||
def _switches_from_service_call(
|
||||
hass: HomeAssistant, service_call: ServiceCall
|
||||
) -> list[AdaptiveSwitch]:
|
||||
data = service_call.data
|
||||
|
|
@ -351,7 +350,7 @@ def _get_switches_from_service_call(
|
|||
return switches
|
||||
|
||||
if lights:
|
||||
switch = find_switch_for_lights(hass, lights)
|
||||
switch = _switch_with_lights(hass, lights)
|
||||
return [switch]
|
||||
|
||||
raise ValueError(
|
||||
|
|
@ -377,11 +376,7 @@ async def handle_change_switch_settings(
|
|||
else:
|
||||
defaults = None
|
||||
|
||||
switch._set_changeable_settings(
|
||||
data=data,
|
||||
defaults=defaults,
|
||||
)
|
||||
|
||||
switch._set_changeable_settings(data=data, defaults=defaults)
|
||||
switch._update_time_interval_listener()
|
||||
|
||||
_LOGGER.debug(
|
||||
|
|
@ -389,11 +384,10 @@ async def handle_change_switch_settings(
|
|||
data,
|
||||
)
|
||||
|
||||
all_lights = switch.lights # pylint: disable=protected-access
|
||||
switch.manager.reset(*all_lights, reset_manual_control=False)
|
||||
switch.manager.reset(*switch.lights, reset_manual_control=False)
|
||||
if switch.is_on:
|
||||
await switch._update_attrs_and_maybe_adapt_lights( # pylint: disable=protected-access
|
||||
all_lights,
|
||||
switch.lights,
|
||||
transition=switch.initial_transition,
|
||||
force=True,
|
||||
context=switch.create_context("service", parent=service_call.context),
|
||||
|
|
@ -471,7 +465,7 @@ async def async_setup_entry(
|
|||
"Called 'adaptive_lighting.apply' service with '%s'",
|
||||
data,
|
||||
)
|
||||
switches = _get_switches_from_service_call(hass, service_call)
|
||||
switches = _switches_from_service_call(hass, service_call)
|
||||
lights = data[CONF_LIGHTS]
|
||||
for switch in switches:
|
||||
if not lights:
|
||||
|
|
@ -500,7 +494,7 @@ async def async_setup_entry(
|
|||
"Called 'adaptive_lighting.set_manual_control' service with '%s'",
|
||||
data,
|
||||
)
|
||||
switches = _get_switches_from_service_call(hass, service_call)
|
||||
switches = _switches_from_service_call(hass, service_call)
|
||||
lights = data[CONF_LIGHTS]
|
||||
for switch in switches:
|
||||
if not lights:
|
||||
|
|
@ -528,9 +522,7 @@ async def async_setup_entry(
|
|||
domain=DOMAIN,
|
||||
service=SERVICE_APPLY,
|
||||
service_func=handle_apply,
|
||||
schema=apply_service_schema(
|
||||
switch.initial_transition
|
||||
), # pylint: disable=protected-access
|
||||
schema=apply_service_schema(switch.initial_transition),
|
||||
)
|
||||
|
||||
# Register `set_manual_control` service
|
||||
|
|
@ -582,16 +574,15 @@ def validate(
|
|||
return data
|
||||
|
||||
|
||||
def match_switch_state_event(event: Event, from_or_to_state: list[str]):
|
||||
def _is_state_event(event: Event, from_or_to_state: Iterable[str]):
|
||||
"""Match state event when either 'from_state' or 'to_state' matches."""
|
||||
old_state = event.data.get("old_state")
|
||||
from_state_match = old_state is not None and old_state.state in from_or_to_state
|
||||
|
||||
new_state = event.data.get("new_state")
|
||||
to_state_match = new_state is not None and new_state.state in from_or_to_state
|
||||
|
||||
match = from_state_match or to_state_match
|
||||
return match
|
||||
return (
|
||||
(old_state := event.data.get("old_state")) is not None
|
||||
and old_state.state in from_or_to_state
|
||||
) or (
|
||||
(new_state := event.data.get("new_state")) is not None
|
||||
and new_state.state in from_or_to_state
|
||||
)
|
||||
|
||||
|
||||
def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
|
||||
|
|
@ -612,35 +603,36 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
|
|||
return list(all_lights)
|
||||
|
||||
|
||||
def _supported_features(hass: HomeAssistant, light: str):
|
||||
def _supported_features(hass: HomeAssistant, light: str) -> set[str]:
|
||||
state = hass.states.get(light)
|
||||
supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
supported = {
|
||||
key for key, value in _SUPPORT_OPTS.items() if supported_features & value
|
||||
}
|
||||
|
||||
supported_color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES, set())
|
||||
if COLOR_MODE_RGB in supported_color_modes:
|
||||
supported.add("color")
|
||||
# Adding brightness here, see
|
||||
# comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011
|
||||
supported.add("brightness")
|
||||
if COLOR_MODE_RGBW in supported_color_modes:
|
||||
supported.add("color")
|
||||
supported.add("brightness") # see above url
|
||||
if COLOR_MODE_RGBWW:
|
||||
supported.add("color")
|
||||
supported.add("brightness") # see above url
|
||||
if COLOR_MODE_XY in supported_color_modes:
|
||||
supported.add("color")
|
||||
supported.add("brightness") # see above url
|
||||
if COLOR_MODE_HS in supported_color_modes:
|
||||
supported.add("color")
|
||||
supported.add("brightness") # see above url
|
||||
color_modes = {
|
||||
COLOR_MODE_RGB,
|
||||
COLOR_MODE_RGBW,
|
||||
COLOR_MODE_RGBWW,
|
||||
COLOR_MODE_XY,
|
||||
COLOR_MODE_HS,
|
||||
}
|
||||
|
||||
# Adding brightness when color mode is supported, see
|
||||
# comment https://github.com/basnijholt/adaptive-lighting/issues/112#issuecomment-836944011
|
||||
|
||||
for mode in color_modes:
|
||||
if mode in supported_color_modes:
|
||||
supported.update({"color", "brightness"})
|
||||
break
|
||||
|
||||
if COLOR_MODE_COLOR_TEMP in supported_color_modes:
|
||||
supported.add("color_temp")
|
||||
supported.add("brightness") # see above url
|
||||
supported.update({"color_temp", "brightness"})
|
||||
|
||||
if COLOR_MODE_BRIGHTNESS in supported_color_modes:
|
||||
supported.add("brightness")
|
||||
|
||||
return supported
|
||||
|
||||
|
||||
|
|
@ -670,16 +662,16 @@ def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
|
|||
return attributes
|
||||
|
||||
rgb = None
|
||||
if ATTR_COLOR_TEMP_KELVIN in attributes:
|
||||
rgb = color_temperature_to_rgb(attributes[ATTR_COLOR_TEMP_KELVIN])
|
||||
elif ATTR_XY_COLOR in attributes:
|
||||
rgb = color_xy_to_RGB(*attributes[ATTR_XY_COLOR])
|
||||
if (color := attributes.get(ATTR_COLOR_TEMP_KELVIN)) is not None:
|
||||
rgb = color_temperature_to_rgb(color)
|
||||
elif (color := attributes.get(ATTR_XY_COLOR)) is not None:
|
||||
rgb = color_xy_to_RGB(*color)
|
||||
|
||||
if rgb is not None:
|
||||
attributes[ATTR_RGB_COLOR] = rgb
|
||||
_LOGGER.debug(f"Converted {attributes} to rgb {rgb}")
|
||||
else:
|
||||
_LOGGER.debug("No suitable conversion found")
|
||||
_LOGGER.debug("No suitable color conversion found for %s", attributes)
|
||||
|
||||
return attributes
|
||||
|
||||
|
|
@ -796,10 +788,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
|
||||
# backup data for use in change_switch_settings "configuration" CONF_USE_DEFAULTS
|
||||
self._config_backup = deepcopy(data)
|
||||
self._set_changeable_settings(
|
||||
data=data,
|
||||
defaults=None,
|
||||
)
|
||||
self._set_changeable_settings(data=data, defaults=None)
|
||||
|
||||
# Set other attributes
|
||||
self._icon = ICON_MAIN
|
||||
|
|
@ -835,7 +824,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
def _set_changeable_settings(
|
||||
self,
|
||||
data: dict,
|
||||
defaults: dict,
|
||||
defaults: dict | None = None,
|
||||
):
|
||||
# Only pass settings users can change during runtime
|
||||
data = validate(
|
||||
|
|
@ -853,9 +842,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if self._include_config_in_attributes:
|
||||
attrdata = deepcopy(data)
|
||||
for k, v in attrdata.items():
|
||||
if isinstance(v, (datetime.date, datetime.datetime)):
|
||||
if isinstance(v, datetime.date | datetime.datetime):
|
||||
attrdata[k] = v.isoformat()
|
||||
if isinstance(v, (datetime.timedelta)):
|
||||
elif isinstance(v, datetime.timedelta):
|
||||
attrdata[k] = v.total_seconds()
|
||||
self._config.update(attrdata)
|
||||
|
||||
|
|
@ -880,13 +869,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
|
||||
self._skip_redundant_commands = data[CONF_SKIP_REDUNDANT_COMMANDS]
|
||||
self._expand_light_groups() # updates manual control timers
|
||||
_loc = get_astral_location(self.hass)
|
||||
if isinstance(_loc, tuple):
|
||||
# Astral v2.2
|
||||
location, _ = _loc
|
||||
else:
|
||||
# Astral v1
|
||||
location = _loc
|
||||
location, _ = get_astral_location(self.hass)
|
||||
|
||||
self._sun_light_settings = SunLightSettings(
|
||||
name=self._name,
|
||||
|
|
@ -971,8 +954,8 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
|
||||
remove_sleep = async_track_state_change_event(
|
||||
self.hass,
|
||||
self.sleep_mode_switch.entity_id,
|
||||
self._sleep_mode_switch_state_event,
|
||||
entity_ids=self.sleep_mode_switch.entity_id,
|
||||
action=self._sleep_mode_switch_state_event_action,
|
||||
)
|
||||
|
||||
self.remove_listeners.append(remove_sleep)
|
||||
|
|
@ -980,7 +963,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if self.lights:
|
||||
self._expand_light_groups()
|
||||
remove_state = async_track_state_change_event(
|
||||
self.hass, self.lights, self._light_event
|
||||
self.hass, entity_ids=self.lights, action=self._light_event_action
|
||||
)
|
||||
self.remove_listeners.append(remove_state)
|
||||
|
||||
|
|
@ -1005,7 +988,9 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
)
|
||||
|
||||
self.remove_interval = async_track_time_interval(
|
||||
self.hass, self._async_update_at_interval, adaptation_interval
|
||||
self.hass,
|
||||
action=self._async_update_at_interval_action,
|
||||
interval=adaptation_interval,
|
||||
)
|
||||
|
||||
def _remove_interval_listener(self) -> None:
|
||||
|
|
@ -1078,7 +1063,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._remove_listeners()
|
||||
self.manager.reset(*self.lights)
|
||||
|
||||
async def _async_update_at_interval(self, now=None) -> None:
|
||||
async def _async_update_at_interval_action(self, now=None) -> None:
|
||||
await self._update_attrs_and_maybe_adapt_lights(
|
||||
transition=self._transition,
|
||||
force=False,
|
||||
|
|
@ -1174,8 +1159,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
prefer_rgb_color: bool | None = None,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
lock = self._locks.get(light)
|
||||
if lock is not None and lock.locked():
|
||||
if (lock := self._locks.get(light)) is not None and lock.locked():
|
||||
_LOGGER.debug("%s: '%s' is locked", self._name, light)
|
||||
return
|
||||
|
||||
|
|
@ -1293,10 +1277,13 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if lights is None:
|
||||
lights = self.lights
|
||||
|
||||
filtered_lights = []
|
||||
if not force:
|
||||
if self._only_once:
|
||||
return
|
||||
if not force and self._only_once:
|
||||
return
|
||||
|
||||
if force:
|
||||
filtered_lights = lights
|
||||
else:
|
||||
filtered_lights = []
|
||||
for light in lights:
|
||||
# Don't adapt lights that haven't finished prior transitions.
|
||||
timer = self.manager.transition_timers.get(light)
|
||||
|
|
@ -1308,37 +1295,14 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
)
|
||||
else:
|
||||
filtered_lights.append(light)
|
||||
else:
|
||||
filtered_lights = lights
|
||||
|
||||
if not filtered_lights:
|
||||
return
|
||||
|
||||
await self._update_manual_control_and_maybe_adapt(
|
||||
filtered_lights, transition, force, context
|
||||
)
|
||||
|
||||
async def _update_manual_control_and_maybe_adapt(
|
||||
self,
|
||||
lights: list[str],
|
||||
transition: int | None,
|
||||
force: bool,
|
||||
context: Context | None,
|
||||
) -> None:
|
||||
assert context is not None
|
||||
_LOGGER.debug(
|
||||
"%s: '_update_manual_control_and_maybe_adapt(%s, %s, force=%s, context.id=%s)' called",
|
||||
self.name,
|
||||
lights,
|
||||
transition,
|
||||
force,
|
||||
context.id,
|
||||
)
|
||||
|
||||
adapt_brightness = self.adapt_brightness_switch.is_on
|
||||
adapt_color = self.adapt_color_switch.is_on
|
||||
|
||||
for light in lights:
|
||||
for light in filtered_lights:
|
||||
if not is_on(self.hass, light):
|
||||
continue
|
||||
|
||||
|
|
@ -1375,12 +1339,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
else:
|
||||
await self._adapt_light(light, transition, context=context)
|
||||
|
||||
async def _sleep_mode_switch_state_event(self, event: Event) -> None:
|
||||
if not match_switch_state_event(event, (STATE_ON, STATE_OFF)):
|
||||
async def _sleep_mode_switch_state_event_action(self, event: Event) -> None:
|
||||
if not _is_state_event(event, (STATE_ON, STATE_OFF)):
|
||||
_LOGGER.debug("%s: Ignoring sleep event %s", self._name, event)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"%s: _sleep_mode_switch_state_event, event: '%s'", self._name, event
|
||||
"%s: _sleep_mode_switch_state_event_action, event: '%s'", self._name, event
|
||||
)
|
||||
# Reset the manually controlled status when the "sleep mode" changes
|
||||
self.manager.reset(*self.lights)
|
||||
|
|
@ -1390,7 +1354,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
context=self.create_context("sleep", parent=event.context),
|
||||
)
|
||||
|
||||
async def _light_event(self, event: Event) -> None:
|
||||
async def _light_event_action(self, event: Event) -> None:
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
entity_id = event.data.get("entity_id")
|
||||
|
|
@ -1414,9 +1378,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
|
||||
# Tracks 'off' → 'on' state changes
|
||||
self._off_to_on_event[entity_id] = event
|
||||
lock = self._locks.get(entity_id)
|
||||
if lock is None:
|
||||
lock = self._locks[entity_id] = asyncio.Lock()
|
||||
lock = self._locks.setdefault(entity_id, asyncio.Lock())
|
||||
async with lock:
|
||||
if await self.manager.maybe_cancel_adjusting(
|
||||
entity_id,
|
||||
|
|
@ -1549,18 +1511,15 @@ class SunLightSettings:
|
|||
time_zone: datetime.tzinfo
|
||||
transition: int
|
||||
|
||||
def get_sun_events(self, date: datetime.datetime) -> dict[str, float]:
|
||||
def get_sun_events(self, date: datetime.datetime) -> list[tuple[str, float]]:
|
||||
"""Get the four sun event's timestamps at 'date'."""
|
||||
|
||||
def _replace_time(date: datetime.datetime, key: str) -> datetime.datetime:
|
||||
time = getattr(self, f"{key}_time")
|
||||
date_time = datetime.datetime.combine(date, time)
|
||||
try: # HA ≤2021.05, https://github.com/basnijholt/adaptive-lighting/issues/128
|
||||
utc_time = self.time_zone.localize(date_time).astimezone(dt_util.UTC)
|
||||
except AttributeError: # HA ≥2021.06
|
||||
utc_time = date_time.replace(
|
||||
tzinfo=dt_util.DEFAULT_TIME_ZONE
|
||||
).astimezone(dt_util.UTC)
|
||||
utc_time = date_time.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE).astimezone(
|
||||
dt_util.UTC
|
||||
)
|
||||
return utc_time
|
||||
|
||||
def calculate_noon_and_midnight(
|
||||
|
|
@ -1606,16 +1565,10 @@ class SunLightSettings:
|
|||
and self.max_sunrise_time is None
|
||||
and self.min_sunset_time is None
|
||||
):
|
||||
try:
|
||||
# Astral v1
|
||||
solar_noon = location.solar_noon(date, local=False)
|
||||
solar_midnight = location.solar_midnight(date, local=False)
|
||||
except AttributeError:
|
||||
# Astral v2
|
||||
solar_noon = location.noon(date, local=False)
|
||||
solar_midnight = location.midnight(date, local=False)
|
||||
solar_noon = location.noon(date, local=False)
|
||||
solar_midnight = location.midnight(date, local=False)
|
||||
else:
|
||||
(solar_noon, solar_midnight) = calculate_noon_and_midnight(sunset, sunrise)
|
||||
solar_noon, solar_midnight = calculate_noon_and_midnight(sunset, sunrise)
|
||||
|
||||
events = [
|
||||
(SUN_EVENT_SUNRISE, sunrise.timestamp()),
|
||||
|
|
@ -1641,9 +1594,10 @@ class SunLightSettings:
|
|||
def relevant_events(self, now: datetime.datetime) -> list[tuple[str, float]]:
|
||||
"""Get the previous and next sun event."""
|
||||
events = [
|
||||
self.get_sun_events(now + timedelta(days=days)) for days in [-1, 0, 1]
|
||||
event
|
||||
for days in [-1, 0, 1]
|
||||
for event in self.get_sun_events(now + timedelta(days=days))
|
||||
]
|
||||
events = sum(events, []) # flatten lists
|
||||
events = sorted(events, key=lambda x: x[1])
|
||||
i_now = bisect.bisect([ts for _, ts in events], now.timestamp())
|
||||
return events[i_now - 1 : i_now + 1]
|
||||
|
|
@ -1756,23 +1710,22 @@ class AdaptiveLightingManager:
|
|||
# Track light transitions
|
||||
self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
|
||||
|
||||
self.listener_removers = []
|
||||
|
||||
self.listener_removers.append(
|
||||
# Setup listeners and its callbacks to remove them later
|
||||
self.listener_removers = [
|
||||
self.hass.bus.async_listen(
|
||||
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
|
||||
)
|
||||
)
|
||||
self.listener_removers.append(
|
||||
EVENT_CALL_SERVICE,
|
||||
self.turn_on_off_event_listener,
|
||||
),
|
||||
self.hass.bus.async_listen(
|
||||
EVENT_STATE_CHANGED, self.state_changed_event_listener
|
||||
)
|
||||
)
|
||||
EVENT_STATE_CHANGED,
|
||||
self.state_changed_event_listener,
|
||||
),
|
||||
]
|
||||
|
||||
self._proactively_adapting_contexts: dict[str, str] = {}
|
||||
|
||||
is_proactive_adaptation_enabled = (
|
||||
data.get(INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION, True) is not False
|
||||
is_proactive_adaptation_enabled = data.get(
|
||||
INTERNAL_CONF_PROACTIVE_SERVICE_CALL_ADAPTATION, True
|
||||
)
|
||||
|
||||
if is_proactive_adaptation_enabled:
|
||||
|
|
@ -1860,7 +1813,7 @@ class AdaptiveLightingManager:
|
|||
|
||||
entity_id = entity_ids[0]
|
||||
try:
|
||||
adaptive_switch = find_switch_for_lights(self.hass, [entity_id])
|
||||
adaptive_switch = _switch_with_lights(self.hass, [entity_id])
|
||||
except NoSwitchFoundError:
|
||||
# This might be a light that is not managed by this AL instance.
|
||||
_LOGGER.debug(
|
||||
|
|
@ -1891,18 +1844,13 @@ class AdaptiveLightingManager:
|
|||
self.reset(entity_id, reset_manual_control=False)
|
||||
self.clear_proactively_adapting(entity_id)
|
||||
|
||||
adapt_brightness = adaptive_switch.adapt_brightness_switch.is_on or False
|
||||
adapt_color = adaptive_switch.adapt_color_switch.is_on or False
|
||||
transition = (
|
||||
data[CONF_PARAMS].get(ATTR_TRANSITION, None)
|
||||
or adaptive_switch.initial_transition
|
||||
transition = data[CONF_PARAMS].get(
|
||||
ATTR_TRANSITION, adaptive_switch.initial_transition
|
||||
)
|
||||
|
||||
adaptation_data = await adaptive_switch.prepare_adaptation_data(
|
||||
entity_id,
|
||||
transition,
|
||||
adapt_brightness,
|
||||
adapt_color,
|
||||
)
|
||||
if adaptation_data is None:
|
||||
return
|
||||
|
|
@ -1972,7 +1920,7 @@ class AdaptiveLightingManager:
|
|||
)
|
||||
|
||||
async def reset():
|
||||
ValueError("TEST")
|
||||
# Called when the timer expires, doesn't need to do anything
|
||||
_LOGGER.debug(
|
||||
"Transition finished for light %s",
|
||||
light,
|
||||
|
|
@ -2005,7 +1953,7 @@ class AdaptiveLightingManager:
|
|||
|
||||
async def reset():
|
||||
self.reset(light)
|
||||
switches = _get_switches_with_lights(self.hass, [light])
|
||||
switches = _switches_with_lights(self.hass, [light])
|
||||
for switch in switches:
|
||||
if not switch.is_on:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"name": "Adaptive Lighting",
|
||||
"render_readme": true
|
||||
"render_readme": true,
|
||||
"homeassistant": "2022.11.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
from collections import defaultdict
|
||||
|
||||
deps = defaultdict(list)
|
||||
components, packages = [], []
|
||||
|
||||
with open("core/requirements_test_all.txt") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
components = []
|
||||
packages = []
|
||||
deps = {}
|
||||
for i, line in enumerate(lines):
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith("# homeassistant."):
|
||||
component = line.split("# homeassistant.")[1]
|
||||
components.append(component)
|
||||
if components and packages:
|
||||
for component in components:
|
||||
deps[component].extend(packages)
|
||||
components, packages = [], []
|
||||
components.append(line.split("# homeassistant.")[1])
|
||||
elif components and line:
|
||||
packages.append(line)
|
||||
else:
|
||||
for component in components:
|
||||
for package in packages:
|
||||
deps.setdefault(component, []).append(package)
|
||||
components = []
|
||||
packages = []
|
||||
|
||||
# The last batch of components and packages
|
||||
if components and packages:
|
||||
for component in components:
|
||||
deps[component].extend(packages)
|
||||
|
||||
required = [
|
||||
"components.recorder",
|
||||
|
|
@ -24,10 +29,9 @@ required = [
|
|||
"components.zeroconf",
|
||||
"components.http",
|
||||
"components.stream",
|
||||
"components.conversation",
|
||||
"components.conversation", # only available after HA≥2023.2
|
||||
"components.cloud",
|
||||
]
|
||||
to_install = []
|
||||
for r in required:
|
||||
to_install.extend(deps[r])
|
||||
to_install = [package for r in required for package in deps[r]]
|
||||
|
||||
print(" ".join(to_install))
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import pytest
|
|||
from custom_components.adaptive_lighting.adaptation_utils import (
|
||||
ServiceData,
|
||||
_create_service_call_data_iterator,
|
||||
_filter_service_data,
|
||||
_has_relevant_service_data_attributes,
|
||||
_remove_redundant_attributes,
|
||||
_split_service_call_data,
|
||||
prepare_adaptation_data,
|
||||
)
|
||||
|
|
@ -74,11 +74,6 @@ async def test_split_service_call_data(input_data, expected_data_list):
|
|||
@pytest.mark.parametrize(
|
||||
"service_data,state,service_data_expected",
|
||||
[
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
|
||||
None,
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
|
||||
),
|
||||
(
|
||||
{ATTR_ENTITY_ID: "light.test", ATTR_BRIGHTNESS: 10, ATTR_TRANSITION: 2},
|
||||
State("light.test", STATE_ON),
|
||||
|
|
@ -96,17 +91,16 @@ async def test_split_service_call_data(input_data, expected_data_list):
|
|||
),
|
||||
],
|
||||
ids=[
|
||||
"pass all attributes on missing state",
|
||||
"pass all attributes on empty state",
|
||||
"remove attributes whose values equal the state",
|
||||
"keep attributes whose values differ from the state",
|
||||
],
|
||||
)
|
||||
async def test_filter_service_data(
|
||||
async def test_remove_redundant_attributes(
|
||||
service_data: ServiceData, state: State | None, service_data_expected: ServiceData
|
||||
):
|
||||
"""Test filtering of service data."""
|
||||
assert _filter_service_data(service_data, state) == service_data_expected
|
||||
assert _remove_redundant_attributes(service_data, state) == service_data_expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
|
|
@ -1206,10 +1206,10 @@ async def test_turn_on_and_off_when_already_at_that_state(hass):
|
|||
|
||||
|
||||
@pytest.mark.dependency(depends=GLOBAL_TEST_DEPENDENCIES)
|
||||
async def test_async_update_at_interval(hass):
|
||||
"""Test '_async_update_at_interval' method."""
|
||||
async def test_async_update_at_interval_action(hass):
|
||||
"""Test '_async_update_at_interval_action' method."""
|
||||
_, switch = await setup_switch(hass, {})
|
||||
await switch._async_update_at_interval()
|
||||
await switch._async_update_at_interval_action()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("separate_turn_on_commands", (True, False))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue