mirror of
https://github.com/basnijholt/adaptive-lighting.git
synced 2026-09-20 10:44:05 +02:00
merge with origin\main
This commit is contained in:
parent
ccf18c3879
commit
dced97b5ca
22 changed files with 1433 additions and 517 deletions
116
custom_components/adaptive_lighting/_docs_helpers.py
Normal file
116
custom_components/adaptive_lighting/_docs_helpers.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from typing import Any
|
||||
|
||||
from homeassistant.helpers import selector
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import pandas as pd
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
DOCS,
|
||||
DOCS_APPLY,
|
||||
DOCS_MANUAL_CONTROL,
|
||||
SET_MANUAL_CONTROL_SCHEMA,
|
||||
VALIDATION_TUPLES,
|
||||
apply_service_schema,
|
||||
)
|
||||
|
||||
|
||||
def _format_voluptuous_instance(instance):
|
||||
coerce_type = None
|
||||
min_val = None
|
||||
max_val = None
|
||||
|
||||
for validator in instance.validators:
|
||||
if isinstance(validator, vol.Coerce):
|
||||
coerce_type = validator.type.__name__
|
||||
elif isinstance(validator, (vol.Clamp, vol.Range)):
|
||||
min_val = validator.min
|
||||
max_val = validator.max
|
||||
|
||||
if min_val is not None and max_val is not None:
|
||||
return f"`{coerce_type}` {min_val}-{max_val}"
|
||||
elif min_val is not None:
|
||||
return f"`{coerce_type} > {min_val}`"
|
||||
elif max_val is not None:
|
||||
return f"`{coerce_type} < {max_val}`"
|
||||
else:
|
||||
return f"`{coerce_type}`"
|
||||
|
||||
|
||||
def _type_to_str(type_: Any) -> str:
|
||||
"""Convert a (voluptuous) type to a string."""
|
||||
if type_ == cv.entity_ids:
|
||||
return "list of `entity_id`s"
|
||||
elif type_ in (bool, int, float, str):
|
||||
return f"`{type_.__name__}`"
|
||||
elif type_ == cv.boolean:
|
||||
return "bool"
|
||||
elif isinstance(type_, vol.All):
|
||||
return _format_voluptuous_instance(type_)
|
||||
elif isinstance(type_, vol.In):
|
||||
return f"one of `{type_.container}`"
|
||||
elif isinstance(type_, selector.SelectSelector):
|
||||
return f"one of `{type_.config['options']}`"
|
||||
elif isinstance(type_, selector.ColorRGBSelector):
|
||||
return "RGB color"
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {type_}")
|
||||
|
||||
|
||||
def generate_config_markdown_table():
|
||||
import pandas as pd
|
||||
|
||||
rows = []
|
||||
for k, default, type_ in VALIDATION_TUPLES:
|
||||
description = DOCS[k]
|
||||
row = {
|
||||
"Variable name": f"`{k}`",
|
||||
"Description": description,
|
||||
"Default": f"`{default}`",
|
||||
"Type": _type_to_str(type_),
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
return df.to_markdown(index=False)
|
||||
|
||||
|
||||
def _schema_to_dict(schema: vol.Schema) -> dict[str, tuple[Any, Any]]:
|
||||
result = {}
|
||||
for key, value in schema.schema.items():
|
||||
if isinstance(key, vol.Optional):
|
||||
default_value = key.default
|
||||
result[key.schema] = (default_value, value)
|
||||
return result
|
||||
|
||||
|
||||
def _generate_service_markdown_table(
|
||||
schema: dict[str, tuple[Any, Any]], alternative_docs: dict[str, str] = None
|
||||
):
|
||||
schema = _schema_to_dict(schema)
|
||||
rows = []
|
||||
for k, (default, type_) in schema.items():
|
||||
if alternative_docs is not None and k in alternative_docs:
|
||||
description = alternative_docs[k]
|
||||
else:
|
||||
description = DOCS[k]
|
||||
row = {
|
||||
"Service data attribute": f"`{k}`",
|
||||
"Description": description,
|
||||
"Required": "✅" if default == vol.UNDEFINED else "❌",
|
||||
"Type": _type_to_str(type_),
|
||||
}
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
return df.to_markdown(index=False)
|
||||
|
||||
|
||||
def generate_apply_markdown_table():
|
||||
return _generate_service_markdown_table(apply_service_schema(), DOCS_APPLY)
|
||||
|
||||
|
||||
def generate_set_manual_control_markdown_table():
|
||||
return _generate_service_markdown_table(
|
||||
SET_MANUAL_CONTROL_SCHEMA, DOCS_MANUAL_CONTROL
|
||||
)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"""Constants for the Adaptive Lighting integration."""
|
||||
|
||||
from homeassistant.components.light import VALID_TRANSITION
|
||||
from homeassistant.const import CONF_ENTITY_ID
|
||||
from homeassistant.helpers import selector
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import voluptuous as vol
|
||||
|
|
@ -14,16 +15,14 @@ DOMAIN = "adaptive_lighting"
|
|||
SUN_EVENT_NOON = "solar_noon"
|
||||
SUN_EVENT_MIDNIGHT = "solar_midnight"
|
||||
|
||||
DOCS = {}
|
||||
DOCS = {CONF_ENTITY_ID: "Entity ID of the switch. 📝"}
|
||||
|
||||
|
||||
CONF_NAME, DEFAULT_NAME = "name", "default"
|
||||
DOCS[CONF_NAME] = "Display name for this switch. 📝"
|
||||
|
||||
CONF_LIGHTS, DEFAULT_LIGHTS = "lights", []
|
||||
DOCS[CONF_LIGHTS] = (
|
||||
"List of light entities to be controlled by Adaptive " "Lighting (may be empty). 🌟"
|
||||
)
|
||||
DOCS[CONF_LIGHTS] = "List of light entity_ids to be controlled (may be empty). 🌟"
|
||||
|
||||
CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES = (
|
||||
"detect_non_ha_changes",
|
||||
|
|
@ -45,11 +44,14 @@ DOCS[CONF_INCLUDE_CONFIG_IN_ATTRIBUTES] = (
|
|||
|
||||
CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION = "initial_transition", 1
|
||||
DOCS[CONF_INITIAL_TRANSITION] = (
|
||||
"Duration of the first transition when lights turn " "from `off` to `on`. ⏲️"
|
||||
"Duration of the first transition when lights turn "
|
||||
"from `off` to `on` in seconds. ⏲️"
|
||||
)
|
||||
|
||||
CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION = "sleep_transition", 1
|
||||
DOCS[CONF_SLEEP_TRANSITION] = "Duration of transition when 'sleep mode' is toggled. 😴"
|
||||
DOCS[CONF_SLEEP_TRANSITION] = (
|
||||
'Duration of transition when "sleep mode" is toggled ' "in seconds. 😴"
|
||||
)
|
||||
|
||||
CONF_INTERVAL, DEFAULT_INTERVAL = "interval", 90
|
||||
DOCS[CONF_INTERVAL] = "Frequency to adapt the lights, in seconds. 🔄"
|
||||
|
|
@ -73,9 +75,10 @@ DOCS[CONF_ONLY_ONCE] = (
|
|||
)
|
||||
|
||||
CONF_PREFER_RGB_COLOR, DEFAULT_PREFER_RGB_COLOR = "prefer_rgb_color", False
|
||||
DOCS[
|
||||
CONF_PREFER_RGB_COLOR
|
||||
] = "Use RGB color adjustment instead of native light color temperature. 🌈"
|
||||
DOCS[CONF_PREFER_RGB_COLOR] = (
|
||||
"Whether to prefer RGB color adjustment over "
|
||||
"light color temperature when possible. 🌈"
|
||||
)
|
||||
|
||||
CONF_SEPARATE_TURN_ON_COMMANDS, DEFAULT_SEPARATE_TURN_ON_COMMANDS = (
|
||||
"separate_turn_on_commands",
|
||||
|
|
@ -87,47 +90,53 @@ DOCS[CONF_SEPARATE_TURN_ON_COMMANDS] = (
|
|||
)
|
||||
|
||||
CONF_SLEEP_BRIGHTNESS, DEFAULT_SLEEP_BRIGHTNESS = "sleep_brightness", 1
|
||||
DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness of lights in sleep mode. 😴"
|
||||
DOCS[CONF_SLEEP_BRIGHTNESS] = "Brightness percentage of lights in sleep mode. 😴"
|
||||
|
||||
CONF_SLEEP_COLOR_TEMP, DEFAULT_SLEEP_COLOR_TEMP = "sleep_color_temp", 1000
|
||||
DOCS[CONF_SLEEP_COLOR_TEMP] = (
|
||||
"Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is "
|
||||
"`color_temp`). 😴"
|
||||
"`color_temp`) in Kelvin. 😴"
|
||||
)
|
||||
|
||||
CONF_SLEEP_RGB_COLOR, DEFAULT_SLEEP_RGB_COLOR = "sleep_rgb_color", [255, 56, 0]
|
||||
DOCS[CONF_SLEEP_RGB_COLOR] = (
|
||||
"RGB color in sleep mode (used when " "`sleep_rgb_or_color_temp` is 'rgb_color'). 🌈"
|
||||
"RGB color in sleep mode (used when " '`sleep_rgb_or_color_temp` is "rgb_color"). 🌈'
|
||||
)
|
||||
|
||||
CONF_SLEEP_RGB_OR_COLOR_TEMP, DEFAULT_SLEEP_RGB_OR_COLOR_TEMP = (
|
||||
"sleep_rgb_or_color_temp",
|
||||
"color_temp",
|
||||
)
|
||||
DOCS[
|
||||
CONF_SLEEP_RGB_OR_COLOR_TEMP
|
||||
] = "Use either `'rgb_color'` or `'color_temp'` in sleep mode. 🌙"
|
||||
DOCS[CONF_SLEEP_RGB_OR_COLOR_TEMP] = (
|
||||
'Use either `"rgb_color"` or `"color_temp"` ' "in sleep mode. 🌙"
|
||||
)
|
||||
|
||||
CONF_SUNRISE_OFFSET, DEFAULT_SUNRISE_OFFSET = "sunrise_offset", 0
|
||||
DOCS[CONF_SUNRISE_OFFSET] = "Adjust sunrise time with a positive or negative offset. ⏰"
|
||||
DOCS[CONF_SUNRISE_OFFSET] = (
|
||||
"Adjust sunrise time with a positive or negative offset " "in seconds. ⏰"
|
||||
)
|
||||
|
||||
CONF_SUNRISE_TIME = "sunrise_time"
|
||||
DOCS[CONF_SUNRISE_TIME] = "Set a fixed time for sunrise. 🌅"
|
||||
DOCS[CONF_SUNRISE_TIME] = "Set a fixed time (HH:MM:SS) for sunrise. 🌅"
|
||||
|
||||
CONF_MAX_SUNRISE_TIME = "max_sunrise_time"
|
||||
DOCS[CONF_MAX_SUNRISE_TIME] = (
|
||||
"Set the latest virtual sunrise time, allowing" " for earlier real sunrises. 🌅"
|
||||
"Set the latest virtual sunrise time (HH:MM:SS), allowing"
|
||||
" for earlier real sunrises. 🌅"
|
||||
)
|
||||
|
||||
CONF_SUNSET_OFFSET, DEFAULT_SUNSET_OFFSET = "sunset_offset", 0
|
||||
DOCS[CONF_SUNSET_OFFSET] = "Adjust sunset time with a positive or negative offset. ⏰"
|
||||
DOCS[
|
||||
CONF_SUNSET_OFFSET
|
||||
] = "Adjust sunset time with a positive or negative offset in seconds. ⏰"
|
||||
|
||||
CONF_SUNSET_TIME = "sunset_time"
|
||||
DOCS[CONF_SUNSET_TIME] = "Set a fixed time for sunset. 🌇"
|
||||
DOCS[CONF_SUNSET_TIME] = "Set a fixed time (HH:MM:SS) for sunset. 🌇"
|
||||
|
||||
CONF_MIN_SUNSET_TIME = "min_sunset_time"
|
||||
DOCS[CONF_MIN_SUNSET_TIME] = (
|
||||
"Set the earliest virtual sunset time, allowing" " for later real sunsets. 🌇"
|
||||
"Set the earliest virtual sunset time (HH:MM:SS), allowing"
|
||||
" for later real sunsets. 🌇"
|
||||
)
|
||||
|
||||
CONF_TAKE_OVER_CONTROL, DEFAULT_TAKE_OVER_CONTROL = "take_over_control", True
|
||||
|
|
@ -140,18 +149,32 @@ DOCS[CONF_TAKE_OVER_CONTROL] = (
|
|||
CONF_TRANSITION, DEFAULT_TRANSITION = "transition", 45
|
||||
DOCS[CONF_TRANSITION] = "Duration of transition when lights change, in seconds. 🕑"
|
||||
|
||||
CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP = (
|
||||
"transition_until_sleep",
|
||||
False,
|
||||
)
|
||||
DOCS[CONF_ADAPT_UNTIL_SLEEP] = (
|
||||
"When enabled, Adaptive Lighting will treat sleep settings as the minimum, "
|
||||
"transitioning to these values after sunset. 🌙"
|
||||
)
|
||||
|
||||
CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY = "adapt_delay", 0
|
||||
DOCS[CONF_ADAPT_DELAY] = (
|
||||
"Wait time (seconds) between light turn on and Adaptive Lighting applying "
|
||||
"changes. Helps avoid flickering. ⏲️"
|
||||
"changes. Might help to avoid flickering. ⏲️"
|
||||
)
|
||||
|
||||
CONF_SEND_SPLIT_DELAY, DEFAULT_SEND_SPLIT_DELAY = "send_split_delay", 0
|
||||
DOCS[CONF_SEND_SPLIT_DELAY] = (
|
||||
"Wait time (milliseconds) between commands when using `separate_turn_on_commands`. "
|
||||
"Helps ensure correct handling. ⏲️"
|
||||
"Delay (ms) between `separate_turn_on_commands` for lights that don't support "
|
||||
"simultaneous brightness and color setting. ⏲️"
|
||||
)
|
||||
|
||||
CONF_AUTORESET_CONTROL, DEFAULT_AUTORESET_CONTROL = "autoreset_control_seconds", 0
|
||||
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"
|
||||
|
|
@ -160,18 +183,40 @@ ATTR_TURN_ON_OFF_LISTENER = "turn_on_off_listener"
|
|||
UNDO_UPDATE_LISTENER = "undo_update_listener"
|
||||
NONE_STR = "None"
|
||||
ATTR_ADAPT_COLOR = "adapt_color"
|
||||
DOCS[ATTR_ADAPT_COLOR] = "Whether to adapt the color on supporting lights. 🌈"
|
||||
ATTR_ADAPT_BRIGHTNESS = "adapt_brightness"
|
||||
DOCS[ATTR_ADAPT_BRIGHTNESS] = "Whether to adapt the brightness of the light. 🌞"
|
||||
|
||||
SERVICE_SET_MANUAL_CONTROL = "set_manual_control"
|
||||
CONF_MANUAL_CONTROL = "manual_control"
|
||||
DOCS[CONF_MANUAL_CONTROL] = "Whether to manually control the lights. 🔒"
|
||||
SERVICE_APPLY = "apply"
|
||||
CONF_TURN_ON_LIGHTS = "turn_on_lights"
|
||||
DOCS[CONF_TURN_ON_LIGHTS] = "Whether to turn on lights that are currently off. 🔆"
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS = "change_switch_settings"
|
||||
CONF_USE_DEFAULTS = "use_defaults"
|
||||
|
||||
DOCS[CONF_USE_DEFAULTS] = (
|
||||
"Sets the default values not specified in this service call. Options: "
|
||||
'"current" (default, retains current values), "factory" (resets to '
|
||||
'documented defaults), or "configuration" (reverts to switch config defaults). ⚙️'
|
||||
)
|
||||
|
||||
TURNING_OFF_DELAY = 5
|
||||
|
||||
DOCS_MANUAL_CONTROL = {
|
||||
CONF_ENTITY_ID: "The `entity_id` of the switch in which to (un)mark the "
|
||||
"light as being `manually controlled`. 📝",
|
||||
CONF_LIGHTS: "entity_id(s) of lights, if not specified, all lights in the "
|
||||
"switch are selected. 💡",
|
||||
CONF_MANUAL_CONTROL: 'Whether to add ("true") or remove ("false") the '
|
||||
'light from the "manual_control" list. 🔒',
|
||||
}
|
||||
|
||||
DOCS_APPLY = {
|
||||
CONF_ENTITY_ID: "The `entity_id` of the switch with the settings to apply. 📝",
|
||||
CONF_LIGHTS: "A light (or list of lights) to apply the settings to. 💡",
|
||||
}
|
||||
|
||||
|
||||
def int_between(min_int, max_int):
|
||||
"""Return an integer between 'min_int' and 'max_int'."""
|
||||
|
|
@ -185,6 +230,7 @@ VALIDATION_TUPLES = [
|
|||
(CONF_INITIAL_TRANSITION, DEFAULT_INITIAL_TRANSITION, VALID_TRANSITION),
|
||||
(CONF_SLEEP_TRANSITION, DEFAULT_SLEEP_TRANSITION, VALID_TRANSITION),
|
||||
(CONF_TRANSITION, DEFAULT_TRANSITION, VALID_TRANSITION),
|
||||
(CONF_ADAPT_UNTIL_SLEEP, DEFAULT_ADAPT_UNTIL_SLEEP, bool),
|
||||
(CONF_INTERVAL, DEFAULT_INTERVAL, cv.positive_int),
|
||||
(CONF_MIN_BRIGHTNESS, DEFAULT_MIN_BRIGHTNESS, int_between(1, 100)),
|
||||
(CONF_MAX_BRIGHTNESS, DEFAULT_MAX_BRIGHTNESS, int_between(1, 100)),
|
||||
|
|
@ -220,6 +266,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, 365 * 24 * 60 * 60), # 1 year max
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -270,55 +321,28 @@ _DOMAIN_SCHEMA = vol.Schema(
|
|||
)
|
||||
|
||||
|
||||
def _format_voluptuous_instance(instance):
|
||||
coerce_type = None
|
||||
min_val = None
|
||||
max_val = None
|
||||
|
||||
for validator in instance.validators:
|
||||
if isinstance(validator, vol.Coerce):
|
||||
coerce_type = validator.type.__name__
|
||||
elif isinstance(validator, (vol.Clamp, vol.Range)):
|
||||
min_val = validator.min
|
||||
max_val = validator.max
|
||||
|
||||
if min_val is not None and max_val is not None:
|
||||
return f"`{coerce_type}` {min_val}-{max_val}"
|
||||
elif min_val is not None:
|
||||
return f"`{coerce_type} > {min_val}`"
|
||||
elif max_val is not None:
|
||||
return f"`{coerce_type} < {max_val}`"
|
||||
else:
|
||||
return f"`{coerce_type}`"
|
||||
|
||||
|
||||
def generate_markdown_table():
|
||||
import pandas as pd
|
||||
|
||||
rows = []
|
||||
for k, default, type_ in VALIDATION_TUPLES:
|
||||
description = DOCS[k]
|
||||
if type_ == cv.entity_ids:
|
||||
type_ = "list of `entity_id`s"
|
||||
elif type_ in (bool, int, float, str):
|
||||
type_ = f"`{type_.__name__}`"
|
||||
elif isinstance(type_, vol.All):
|
||||
type_ = _format_voluptuous_instance(type_)
|
||||
elif isinstance(type_, vol.In):
|
||||
type_ = f"one of `{type_.container}`"
|
||||
elif isinstance(type_, selector.SelectSelector):
|
||||
type_ = f"one of `{type_.config['options']}`"
|
||||
elif isinstance(type_, selector.ColorRGBSelector):
|
||||
type_ = "RGB color"
|
||||
else:
|
||||
raise ValueError(f"Unknown type: {type_}")
|
||||
row = {
|
||||
"Variable name": f"`{k}`",
|
||||
"Description": description,
|
||||
"Default": f"`{default}`",
|
||||
"Type": type_,
|
||||
def apply_service_schema(initial_transition: int = 1):
|
||||
"""Return the schema for the apply service."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
|
||||
vol.Optional(
|
||||
CONF_TRANSITION,
|
||||
default=initial_transition,
|
||||
): VALID_TRANSITION,
|
||||
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
|
||||
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
|
||||
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
|
||||
vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean,
|
||||
}
|
||||
rows.append(row)
|
||||
)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
return df.to_markdown(index=False)
|
||||
|
||||
SET_MANUAL_CONTROL_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
|
||||
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
|
||||
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@
|
|||
"documentation": "https://github.com/basnijholt/adaptive-lighting#readme",
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/basnijholt/adaptive-lighting/issues",
|
||||
"requirements": [],
|
||||
"version": "1.8.0"
|
||||
"requirements": ["ulid-transform"],
|
||||
"version": "1.11.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,191 +1,186 @@
|
|||
# This file is auto-generated by .github/update-services.py.
|
||||
apply:
|
||||
description: Applies the current Adaptive Lighting settings to lights.
|
||||
fields:
|
||||
entity_id:
|
||||
description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used.
|
||||
example: switch.adaptive_lighting_default
|
||||
description: The `entity_id` of the switch with the settings to apply. 📝
|
||||
selector:
|
||||
entity:
|
||||
integration: adaptive_lighting
|
||||
domain: switch
|
||||
multiple: false
|
||||
lights:
|
||||
description: entity_id(s) of lights, if not specified, all lights in the switch are selected.
|
||||
example: light.bedroom_ceiling
|
||||
description: A light (or list of lights) to apply the settings to. 💡
|
||||
selector:
|
||||
entity:
|
||||
domain: light
|
||||
multiple: true
|
||||
transition:
|
||||
description: Transition of the lights.
|
||||
description: Duration of transition when lights change, in seconds. 🕑
|
||||
example: 10
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
adapt_brightness:
|
||||
description: "Adapt the 'brightness', default: true"
|
||||
description: Whether to adapt the brightness of the light. 🌞
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
adapt_color:
|
||||
description: "Adapt the color_temp/color_rgb, default: true"
|
||||
description: Whether to adapt the color on supporting lights. 🌈
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
prefer_rgb_color:
|
||||
description: "Prefer to use color_rgb over color_temp if possible, default: false"
|
||||
description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
turn_on_lights:
|
||||
description: "Turn on the lights that are off, default: false"
|
||||
description: Whether to turn on lights that are currently off. 🔆
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
boolean: null
|
||||
set_manual_control:
|
||||
description: Mark whether a light is 'manually controlled'.
|
||||
fields:
|
||||
entity_id:
|
||||
description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used.
|
||||
example: switch.adaptive_lighting_default
|
||||
description: The `entity_id` of the switch in which to (un)mark the light as being `manually controlled`. 📝
|
||||
selector:
|
||||
entity:
|
||||
integration: adaptive_lighting
|
||||
domain: switch
|
||||
multiple: false
|
||||
lights:
|
||||
description: entity_id(s) of lights, if not specified, all lights in the switch are selected.
|
||||
example: light.bedroom_ceiling
|
||||
description: entity_id(s) of lights, if not specified, all lights in the switch are selected. 💡
|
||||
selector:
|
||||
entity:
|
||||
domain: light
|
||||
multiple: true
|
||||
manual_control:
|
||||
description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true"
|
||||
description: Whether to add ("true") or remove ("false") the light from the "manual_control" list. 🔒
|
||||
example: true
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
boolean: null
|
||||
change_switch_settings:
|
||||
description: "Change any settings you'd like in the switch. All options here are the same as in the config flow."
|
||||
description: Change any settings you'd like in the switch. All options here are the same as in the config flow.
|
||||
fields:
|
||||
entity_id:
|
||||
description: "entity_id of the Adaptive Lighting switch."
|
||||
description: Entity ID of the switch. 📝
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: switch
|
||||
use_defaults:
|
||||
description: "(default: 'current' for current settings) You can set this to 'factory', 'configuration', or 'current' to reset the variables not being set with this service call. 'current' leaves them as is, 'configuration' resets to whatever already initializes at startup, 'factory' resets to the default values listed in the documentation."
|
||||
example: "current"
|
||||
description: 'Sets the default values not specified in this service call. Options: "current" (default, retains current values), "factory" (resets to documented defaults), or "configuration" (reverts to switch config defaults). ⚙️'
|
||||
example: current
|
||||
required: false
|
||||
default: "current"
|
||||
default: current
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "current"
|
||||
- "configuration"
|
||||
- "factory"
|
||||
- current
|
||||
- configuration
|
||||
- factory
|
||||
include_config_in_attributes:
|
||||
description: "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)"
|
||||
description: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
turn_on_lights:
|
||||
description: "Turn on the lights that are off, default: false"
|
||||
description: Whether to turn on lights that are currently off. 🔆
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
initial_transition:
|
||||
description: "initial_transition: When lights turn 'off' to 'on'. (seconds)"
|
||||
description: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️
|
||||
example: 1
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
sleep_transition:
|
||||
description: "sleep_transition: When 'sleep_state' changes. (seconds)"
|
||||
description: Duration of transition when "sleep mode" is toggled in seconds. 😴
|
||||
example: 1
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
max_brightness:
|
||||
description: "max_brightness: Highest brightness of lights during a cycle. (%)"
|
||||
description: Maximum brightness percentage. 💡
|
||||
required: false
|
||||
example: 100
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
max_color_temp:
|
||||
description: "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)"
|
||||
description: Coldest color temperature in Kelvin. ❄️
|
||||
required: false
|
||||
example: 5500
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
min_brightness:
|
||||
description: "min_brightness: Lowest brightness of lights during a cycle. (%)"
|
||||
description: Minimum brightness percentage. 💡
|
||||
required: false
|
||||
example: 1
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
min_color_temp:
|
||||
description: "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)"
|
||||
description: Warmest color temperature in Kelvin. 🔥
|
||||
required: false
|
||||
example: 2000
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
only_once:
|
||||
description: "only_once: Only adapt the lights when turning them on."
|
||||
description: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
prefer_rgb_color:
|
||||
description: "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible."
|
||||
description: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
separate_turn_on_commands:
|
||||
description: "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights)."
|
||||
description: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
send_split_delay:
|
||||
description: "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly."
|
||||
description: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️
|
||||
required: false
|
||||
example: 0
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
sleep_brightness:
|
||||
description: "sleep_brightness, Brightness setting for Sleep Mode. (%)"
|
||||
description: Brightness percentage of lights in sleep mode. 😴
|
||||
required: false
|
||||
example: 1
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
sleep_rgb_or_color_temp:
|
||||
description: "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'"
|
||||
description: Use either `"rgb_color"` or `"color_temp"` in sleep mode. 🌙
|
||||
required: false
|
||||
example: "color_temp"
|
||||
example: color_temp
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "rgb_color"
|
||||
- "color_temp"
|
||||
- rgb_color
|
||||
- color_temp
|
||||
sleep_rgb_color:
|
||||
description: "sleep_rgb_color, in RGB"
|
||||
description: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is "rgb_color"). 🌈
|
||||
required: false
|
||||
selector:
|
||||
color_rgb:
|
||||
color_rgb: null
|
||||
sleep_color_temp:
|
||||
description: "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)"
|
||||
description: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴
|
||||
required: false
|
||||
example: 1000
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
sunrise_offset:
|
||||
description: sunrise_offset, in +/- seconds (integer)
|
||||
description: Adjust sunrise time with a positive or negative offset in seconds. ⏰
|
||||
required: false
|
||||
example: 0
|
||||
selector:
|
||||
|
|
@ -193,58 +188,64 @@ change_switch_settings:
|
|||
min: 0
|
||||
max: 86300
|
||||
sunrise_time:
|
||||
description: sunrise_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunrise time at your location)
|
||||
description: Set a fixed time (HH:MM:SS) for sunrise. 🌅
|
||||
required: false
|
||||
example: ""
|
||||
example: ''
|
||||
selector:
|
||||
time:
|
||||
time: null
|
||||
sunset_offset:
|
||||
description: sunset_offset, in +/- seconds (integer)
|
||||
description: Adjust sunset time with a positive or negative offset in seconds. ⏰
|
||||
required: false
|
||||
example: ""
|
||||
example: ''
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 86300
|
||||
sunset_time:
|
||||
description: sunset_time, in 'HH:MM:SS' format (if 'None', it uses the actual sunset time at your location)
|
||||
example: ""
|
||||
description: Set a fixed time (HH:MM:SS) for sunset. 🌇
|
||||
example: ''
|
||||
required: false
|
||||
selector:
|
||||
time:
|
||||
time: null
|
||||
max_sunrise_time:
|
||||
description: "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)"
|
||||
example: ""
|
||||
description: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅
|
||||
example: ''
|
||||
required: false
|
||||
selector:
|
||||
time:
|
||||
time: null
|
||||
min_sunset_time:
|
||||
description: "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)"
|
||||
example: ""
|
||||
description: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇
|
||||
example: ''
|
||||
required: false
|
||||
selector:
|
||||
time:
|
||||
time: null
|
||||
take_over_control:
|
||||
description: "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on."
|
||||
description: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒
|
||||
required: false
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
detect_non_ha_changes:
|
||||
description: "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)"
|
||||
description: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️
|
||||
required: false
|
||||
example: false
|
||||
selector:
|
||||
boolean:
|
||||
boolean: null
|
||||
transition:
|
||||
description: "Transition time when applying a change to the lights (seconds)"
|
||||
description: Duration of transition when lights change, in seconds. 🕑
|
||||
required: false
|
||||
example: 45
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
adapt_delay:
|
||||
description: "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering."
|
||||
description: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️
|
||||
required: false
|
||||
example: 0
|
||||
selector:
|
||||
text:
|
||||
text: null
|
||||
autoreset_control_seconds:
|
||||
description: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️
|
||||
required: false
|
||||
example: 0
|
||||
selector:
|
||||
text: null
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Choose a name for the Adaptive Lighting",
|
||||
"title": "Choose a name for the Adaptive Lighting instance",
|
||||
"description": "Every instance can contain multiple lights!",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
|
|
@ -19,33 +19,35 @@
|
|||
"title": "Adaptive Lighting options",
|
||||
"description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have this entry defined in YAML.",
|
||||
"data": {
|
||||
"lights": "lights",
|
||||
"initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)",
|
||||
"include_config_in_attributes": "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)",
|
||||
"sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)",
|
||||
"interval": "interval: Time between switch updates. (seconds)",
|
||||
"max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)",
|
||||
"max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)",
|
||||
"min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)",
|
||||
"min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)",
|
||||
"only_once": "only_once: Only adapt the lights when turning them on.",
|
||||
"prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).",
|
||||
"send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.",
|
||||
"sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)",
|
||||
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'",
|
||||
"sleep_rgb_color": "sleep_rgb_color, in RGB",
|
||||
"sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)",
|
||||
"sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)",
|
||||
"sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
|
||||
"max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
|
||||
"sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)",
|
||||
"sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
|
||||
"min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
|
||||
"take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.",
|
||||
"detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)",
|
||||
"transition": "Transition time when applying a change to the lights (seconds)",
|
||||
"adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering."
|
||||
"lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟",
|
||||
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
|
||||
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
|
||||
"initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
|
||||
"sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
|
||||
"transition": "transition: Duration of transition when lights change, in seconds. 🕑",
|
||||
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
|
||||
"interval": "interval: Frequency to adapt the lights, in seconds. 🔄",
|
||||
"min_brightness": "min_brightness: Minimum brightness percentage. 💡",
|
||||
"max_brightness": "max_brightness: Maximum brightness percentage. 💡",
|
||||
"min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥",
|
||||
"max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️",
|
||||
"sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴",
|
||||
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
|
||||
"sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴",
|
||||
"sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
|
||||
"sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅",
|
||||
"max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅",
|
||||
"sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰",
|
||||
"sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇",
|
||||
"min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇",
|
||||
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰",
|
||||
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
|
||||
"take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒",
|
||||
"detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
|
||||
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
|
||||
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import base64
|
||||
import bisect
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Coroutine
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
|
|
@ -39,7 +39,6 @@ from homeassistant.components.light import (
|
|||
SUPPORT_COLOR,
|
||||
SUPPORT_COLOR_TEMP,
|
||||
SUPPORT_TRANSITION,
|
||||
VALID_TRANSITION,
|
||||
is_on,
|
||||
)
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
|
|
@ -86,8 +85,10 @@ from homeassistant.util.color import (
|
|||
color_RGB_to_xy,
|
||||
color_temperature_to_rgb,
|
||||
color_xy_to_hs,
|
||||
color_xy_to_RGB,
|
||||
)
|
||||
import homeassistant.util.dt as dt_util
|
||||
import ulid_transform
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
|
|
@ -97,6 +98,8 @@ from .const import (
|
|||
ATTR_ADAPT_COLOR,
|
||||
ATTR_TURN_ON_OFF_LISTENER,
|
||||
CONF_ADAPT_DELAY,
|
||||
CONF_ADAPT_UNTIL_SLEEP,
|
||||
CONF_AUTORESET_CONTROL,
|
||||
CONF_DETECT_NON_HA_CHANGES,
|
||||
CONF_INCLUDE_CONFIG_IN_ATTRIBUTES,
|
||||
CONF_INITIAL_TRANSITION,
|
||||
|
|
@ -135,11 +138,13 @@ from .const import (
|
|||
SERVICE_APPLY,
|
||||
SERVICE_CHANGE_SWITCH_SETTINGS,
|
||||
SERVICE_SET_MANUAL_CONTROL,
|
||||
SET_MANUAL_CONTROL_SCHEMA,
|
||||
SLEEP_MODE_SWITCH,
|
||||
SUN_EVENT_MIDNIGHT,
|
||||
SUN_EVENT_NOON,
|
||||
TURNING_OFF_DELAY,
|
||||
VALIDATION_TUPLES,
|
||||
apply_service_schema,
|
||||
replace_none_str,
|
||||
)
|
||||
|
||||
|
|
@ -178,21 +183,58 @@ BRIGHTNESS_ATTRS = {
|
|||
}
|
||||
|
||||
# Keep a short domain version for the context instances (which can only be 36 chars)
|
||||
_DOMAIN_SHORT = "adapt_lgt"
|
||||
_DOMAIN_SHORT = "al"
|
||||
|
||||
|
||||
def _int_to_bytes(i: int, signed: bool = False) -> bytes:
|
||||
bits = i.bit_length()
|
||||
if signed:
|
||||
# Make room for the sign bit.
|
||||
bits += 1
|
||||
return i.to_bytes((bits + 7) // 8, "little", signed=signed)
|
||||
def _int_to_base36(num: int) -> str:
|
||||
"""
|
||||
Convert an integer to its base-36 representation using numbers and uppercase letters.
|
||||
|
||||
Base-36 encoding uses digits 0-9 and uppercase letters A-Z, providing a case-insensitive
|
||||
alphanumeric representation. The function takes an integer `num` as input and returns
|
||||
its base-36 representation as a string.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
num
|
||||
The integer to convert to base-36.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The base-36 representation of the input integer.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> num = 123456
|
||||
>>> base36_num = int_to_base36(num)
|
||||
>>> print(base36_num)
|
||||
'2N9'
|
||||
"""
|
||||
ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
if num == 0:
|
||||
return ALPHANUMERIC_CHARS[0]
|
||||
|
||||
base36_str = ""
|
||||
base = len(ALPHANUMERIC_CHARS)
|
||||
|
||||
while num:
|
||||
num, remainder = divmod(num, base)
|
||||
base36_str = ALPHANUMERIC_CHARS[remainder] + base36_str
|
||||
|
||||
return base36_str
|
||||
|
||||
|
||||
def _short_hash(string: str, length: int = 4) -> str:
|
||||
"""Create a hash of 'string' with length 'length'."""
|
||||
str_hash_bytes = _int_to_bytes(hash(string), signed=True)
|
||||
return base64.b85encode(str_hash_bytes)[:length]
|
||||
return base64.b32encode(string.encode()).decode("utf-8").zfill(length)[:length]
|
||||
|
||||
|
||||
def _remove_vowels(input_str: str, length: int = 4) -> str:
|
||||
vowels = "aeiouAEIOU"
|
||||
output_str = "".join([char for char in input_str if char not in vowels])
|
||||
return output_str.zfill(length)[:length]
|
||||
|
||||
|
||||
def create_context(
|
||||
|
|
@ -200,12 +242,16 @@ def create_context(
|
|||
) -> Context:
|
||||
"""Create a context that can identify this integration."""
|
||||
# Use a hash for the name because otherwise the context might become
|
||||
# too long (max len == 36) to fit in the database.
|
||||
name_hash = _short_hash(name)
|
||||
# too long (max len == 26) to fit in the database.
|
||||
# Pack index with base85 to maximize the number of contexts we can create
|
||||
# before we exceed the 36-character limit and are forced to wrap.
|
||||
index_packed = base64.b85encode(_int_to_bytes(index, signed=False))
|
||||
context_id = f"{_DOMAIN_SHORT}:{name_hash}:{which}:{index_packed}"[:36]
|
||||
# before we exceed the 26-character limit and are forced to wrap.
|
||||
time_stamp = ulid_transform.ulid_now()[:10] # time part of a ULID
|
||||
name_hash = _short_hash(name)
|
||||
which_short = _remove_vowels(which)
|
||||
context_id_start = f"{time_stamp}:{_DOMAIN_SHORT}:{name_hash}:{which_short}:"
|
||||
chars_left = 26 - len(context_id_start)
|
||||
index_packed = _int_to_base36(index).zfill(chars_left)[-chars_left:]
|
||||
context_id = context_id_start + index_packed
|
||||
parent_id = parent.id if parent else None
|
||||
return Context(id=context_id, parent_id=parent_id)
|
||||
|
||||
|
|
@ -214,7 +260,7 @@ def is_our_context(context: Context | None) -> bool:
|
|||
"""Check whether this integration created 'context'."""
|
||||
if context is None:
|
||||
return False
|
||||
return context.id.startswith(_DOMAIN_SHORT)
|
||||
return f":{_DOMAIN_SHORT}:" in context.id
|
||||
|
||||
|
||||
def _split_service_data(service_data, adapt_brightness, adapt_color):
|
||||
|
|
@ -265,7 +311,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 +376,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
|
||||
|
||||
|
|
@ -378,6 +424,7 @@ def _fire_manual_control_event(
|
|||
switch.entity_id,
|
||||
light,
|
||||
)
|
||||
switch.turn_on_off_listener.mark_as_manual_control(light)
|
||||
fire(
|
||||
f"{DOMAIN}.manual_control",
|
||||
{ATTR_ENTITY_ID: light, SWITCH_DOMAIN: switch.entity_id},
|
||||
|
|
@ -473,7 +520,6 @@ async def async_setup_entry(
|
|||
all_lights = _expand_light_groups(switch.hass, lights)
|
||||
if service_call.data[CONF_MANUAL_CONTROL]:
|
||||
for light in all_lights:
|
||||
switch.turn_on_off_listener.manual_control[light] = True
|
||||
_fire_manual_control_event(switch, light, service_call.context)
|
||||
else:
|
||||
switch.turn_on_off_listener.reset(*all_lights)
|
||||
|
|
@ -493,20 +539,9 @@ async def async_setup_entry(
|
|||
domain=DOMAIN,
|
||||
service=SERVICE_APPLY,
|
||||
service_func=handle_apply,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Optional("entity_id"): cv.entity_ids,
|
||||
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
|
||||
vol.Optional(
|
||||
CONF_TRANSITION,
|
||||
default=switch._initial_transition, # pylint: disable=protected-access
|
||||
): VALID_TRANSITION,
|
||||
vol.Optional(ATTR_ADAPT_BRIGHTNESS, default=True): cv.boolean,
|
||||
vol.Optional(ATTR_ADAPT_COLOR, default=True): cv.boolean,
|
||||
vol.Optional(CONF_PREFER_RGB_COLOR, default=False): cv.boolean,
|
||||
vol.Optional(CONF_TURN_ON_LIGHTS, default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
schema=apply_service_schema(
|
||||
switch._initial_transition
|
||||
), # pylint: disable=protected-access
|
||||
)
|
||||
|
||||
# Register `set_manual_control` service
|
||||
|
|
@ -514,13 +549,7 @@ async def async_setup_entry(
|
|||
domain=DOMAIN,
|
||||
service=SERVICE_SET_MANUAL_CONTROL,
|
||||
service_func=handle_set_manual_control,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Optional("entity_id"): cv.entity_ids,
|
||||
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
|
||||
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean,
|
||||
}
|
||||
),
|
||||
schema=SET_MANUAL_CONTROL_SCHEMA,
|
||||
)
|
||||
|
||||
args = {vol.Optional(CONF_USE_DEFAULTS, default="current"): cv.string}
|
||||
|
|
@ -596,7 +625,7 @@ def _expand_light_groups(hass: HomeAssistant, lights: list[str]) -> list[str]:
|
|||
|
||||
def _supported_features(hass: HomeAssistant, light: str):
|
||||
state = hass.states.get(light)
|
||||
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
|
||||
supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
supported = {
|
||||
key for key, value in _SUPPORT_OPTS.items() if supported_features & value
|
||||
}
|
||||
|
|
@ -642,6 +671,41 @@ def color_difference_redmean(
|
|||
return math.sqrt(red_term + green_term + blue_term)
|
||||
|
||||
|
||||
# All comparisons should be done with RGB since
|
||||
# converting anything to color temp is inaccurate.
|
||||
def _convert_attributes(attributes: dict[str, Any]) -> dict[str, Any]:
|
||||
if ATTR_RGB_COLOR in attributes:
|
||||
return attributes
|
||||
|
||||
rgb = None
|
||||
if ATTR_COLOR_TEMP_KELVIN in attributes:
|
||||
rgb = color_temperature_to_rgb(attributes[ATTR_COLOR_TEMP_KELVIN])
|
||||
elif ATTR_XY_COLOR in attributes:
|
||||
rgb = color_xy_to_RGB(*attributes[ATTR_XY_COLOR])
|
||||
|
||||
if rgb is not None:
|
||||
attributes[ATTR_RGB_COLOR] = rgb
|
||||
_LOGGER.debug(f"Converted {attributes} to rgb {rgb}")
|
||||
else:
|
||||
_LOGGER.debug("No suitable conversion found")
|
||||
|
||||
return attributes
|
||||
|
||||
|
||||
def _add_missing_attributes(
|
||||
old_attributes: dict[str, Any],
|
||||
new_attributes: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not any(
|
||||
attr in old_attributes and attr in new_attributes
|
||||
for attr in [ATTR_COLOR_TEMP_KELVIN, ATTR_RGB_COLOR]
|
||||
):
|
||||
old_attributes = _convert_attributes(old_attributes)
|
||||
new_attributes = _convert_attributes(new_attributes)
|
||||
|
||||
return old_attributes, new_attributes
|
||||
|
||||
|
||||
def _attributes_have_changed(
|
||||
light: str,
|
||||
old_attributes: dict[str, Any],
|
||||
|
|
@ -650,6 +714,11 @@ def _attributes_have_changed(
|
|||
adapt_color: bool,
|
||||
context: Context,
|
||||
) -> bool:
|
||||
if adapt_color:
|
||||
old_attributes, new_attributes = _add_missing_attributes(
|
||||
old_attributes, new_attributes
|
||||
)
|
||||
|
||||
if (
|
||||
adapt_brightness
|
||||
and ATTR_BRIGHTNESS in old_attributes
|
||||
|
|
@ -704,21 +773,6 @@ def _attributes_have_changed(
|
|||
context.id,
|
||||
)
|
||||
return True
|
||||
|
||||
switched_color_temp = (
|
||||
ATTR_RGB_COLOR in old_attributes and ATTR_RGB_COLOR not in new_attributes
|
||||
)
|
||||
switched_to_rgb_color = (
|
||||
ATTR_COLOR_TEMP_KELVIN in old_attributes
|
||||
and ATTR_COLOR_TEMP_KELVIN not in new_attributes
|
||||
)
|
||||
if switched_color_temp or switched_to_rgb_color:
|
||||
# Light switched from RGB mode to color_temp or visa versa
|
||||
_LOGGER.debug(
|
||||
"'%s' switched from RGB mode to color_temp or visa versa",
|
||||
light,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -816,10 +870,21 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._only_once = data[CONF_ONLY_ONCE]
|
||||
self._prefer_rgb_color = data[CONF_PREFER_RGB_COLOR]
|
||||
self._separate_turn_on_commands = data[CONF_SEPARATE_TURN_ON_COMMANDS]
|
||||
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
|
||||
self._transition = data[CONF_TRANSITION]
|
||||
self._adapt_delay = data[CONF_ADAPT_DELAY]
|
||||
self._send_split_delay = data[CONF_SEND_SPLIT_DELAY]
|
||||
self._take_over_control = data[CONF_TAKE_OVER_CONTROL]
|
||||
self._detect_non_ha_changes = data[CONF_DETECT_NON_HA_CHANGES]
|
||||
if not data[CONF_TAKE_OVER_CONTROL] and data[CONF_DETECT_NON_HA_CHANGES]:
|
||||
_LOGGER.warning(
|
||||
"%s: Config mismatch: 'detect_non_ha_changes: true' "
|
||||
"requires 'take_over_control' to be enabled. Adjusting config "
|
||||
"and continuing setup with `take_over_control: true`.",
|
||||
self._name,
|
||||
)
|
||||
self._take_over_control = True
|
||||
self._auto_reset_manual_control_time = data[CONF_AUTORESET_CONTROL]
|
||||
self._expand_light_groups() # updates manual control timers
|
||||
_loc = get_astral_location(self.hass)
|
||||
if isinstance(_loc, tuple):
|
||||
# Astral v2.2
|
||||
|
|
@ -831,6 +896,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
self._sun_light_settings = SunLightSettings(
|
||||
name=self._name,
|
||||
astral_location=location,
|
||||
adapt_until_sleep=data[CONF_ADAPT_UNTIL_SLEEP],
|
||||
max_brightness=data[CONF_MAX_BRIGHTNESS],
|
||||
max_color_temp=data[CONF_MAX_COLOR_TEMP],
|
||||
min_brightness=data[CONF_MIN_BRIGHTNESS],
|
||||
|
|
@ -893,6 +959,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:
|
||||
|
|
@ -945,6 +1014,12 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if self.turn_on_off_listener.manual_control.get(light)
|
||||
]
|
||||
extra_state_attributes.update(self._settings)
|
||||
timers = self.turn_on_off_listener.auto_reset_manual_control_timers
|
||||
extra_state_attributes["autoreset_time_remaining"] = {
|
||||
light: time
|
||||
for light in self._lights
|
||||
if (timer := timers.get(light)) and (time := timer.remaining_time()) > 0
|
||||
}
|
||||
return extra_state_attributes
|
||||
|
||||
def create_context(
|
||||
|
|
@ -1013,9 +1088,6 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if lock is not None and lock.locked():
|
||||
_LOGGER.debug("%s: '%s' is locked", self._name, light)
|
||||
return
|
||||
service_data = {ATTR_ENTITY_ID: light}
|
||||
features = _supported_features(self.hass, light)
|
||||
|
||||
if transition is None:
|
||||
transition = self._transition
|
||||
if adapt_brightness is None:
|
||||
|
|
@ -1025,14 +1097,18 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
if prefer_rgb_color is None:
|
||||
prefer_rgb_color = self._prefer_rgb_color
|
||||
|
||||
if "transition" in features:
|
||||
service_data[ATTR_TRANSITION] = transition
|
||||
|
||||
# The switch might be off and not have _settings set.
|
||||
self._settings = self._sun_light_settings.get_settings(
|
||||
self.sleep_mode_switch.is_on, transition
|
||||
)
|
||||
|
||||
# Build service data.
|
||||
service_data = {ATTR_ENTITY_ID: light}
|
||||
features = _supported_features(self.hass, light)
|
||||
|
||||
# Check transition == 0 to fix #378
|
||||
if "transition" in features and transition > 0:
|
||||
service_data[ATTR_TRANSITION] = transition
|
||||
if "brightness" in features and adapt_brightness:
|
||||
brightness = round(255 * self._settings["brightness_pct"] / 100)
|
||||
service_data[ATTR_BRIGHTNESS] = brightness
|
||||
|
|
@ -1059,20 +1135,19 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
service_data[ATTR_RGB_COLOR] = self._settings["rgb_color"]
|
||||
|
||||
context = context or self.create_context("adapt_lights")
|
||||
if (
|
||||
self._take_over_control
|
||||
and self._detect_non_ha_changes
|
||||
and not force
|
||||
and await self.turn_on_off_listener.significant_change(
|
||||
self,
|
||||
|
||||
# See #80. Doesn't check if transitions differ but it does the job.
|
||||
last_service_data = self.turn_on_off_listener.last_service_data
|
||||
if not force and last_service_data.get(light) == service_data:
|
||||
_LOGGER.debug(
|
||||
"%s: Cancelling adapt to light %s, there's no new values to set (context.id='%s')",
|
||||
self._name,
|
||||
light,
|
||||
adapt_brightness,
|
||||
adapt_color,
|
||||
context,
|
||||
context.id,
|
||||
)
|
||||
):
|
||||
return
|
||||
self.turn_on_off_listener.last_service_data[light] = service_data
|
||||
else:
|
||||
self.turn_on_off_listener.last_service_data[light] = service_data
|
||||
|
||||
async def turn_on(service_data):
|
||||
_LOGGER.debug(
|
||||
|
|
@ -1124,13 +1199,36 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
)
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
if lights is None:
|
||||
lights = self._lights
|
||||
if (self._only_once and not force) or not lights:
|
||||
return
|
||||
await self._adapt_lights(lights, transition, force, context)
|
||||
|
||||
async def _adapt_lights(
|
||||
filtered_lights = []
|
||||
if not force:
|
||||
if self._only_once:
|
||||
return
|
||||
for light in lights:
|
||||
# Don't adapt lights that haven't finished prior transitions.
|
||||
timer = self.turn_on_off_listener.transition_timers.get(light)
|
||||
if timer is not None and timer.is_running():
|
||||
_LOGGER.debug(
|
||||
"%s: Light '%s' is still transitioning",
|
||||
self._name,
|
||||
light,
|
||||
)
|
||||
else:
|
||||
filtered_lights.append(light)
|
||||
else:
|
||||
filtered_lights = lights
|
||||
|
||||
if not filtered_lights:
|
||||
return
|
||||
|
||||
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,
|
||||
|
|
@ -1139,34 +1237,53 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
|
|||
) -> None:
|
||||
assert context is not None
|
||||
_LOGGER.debug(
|
||||
"%s: '_adapt_lights(%s, %s, force=%s, context.id=%s)' called",
|
||||
"%s: '_update_manual_control_and_maybe_adapt(%s, %s, force=%s, context.id=%s)' called",
|
||||
self.name,
|
||||
lights,
|
||||
transition,
|
||||
force,
|
||||
context.id,
|
||||
)
|
||||
|
||||
adapt_brightness = self.adapt_brightness_switch.is_on
|
||||
adapt_color = self.adapt_color_switch.is_on
|
||||
|
||||
for light in lights:
|
||||
if not is_on(self.hass, light):
|
||||
continue
|
||||
if (
|
||||
self._take_over_control
|
||||
and self.turn_on_off_listener.is_manually_controlled(
|
||||
|
||||
manually_controlled = self.turn_on_off_listener.is_manually_controlled(
|
||||
self,
|
||||
light,
|
||||
force,
|
||||
adapt_brightness,
|
||||
adapt_color,
|
||||
)
|
||||
|
||||
significant_change = (
|
||||
self._detect_non_ha_changes
|
||||
and not force
|
||||
and await self.turn_on_off_listener.significant_change(
|
||||
self,
|
||||
light,
|
||||
force,
|
||||
self.adapt_brightness_switch.is_on,
|
||||
self.adapt_color_switch.is_on,
|
||||
adapt_brightness,
|
||||
adapt_color,
|
||||
context,
|
||||
)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"%s: '%s' is being manually controlled, stop adapting, context.id=%s.",
|
||||
self._name,
|
||||
light,
|
||||
context.id,
|
||||
)
|
||||
continue
|
||||
await self._adapt_light(light, transition, force=force, context=context)
|
||||
)
|
||||
|
||||
if self._take_over_control and (manually_controlled or significant_change):
|
||||
if manually_controlled:
|
||||
_LOGGER.debug(
|
||||
"%s: '%s' is being manually controlled, stop adapting, context.id=%s.",
|
||||
self._name,
|
||||
light,
|
||||
context.id,
|
||||
)
|
||||
else:
|
||||
_fire_manual_control_event(self, light, context)
|
||||
else:
|
||||
await self._adapt_light(light, transition, force=force, context=context)
|
||||
|
||||
async def _sleep_mode_switch_state_event(self, event: Event) -> None:
|
||||
if not match_switch_state_event(event, (STATE_ON, STATE_OFF)):
|
||||
|
|
@ -1319,6 +1436,7 @@ class SunLightSettings:
|
|||
|
||||
name: str
|
||||
astral_location: astral.Location
|
||||
adapt_until_sleep: bool
|
||||
max_brightness: int
|
||||
max_color_temp: int
|
||||
min_brightness: int
|
||||
|
|
@ -1468,7 +1586,12 @@ class SunLightSettings:
|
|||
delta = self.max_color_temp - self.min_color_temp
|
||||
ct = (delta * percent) + self.min_color_temp
|
||||
return 5 * round(ct / 5) # round to nearest 5
|
||||
return self.min_color_temp
|
||||
if percent == 0 or not self.adapt_until_sleep:
|
||||
return self.min_color_temp
|
||||
if self.adapt_until_sleep and percent < 0:
|
||||
delta = abs(self.min_color_temp - self.sleep_color_temp)
|
||||
ct = (delta * abs(1 + percent)) + self.sleep_color_temp
|
||||
return 5 * round(ct / 5) # round to nearest 5
|
||||
|
||||
def get_settings(
|
||||
self, is_sleep, transition
|
||||
|
|
@ -1491,11 +1614,14 @@ class SunLightSettings:
|
|||
rgb_color: tuple[float, float, float] = color_temperature_to_rgb(
|
||||
color_temp_kelvin
|
||||
)
|
||||
# backwards compatibility for versions < 1.3.1 - see #403
|
||||
color_temp_mired: float = math.floor(1000000 / color_temp_kelvin)
|
||||
xy_color: tuple[float, float] = color_RGB_to_xy(*rgb_color)
|
||||
hs_color: tuple[float, float] = color_xy_to_hs(*xy_color)
|
||||
return {
|
||||
"brightness_pct": brightness_pct,
|
||||
"color_temp_kelvin": color_temp_kelvin,
|
||||
"color_temp_mired": color_temp_mired,
|
||||
"rgb_color": rgb_color,
|
||||
"xy_color": xy_color,
|
||||
"hs_color": hs_color,
|
||||
|
|
@ -1519,16 +1645,17 @@ class TurnOnOffListener:
|
|||
self.sleep_tasks: dict[str, asyncio.Task] = {}
|
||||
# Tracks which lights are manually controlled
|
||||
self.manual_control: dict[str, bool] = {}
|
||||
# Counts the number of times (in a row) a light had a changed state.
|
||||
self.cnt_significant_changes: dict[str, int] = defaultdict(int)
|
||||
# Track 'state_changed' events of self.lights resulting from this integration
|
||||
self.last_state_change: dict[str, list[State]] = {}
|
||||
# Track last 'service_data' to 'light.turn_on' resulting from this integration
|
||||
self.last_service_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# When a state is different `max_cnt_significant_changes` times in a row,
|
||||
# mark it as manually_controlled.
|
||||
self.max_cnt_significant_changes = 2
|
||||
# Track auto reset of manual_control
|
||||
self.auto_reset_manual_control_timers: dict[str, _AsyncSingleShotTimer] = {}
|
||||
self.auto_reset_manual_control_times: dict[str, float] = {}
|
||||
|
||||
# Track light transitions
|
||||
self.transition_timers: dict[str, _AsyncSingleShotTimer] = {}
|
||||
|
||||
self.remove_listener = self.hass.bus.async_listen(
|
||||
EVENT_CALL_SERVICE, self.turn_on_off_event_listener
|
||||
|
|
@ -1537,14 +1664,106 @@ class TurnOnOffListener:
|
|||
EVENT_STATE_CHANGED, self.state_changed_event_listener
|
||||
)
|
||||
|
||||
def _handle_timer(
|
||||
self,
|
||||
light: str,
|
||||
timers_dict: dict[str, _AsyncSingleShotTimer],
|
||||
delay: float | None,
|
||||
reset_coroutine: Callable[[], Coroutine[Any, Any, None]],
|
||||
) -> None:
|
||||
timer = timers_dict.get(light)
|
||||
if timer is not None:
|
||||
if delay is None: # Timer object exists, but should not anymore
|
||||
timer.cancel()
|
||||
timers_dict.pop(light)
|
||||
else: # Timer object already exists, just update the delay and restart it
|
||||
timer.delay = delay
|
||||
timer.start()
|
||||
elif delay is not None: # Timer object does not exist, create it
|
||||
timer = _AsyncSingleShotTimer(delay, reset_coroutine)
|
||||
timers_dict[light] = timer
|
||||
timer.start()
|
||||
|
||||
def start_transition_timer(self, light: str) -> None:
|
||||
"""Mark a light as manually controlled."""
|
||||
last_service_data = self.last_service_data.get(light)
|
||||
if not last_service_data:
|
||||
_LOGGER.debug("This should not ever happen. Please report to the devs.")
|
||||
return
|
||||
last_transition = last_service_data.get(ATTR_TRANSITION)
|
||||
if not last_transition:
|
||||
_LOGGER.debug(
|
||||
"No transition in last adapt for light %s, continuing...", light
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Start transition timer of %s seconds for light %s", last_transition, light
|
||||
)
|
||||
|
||||
async def reset():
|
||||
ValueError("TEST")
|
||||
_LOGGER.debug(
|
||||
"Transition finished for light %s",
|
||||
light,
|
||||
)
|
||||
|
||||
self._handle_timer(light, self.transition_timers, last_transition, reset)
|
||||
|
||||
def set_auto_reset_manual_control_times(self, lights: list[str], time: float):
|
||||
"""Set the time after which the lights are automatically reset."""
|
||||
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)
|
||||
|
||||
async def reset():
|
||||
self.reset(light)
|
||||
switches = _get_switches_with_lights(self.hass, [light])
|
||||
for switch in switches:
|
||||
if not switch.is_on:
|
||||
continue
|
||||
await switch._update_attrs_and_maybe_adapt_lights(
|
||||
[light],
|
||||
transition=switch._initial_transition,
|
||||
force=True,
|
||||
context=switch.create_context("autoreset"),
|
||||
)
|
||||
_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]
|
||||
|
||||
self._handle_timer(light, self.auto_reset_manual_control_timers, delay, reset)
|
||||
|
||||
def reset(self, *lights, reset_manual_control=True) -> None:
|
||||
"""Reset the 'manual_control' status of the lights."""
|
||||
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
|
||||
|
||||
async def turn_on_off_event_listener(self, event: Event) -> None:
|
||||
"""Track 'light.turn_off' and 'light.turn_on' service calls."""
|
||||
|
|
@ -1599,11 +1818,19 @@ 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."""
|
||||
entity_id = event.data.get(ATTR_ENTITY_ID, "")
|
||||
if entity_id not in self.lights or entity_id.split(".")[0] != LIGHT_DOMAIN:
|
||||
if entity_id not in self.lights:
|
||||
return
|
||||
|
||||
new_state = event.data.get("new_state")
|
||||
|
|
@ -1615,11 +1842,7 @@ class TurnOnOffListener:
|
|||
new_state.context.id,
|
||||
)
|
||||
|
||||
if (
|
||||
new_state is not None
|
||||
and new_state.state == STATE_ON
|
||||
and is_our_context(new_state.context)
|
||||
):
|
||||
if new_state is not None and new_state.state == STATE_ON:
|
||||
# It is possible to have multiple state change events with the same context.
|
||||
# This can happen because a `turn_on.light(brightness_pct=100, transition=30)`
|
||||
# event leads to an instant state change of
|
||||
|
|
@ -1632,21 +1855,33 @@ class TurnOnOffListener:
|
|||
# incorrect 'min_kelvin' and 'max_kelvin', which happens e.g., for
|
||||
# Philips Hue White GU10 Bluetooth lights).
|
||||
old_state: list[State] | None = self.last_state_change.get(entity_id)
|
||||
if (
|
||||
old_state is not None
|
||||
and old_state[0].context.id == new_state.context.id
|
||||
):
|
||||
# If there is already a state change event from this event (with this
|
||||
# context) then append it to the already existing list.
|
||||
_LOGGER.debug(
|
||||
"State change event of '%s' is already in 'self.last_state_change' (%s)"
|
||||
" adding this state also",
|
||||
entity_id,
|
||||
new_state.context.id,
|
||||
)
|
||||
if is_our_context(new_state.context):
|
||||
if (
|
||||
old_state is not None
|
||||
and old_state[0].context.id == new_state.context.id
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"TurnOnOffListener: State change event of '%s' is already"
|
||||
" in 'self.last_state_change' (%s)"
|
||||
" adding this state also",
|
||||
entity_id,
|
||||
new_state.context.id,
|
||||
)
|
||||
self.last_state_change[entity_id].append(new_state)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"TurnOnOffListener: New adapt '%s' found for %s",
|
||||
new_state,
|
||||
entity_id,
|
||||
)
|
||||
self.last_state_change[entity_id] = [new_state]
|
||||
_LOGGER.debug(
|
||||
"Last transition: %s",
|
||||
self.last_service_data[entity_id].get(ATTR_TRANSITION),
|
||||
)
|
||||
self.start_transition_timer(entity_id)
|
||||
elif old_state is not None:
|
||||
self.last_state_change[entity_id].append(new_state)
|
||||
else:
|
||||
self.last_state_change[entity_id] = [new_state]
|
||||
|
||||
def is_manually_controlled(
|
||||
self,
|
||||
|
|
@ -1674,7 +1909,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 = True
|
||||
_fire_manual_control_event(switch, light, turn_on_event.context)
|
||||
_LOGGER.debug(
|
||||
"'%s' was already on and 'light.turn_on' was not called by the"
|
||||
|
|
@ -1701,64 +1936,56 @@ class TurnOnOffListener:
|
|||
detected, we mark the light as 'manually controlled' until the light
|
||||
or switch is turned 'off' and 'on' again.
|
||||
"""
|
||||
if light not in self.last_state_change:
|
||||
return False
|
||||
old_states: list[State] = self.last_state_change[light]
|
||||
await self.hass.helpers.entity_component.async_update_entity(light)
|
||||
new_state = self.hass.states.get(light)
|
||||
last_service_data = self.last_service_data.get(light)
|
||||
if last_service_data is None:
|
||||
return
|
||||
compare_to = functools.partial(
|
||||
_attributes_have_changed,
|
||||
light=light,
|
||||
new_attributes=new_state.attributes,
|
||||
adapt_brightness=adapt_brightness,
|
||||
adapt_color=adapt_color,
|
||||
context=context,
|
||||
)
|
||||
for index, old_state in enumerate(old_states):
|
||||
changed = compare_to(old_attributes=old_state.attributes)
|
||||
if not changed:
|
||||
_LOGGER.debug(
|
||||
"State of '%s' didn't change wrt change event nr. %s (context.id=%s)",
|
||||
light,
|
||||
index,
|
||||
context.id,
|
||||
)
|
||||
break
|
||||
|
||||
last_service_data = self.last_service_data.get(light)
|
||||
if changed and last_service_data is not None:
|
||||
# It can happen that the state change events that are associated
|
||||
# with the last 'light.turn_on' call by this integration were not
|
||||
# final states. Possibly a later EVENT_STATE_CHANGED happened, where
|
||||
# the correct target brightness/color was reached.
|
||||
changed = compare_to(old_attributes=last_service_data)
|
||||
if not changed:
|
||||
# Update state and check for a manual change not done in HA.
|
||||
# Ensure HASS is correctly updating your light's state with
|
||||
# light.turn_on calls if any problems arise. This
|
||||
# can happen e.g. using zigbee2mqtt with 'report: false' in device settings.
|
||||
if switch._detect_non_ha_changes:
|
||||
_LOGGER.debug(
|
||||
"%s: 'detect_non_ha_changes: true', calling update_entity(%s)"
|
||||
" and check if it's last adapt succeeded.",
|
||||
switch._name,
|
||||
light,
|
||||
)
|
||||
# This update_entity probably isn't necessary now that we're checking
|
||||
# if transitions finished from our last adapt.
|
||||
await self.hass.helpers.entity_component.async_update_entity(light)
|
||||
refreshed_state = self.hass.states.get(light)
|
||||
_LOGGER.debug(
|
||||
"%s: Current state of %s: %s",
|
||||
switch._name,
|
||||
light,
|
||||
refreshed_state,
|
||||
)
|
||||
changed = compare_to(
|
||||
old_attributes=last_service_data,
|
||||
new_attributes=refreshed_state.attributes,
|
||||
)
|
||||
if changed:
|
||||
_LOGGER.debug(
|
||||
"State of '%s' didn't change wrt 'last_service_data' (context.id=%s)",
|
||||
light,
|
||||
context.id,
|
||||
)
|
||||
|
||||
n_changes = self.cnt_significant_changes[light]
|
||||
if changed:
|
||||
self.cnt_significant_changes[light] += 1
|
||||
if n_changes >= self.max_cnt_significant_changes:
|
||||
# Only mark a light as significantly changing, if changed==True
|
||||
# N times in a row. We do this because sometimes a state changes
|
||||
# happens only *after* a new update interval has already started.
|
||||
self.manual_control[light] = True
|
||||
_fire_manual_control_event(switch, light, context, is_async=False)
|
||||
else:
|
||||
if n_changes > 1:
|
||||
_LOGGER.debug(
|
||||
"State of '%s' had 'cnt_significant_changes=%s' but the state"
|
||||
" changed to the expected settings now",
|
||||
light,
|
||||
n_changes,
|
||||
)
|
||||
self.cnt_significant_changes[light] = 0
|
||||
|
||||
return changed
|
||||
return True
|
||||
_LOGGER.debug(
|
||||
"%s: Light '%s' correctly matches our last adapt's service data, continuing..."
|
||||
" context.id=%s.",
|
||||
switch._name,
|
||||
light,
|
||||
context.id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def maybe_cancel_adjusting(
|
||||
self, entity_id: str, off_to_on_event: Event, on_to_off_event: Event | None
|
||||
|
|
@ -1857,3 +2084,45 @@ 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
|
||||
|
||||
def remaining_time(self):
|
||||
"""Return the remaining time before the timer expires."""
|
||||
if self.start_time is not None:
|
||||
elapsed_time = (dt_util.utcnow() - self.start_time).total_seconds()
|
||||
return max(0, self.delay - elapsed_time)
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"step": {
|
||||
"user": {
|
||||
"title": "Choose a name for the Adaptive Lighting instance",
|
||||
"description": "Pick a name for this instance. You can run several instances of Adaptive lighting, each of these can contain multiple lights!",
|
||||
"description": "Every instance can contain multiple lights!",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
|
|
@ -20,33 +20,35 @@
|
|||
"title": "Adaptive Lighting options",
|
||||
"description": "All settings for a Adaptive Lighting component. The option names correspond with the YAML settings. No options are shown if you have the adaptive_lighting entry defined in your YAML configuration.",
|
||||
"data": {
|
||||
"lights": "lights",
|
||||
"initial_transition": "initial_transition: When lights turn 'off' to 'on'. (seconds)",
|
||||
"include_config_in_attributes": "include_config_in_attributes: All config options will be listed as attributes under the adaptive-lighting switch this integration creates. (default: false)",
|
||||
"sleep_transition": "sleep_transition: When 'sleep_state' changes. (seconds)",
|
||||
"interval": "interval: Time between switch updates. (seconds)",
|
||||
"max_brightness": "max_brightness: Highest brightness of lights during a cycle. (%)",
|
||||
"max_color_temp": "max_color_temp: Coldest hue of the color temperature cycle. (Kelvin)",
|
||||
"min_brightness": "min_brightness: Lowest brightness of lights during a cycle. (%)",
|
||||
"min_color_temp": "min_color_temp, Warmest hue of the color temperature cycle. (Kelvin)",
|
||||
"only_once": "only_once: Only adapt the lights when turning them on.",
|
||||
"prefer_rgb_color": "prefer_rgb_color: Use 'rgb_color' rather than 'color_temp' when possible.",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Separate the commands for each attribute (color, brightness, etc.) in 'light.turn_on' (required for some lights).",
|
||||
"send_split_delay": "send_split_delay: wait between commands (milliseconds), when separate_turn_on_commands is used. May ensure that both commands are handled by the bulb correctly.",
|
||||
"sleep_brightness": "sleep_brightness, Brightness setting for Sleep Mode. (%)",
|
||||
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp, use 'rgb_color' or 'color_temp'",
|
||||
"sleep_rgb_color": "sleep_rgb_color, in RGB",
|
||||
"sleep_color_temp": "sleep_color_temp: Color temperature setting for Sleep Mode. (Kelvin)",
|
||||
"sunrise_offset": "sunrise_offset: How long before(-) or after(+) to define the sunrise point of the cycle (+/- seconds)",
|
||||
"sunrise_time": "sunrise_time: Manual override of the sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
|
||||
"max_sunrise_time": "max_sunrise_time: Manual override of the maximum sunrise time, if 'None', it uses the actual sunrise time at your location (HH:MM:SS)",
|
||||
"sunset_offset": "sunset_offset: How long before(-) or after(+) to define the sunset point of the cycle (+/- seconds)",
|
||||
"sunset_time": "sunset_time: Manual override of the sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
|
||||
"min_sunset_time": "min_sunset_time: Manual override of the minimum sunset time, if 'None', it uses the actual sunset time at your location (HH:MM:SS)",
|
||||
"take_over_control": "take_over_control: If anything but Adaptive Lighting calls 'light.turn_on' when a light is already on, stop adapting that light until it (or the switch) toggles off -> on.",
|
||||
"detect_non_ha_changes": "detect_non_ha_changes: detects all >10% changes made to the lights (also outside of HA), requires 'take_over_control' to be enabled (calls 'homeassistant.update_entity' every 'interval'!)",
|
||||
"transition": "Transition time when applying a change to the lights (seconds)",
|
||||
"adapt_delay": "adapt_delay: wait time between light turn on (seconds), and Adaptive Lights applying changes to the light state. May avoid flickering."
|
||||
"lights": "lights: List of light entity_ids to be controlled (may be empty). 🌟",
|
||||
"prefer_rgb_color": "prefer_rgb_color: Whether to prefer RGB color adjustment over light color temperature when possible. 🌈",
|
||||
"include_config_in_attributes": "include_config_in_attributes: Show all options as attributes on the switch in Home Assistant when set to `true`. 📝",
|
||||
"initial_transition": "initial_transition: Duration of the first transition when lights turn from `off` to `on` in seconds. ⏲️",
|
||||
"sleep_transition": "sleep_transition: Duration of transition when \"sleep mode\" is toggled in seconds. 😴",
|
||||
"transition": "transition: Duration of transition when lights change, in seconds. 🕑",
|
||||
"transition_until_sleep": "transition_until_sleep: When enabled, Adaptive Lighting will treat sleep settings as the minimum, transitioning to these values after sunset. 🌙",
|
||||
"interval": "interval: Frequency to adapt the lights, in seconds. 🔄",
|
||||
"min_brightness": "min_brightness: Minimum brightness percentage. 💡",
|
||||
"max_brightness": "max_brightness: Maximum brightness percentage. 💡",
|
||||
"min_color_temp": "min_color_temp: Warmest color temperature in Kelvin. 🔥",
|
||||
"max_color_temp": "max_color_temp: Coldest color temperature in Kelvin. ❄️",
|
||||
"sleep_brightness": "sleep_brightness: Brightness percentage of lights in sleep mode. 😴",
|
||||
"sleep_rgb_or_color_temp": "sleep_rgb_or_color_temp: Use either `\"rgb_color\"` or `\"color_temp\"` in sleep mode. 🌙",
|
||||
"sleep_color_temp": "sleep_color_temp: Color temperature in sleep mode (used when `sleep_rgb_or_color_temp` is `color_temp`) in Kelvin. 😴",
|
||||
"sleep_rgb_color": "sleep_rgb_color: RGB color in sleep mode (used when `sleep_rgb_or_color_temp` is \"rgb_color\"). 🌈",
|
||||
"sunrise_time": "sunrise_time: Set a fixed time (HH:MM:SS) for sunrise. 🌅",
|
||||
"max_sunrise_time": "max_sunrise_time: Set the latest virtual sunrise time (HH:MM:SS), allowing for earlier real sunrises. 🌅",
|
||||
"sunrise_offset": "sunrise_offset: Adjust sunrise time with a positive or negative offset in seconds. ⏰",
|
||||
"sunset_time": "sunset_time: Set a fixed time (HH:MM:SS) for sunset. 🌇",
|
||||
"min_sunset_time": "min_sunset_time: Set the earliest virtual sunset time (HH:MM:SS), allowing for later real sunsets. 🌇",
|
||||
"sunset_offset": "sunset_offset: Adjust sunset time with a positive or negative offset in seconds. ⏰",
|
||||
"only_once": "only_once: Adapt lights only when they are turned on (`true`) or keep adapting them (`false`). 🔄",
|
||||
"take_over_control": "take_over_control: Disable Adaptive Lighting if another source calls `light.turn_on` while lights are on and being adapted. Note that this calls `homeassistant.update_entity` every `interval`! 🔒",
|
||||
"detect_non_ha_changes": "detect_non_ha_changes: Detect non-`light.turn_on` state changes and stop adapting lights. Requires `take_over_control`. 🕵️",
|
||||
"separate_turn_on_commands": "separate_turn_on_commands: Use separate `light.turn_on` calls for color and brightness, needed for some light types. 🔀",
|
||||
"send_split_delay": "send_split_delay: Delay (ms) between `separate_turn_on_commands` for lights that don't support simultaneous brightness and color setting. ⏲️",
|
||||
"adapt_delay": "adapt_delay: Wait time (seconds) between light turn on and Adaptive Lighting applying changes. Might help to avoid flickering. ⏲️",
|
||||
"autoreset_control_seconds": "autoreset_control_seconds: Automatically reset the manual control after a number of seconds. Set to 0 to disable. ⏲️"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue