Feature requests: Switch now optional for service calls | New default icons | Reload .yaml w/o restart | adapt_delay ms (#459)

* added #274

* attempt getSwitchFromLightId()

* Update switch.py

* changed from register_entity_service to hass.services.async_register

* Update switch.py

* Update switch.py

* Update switch.py

* Update switch.py

* Update switch.py

* Update switch.py

* Update services.yaml

* Update switch.py

* test

* test builds ready

* target selector may not be possible with current syntax

priority is backwards-compatibility as I know most people will smash that update button

* expanded light groups

* test builds ready

* add feature #104

* Update custom_components/adaptive_lighting/switch.py

Co-authored-by: Chris <firstof9@gmail.com>

* Might as well type both. No reason not to.

Co-authored-by: Chris <firstof9@gmail.com>

* Might as well type both. No reason not to.

Co-authored-by: Chris <firstof9@gmail.com>

* Reformatted debug messages.

Reimported ServiceCall as suggested

* Multiple switches allowed again in services.

Apparently this was possible before.

With this change, the `lights` argument must not be passed with multiple switches, or the integration has no way of knowing what the user wants to do. Integration did not make this check in prior versions.

Also reformatted the debug messages.
Removed `automerge.yaml` (my apologies)

* Reload config without restart.

You can now reload any changes to the yaml file without restarting your home assistant. Should show a 'reload' button in your integrations page or you can call the homeassistant reload integration service call.

See https://community.home-assistant.io/t/how-to-allow-custom-compontent-for-yaml-configuration-reloading/391190/4

* Use snake_case for function name 'parseServiceArgs'

* Slight rephrase

* Small style changes

* Rephrased debug messages.

Removed `integration_entities` as we rewrote the code from that function already in parse_service_args. Reference function now above parse_service_args.

* removed: `these_switches = data = None`

* Factor out _find_switch_with_lights

* Rename function and add log statement

* Remove pylint marker

* Handle multiple switches found

* Small changes

* Rename _parse_service_args to _get_switches_from_service_call

* Rephrase log messages

* Add type hint

---------

Co-authored-by: Chris <firstof9@gmail.com>
Co-authored-by: Bas Nijholt <bas@nijho.lt>
This commit is contained in:
Benjamin Auquite 2023-03-25 22:22:05 -05:00 committed by GitHub
commit 08ff04141e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 266 additions and 90 deletions

View file

@ -6,6 +6,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_SOURCE
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.reload import async_setup_reload_service
import voluptuous as vol
from .const import (
@ -37,6 +38,8 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: dict[str, Any]):
"""Import integration from config."""
# This will reload any changes the user made to any YAML configurations.
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
if DOMAIN in config:
for entry in config[DOMAIN]:

View file

@ -5,7 +5,10 @@ from homeassistant.helpers import selector
import homeassistant.helpers.config_validation as cv
import voluptuous as vol
ICON = "mdi:theme-light-dark"
ICON_MAIN = "mdi:theme-light-dark"
ICON_BRIGHTNESS = "mdi:brightness-4"
ICON_COLOR_TEMP = "mdi:sun-thermometer"
ICON_SLEEP = "mdi:sleep"
DOMAIN = "adaptive_lighting"
SUN_EVENT_NOON = "solar_noon"
@ -115,7 +118,7 @@ VALIDATION_TUPLES = [
(CONF_DETECT_NON_HA_CHANGES, DEFAULT_DETECT_NON_HA_CHANGES, bool),
(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, int_between(0, 10000)),
(CONF_ADAPT_DELAY, DEFAULT_ADAPT_DELAY, cv.positive_float),
]

View file

@ -2,35 +2,66 @@ apply:
description: Applies the current Adaptive Lighting settings to lights.
fields:
entity_id:
description: entity_id of the Adaptive Lighting switch.
description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used.
example: switch.adaptive_lighting_default
selector:
entity:
integration: adaptive_lighting
domain: switch
multiple: false
lights:
description: "entity_id(s) of lights, default: lights of the switch"
description: entity_id(s) of lights, if not specified, all lights in the switch are selected.
example: light.bedroom_ceiling
selector:
entity:
domain: light
multiple: true
transition:
description: Transition of the lights.
example: 10
selector:
text:
adapt_brightness:
description: "Adapt the 'brightness', default: true"
example: true
selector:
boolean:
adapt_color:
description: "Adapt the color_temp/color_rgb, default: true"
example: true
selector:
boolean:
prefer_rgb_color:
description: "Prefer to use color_rgb over color_temp if possible, default: false"
example: false
selector:
boolean:
turn_on_lights:
description: "Turn on the lights that are off, default: false"
example: false
selector:
boolean:
set_manual_control:
description: Mark whether a light is 'manually controlled'.
fields:
entity_id:
description: entity_id of the Adaptive Lighting switch.
description: entity_id of the Adaptive Lighting switch. If not specified, the current Adaptive Lighting switch will be used.
example: switch.adaptive_lighting_default
manual_control:
description: "Whether to add ('true') or remove ('false') the light from the 'manual_control' list, default: true"
example: true
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
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"
example: true
default: true
selector:
boolean:

View file

@ -21,10 +21,8 @@ from homeassistant.components.light import (
ATTR_BRIGHTNESS_STEP,
ATTR_BRIGHTNESS_STEP_PCT,
ATTR_COLOR_NAME,
ATTR_COLOR_TEMP,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_KELVIN,
ATTR_RGB_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
ATTR_TRANSITION,
@ -74,7 +72,7 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.helpers import entity_platform
from homeassistant.helpers import entity_registry
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import (
async_track_state_change_event,
@ -129,7 +127,10 @@ from .const import (
CONF_TURN_ON_LIGHTS,
DOMAIN,
EXTRA_VALIDATION,
ICON,
ICON_BRIGHTNESS,
ICON_COLOR_TEMP,
ICON_MAIN,
ICON_SLEEP,
SERVICE_APPLY,
SERVICE_SET_MANUAL_CONTROL,
SLEEP_MODE_SWITCH,
@ -161,10 +162,8 @@ RGB_REDMEAN_CHANGE = 80 # ≈10% of total range
COLOR_ATTRS = { # Should ATTR_PROFILE be in here?
ATTR_COLOR_NAME,
ATTR_COLOR_TEMP,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_KELVIN,
ATTR_RGB_COLOR,
ATTR_XY_COLOR,
}
@ -238,57 +237,116 @@ def _split_service_data(service_data, adapt_brightness, adapt_color):
return service_datas
async def handle_apply(switch: AdaptiveSwitch, service_call: ServiceCall):
"""Handle the entity service apply."""
hass = switch.hass
data = service_call.data
all_lights = data[CONF_LIGHTS]
if not all_lights:
all_lights = switch._lights
all_lights = _expand_light_groups(hass, all_lights)
switch.turn_on_off_listener.lights.update(all_lights)
_LOGGER.debug(
"Called 'adaptive_lighting.apply' service with '%s'",
data,
)
for light in all_lights:
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light):
await switch._adapt_light( # pylint: disable=protected-access
light,
data[CONF_TRANSITION],
data[ATTR_ADAPT_BRIGHTNESS],
data[ATTR_ADAPT_COLOR],
data[CONF_PREFER_RGB_COLOR],
force=True,
context=switch.create_context("service", parent=service_call.context),
)
def _find_switch_with_any_of_lights(
hass: HomeAssistant,
lights: list[str],
service_call: ServiceCall,
) -> AdaptiveSwitch:
"""Find the switch that controls the lights in 'lights'."""
config_entries = hass.config_entries.async_entries(DOMAIN)
data = hass.data[DOMAIN]
switches = {}
for config in config_entries:
# this check is necessary as there seems to always be an extra config
# entry that doesn't contain any data. I believe this happens when the
# integration exists, but is disabled by the user in HASS.
if config.entry_id in data:
switch = data[config.entry_id]["instance"]
all_check_lights = _expand_light_groups(hass, lights)
switch._expand_light_groups()
if set(switch._lights) & set(all_check_lights):
switches[config.entry_id] = switch
if len(switches) == 1:
return next(iter(switches.values()))
async def handle_set_manual_control(switch: AdaptiveSwitch, service_call: ServiceCall):
"""Set or unset lights as 'manually controlled'."""
lights = service_call.data[CONF_LIGHTS]
if not lights:
all_lights = switch._lights # pylint: disable=protected-access
if len(switches) > 1:
_LOGGER.error(
"Invalid service data: Light(s) %s found in multiple switch configs (%s)."
" You must pass a switch under 'entity_id'. See the README for"
" details. Got %s",
lights,
list(switches.keys()),
service_call.data,
)
raise ValueError(
"adaptive-lighting: Light(s) %s found in multiple switch configs.",
lights,
)
else:
all_lights = _expand_light_groups(switch.hass, lights)
_LOGGER.error(
"Invalid service data: Light was not found in any of your switch's configs."
" You must either include the light(s) that is/are in the integration config, or"
" pass a switch under 'entity_id'. See the README for details. Got %s",
service_call.data,
)
raise ValueError(
"adaptive-lighting: Light(s) %s not found in any switch's configuration.",
lights,
)
# 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(
hass: HomeAssistant, service_call: ServiceCall
) -> list[AdaptiveSwitch]:
_LOGGER.debug(
"Called 'adaptive_lighting.set_manual_control' service with '%s'",
"Function '_get_switches_from_service_call' called with service data:\n'%s'",
service_call.data,
)
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)
# pylint: disable=protected-access
if switch.is_on:
await switch._update_attrs_and_maybe_adapt_lights(
all_lights,
transition=switch._initial_transition,
force=True,
context=switch.create_context("service", parent=service_call.context),
data = service_call.data
lights = data[CONF_LIGHTS]
switch_entity_ids: list[str] | None = data.get("entity_id")
if not lights and not switch_entity_ids:
_LOGGER.debug(
"If you intended to adapt every single light on every single switch, please inform the"
" developers at https://github.com/basnijholt/adaptive-lighting of your use case."
" Currently, you must pass either an adaptive-lighting switch or the lights to"
" an `adaptive_lighting` service call."
)
_LOGGER.error(
"Invalid service data passed to adaptive-lighting service call -"
" you must pass either a switch or a light's entity ID. Service data:\n%s",
service_call.data,
)
raise ValueError(
"adaptive-lighting: No switch or light was passed to service call."
)
if switch_entity_ids is not None:
if len(switch_entity_ids) > 1 and lights:
_LOGGER.error(
"Invalid service data: cannot pass multiple switch entities while also passing"
" lights. Service data received: %s",
service_call.data,
)
raise ValueError(
"adaptive-lighting: Multiple switches were passed with lights argument"
)
switches = []
ent_reg = entity_registry.async_get(hass)
for entity_id in switch_entity_ids:
ent_entry = ent_reg.async_get(entity_id)
config_id = ent_entry.config_entry_id
switches.append(hass.data[DOMAIN][config_id]["instance"])
return switches
if lights:
switch = _find_switch_with_any_of_lights(hass, lights, service_call)
_LOGGER.debug(
"Switch '%s' found for lights '%s'",
switch.entity_id,
lights,
)
return [switch]
_LOGGER.error(
"Invalid service data passed to adaptive-lighting service call -"
" entities were not found in the integration. Service data:\n%s",
service_call.data,
)
raise ValueError("adaptive-lighting: User sent incorrect data to service call")
@callback
@ -320,10 +378,15 @@ async def async_setup_entry(
if ATTR_TURN_ON_OFF_LISTENER not in data:
data[ATTR_TURN_ON_OFF_LISTENER] = TurnOnOffListener(hass)
turn_on_off_listener = data[ATTR_TURN_ON_OFF_LISTENER]
sleep_mode_switch = SimpleSwitch("Sleep Mode", False, hass, config_entry)
adapt_color_switch = SimpleSwitch("Adapt Color", True, hass, config_entry)
adapt_brightness_switch = SimpleSwitch("Adapt Brightness", True, hass, config_entry)
sleep_mode_switch = SimpleSwitch(
"Sleep Mode", False, hass, config_entry, ICON_SLEEP
)
adapt_color_switch = SimpleSwitch(
"Adapt Color", True, hass, config_entry, ICON_COLOR_TEMP
)
adapt_brightness_switch = SimpleSwitch(
"Adapt Brightness", True, hass, config_entry, ICON_BRIGHTNESS
)
switch = AdaptiveSwitch(
hass,
config_entry,
@ -333,6 +396,9 @@ async def async_setup_entry(
adapt_brightness_switch,
)
# save our switch instance, allows us to make switch's entity_id optional in service calls.
hass.data[DOMAIN][config_entry.entry_id]["instance"] = switch
data[config_entry.entry_id][SLEEP_MODE_SWITCH] = sleep_mode_switch
data[config_entry.entry_id][ADAPT_COLOR_SWITCH] = adapt_color_switch
data[config_entry.entry_id][ADAPT_BRIGHTNESS_SWITCH] = adapt_brightness_switch
@ -343,33 +409,101 @@ async def async_setup_entry(
update_before_add=True,
)
@callback
async def handle_apply(service_call: ServiceCall):
"""Handle the entity service apply."""
data = service_call.data
_LOGGER.debug(
"Called 'adaptive_lighting.apply' service with '%s'",
data,
)
these_switches = _get_switches_from_service_call(hass, service_call)
lights = data[CONF_LIGHTS]
for this_switch in these_switches:
if not lights:
all_lights = this_switch._lights # pylint: disable=protected-access
else:
all_lights = _expand_light_groups(this_switch.hass, lights)
this_switch.turn_on_off_listener.lights.update(all_lights)
for light in all_lights:
if data[CONF_TURN_ON_LIGHTS] or is_on(hass, light):
await this_switch._adapt_light( # pylint: disable=protected-access
light,
data[CONF_TRANSITION],
data[ATTR_ADAPT_BRIGHTNESS],
data[ATTR_ADAPT_COLOR],
data[CONF_PREFER_RGB_COLOR],
force=True,
context=this_switch.create_context(
"service", parent=service_call.context
),
)
@callback
async def handle_set_manual_control(service_call: ServiceCall):
"""Set or unset lights as 'manually controlled'."""
data = service_call.data
_LOGGER.debug(
"Called 'adaptive_lighting.set_manual_control' service with '%s'",
data,
)
these_switches = _get_switches_from_service_call(hass, service_call)
lights = data[CONF_LIGHTS]
for this_switch in these_switches:
if not lights:
all_lights = this_switch._lights # pylint: disable=protected-access
else:
all_lights = _expand_light_groups(this_switch.hass, lights)
if service_call.data[CONF_MANUAL_CONTROL]:
for light in all_lights:
this_switch.turn_on_off_listener.manual_control[light] = True
_fire_manual_control_event(this_switch, light, service_call.context)
else:
this_switch.turn_on_off_listener.reset(*all_lights)
# pylint: disable=protected-access
if this_switch.is_on:
await this_switch._update_attrs_and_maybe_adapt_lights(
all_lights,
transition=this_switch._initial_transition,
force=True,
context=this_switch.create_context(
"service", parent=service_call.context
),
)
# Register `apply` service
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_APPLY,
{
vol.Optional(
CONF_LIGHTS, default=[]
): cv.entity_ids, # pylint: disable=protected-access
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,
},
handle_apply,
hass.services.async_register(
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,
}
),
)
platform.async_register_entity_service(
SERVICE_SET_MANUAL_CONTROL,
{
vol.Optional(CONF_LIGHTS, default=[]): cv.entity_ids,
vol.Optional(CONF_MANUAL_CONTROL, default=True): cv.boolean,
},
handle_set_manual_control,
# Register `set_manual_control` service
hass.services.async_register(
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,
}
),
)
@ -610,7 +744,7 @@ class AdaptiveSwitch(SwitchEntity, RestoreEntity):
)
# Set other attributes
self._icon = ICON
self._icon = ICON_MAIN
self._state = None
# Tracks 'off' → 'on' state changes
@ -1046,12 +1180,17 @@ class SimpleSwitch(SwitchEntity, RestoreEntity):
"""Representation of a Adaptive Lighting switch."""
def __init__(
self, which: str, initial_state: bool, hass: HomeAssistant, config_entry
self,
which: str,
initial_state: bool,
hass: HomeAssistant,
config_entry: ConfigEntry,
icon: str,
):
"""Initialize the Adaptive Lighting switch."""
self.hass = hass
data = validate(config_entry)
self._icon = ICON
self._icon = icon
self._state = None
self._which = which
name = data[CONF_NAME]