mirror of
https://github.com/esphome/esphome.git
synced 2026-11-15 09:36:04 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a02de27da | ||
|
|
769b05c4f8 | ||
|
|
f33ec7787a | ||
|
|
87e76ecfd7 | ||
|
|
2bbf636540 | ||
|
|
ff3efba7cb | ||
|
|
ad5a6e8508 |
No files matched your search
@@ -5,6 +5,39 @@ namespace esphome::light {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "light.automation");
|
||||
|
||||
bool dim_relative(LightState *state, LightCall &call, float relative_brightness, float min_brightness,
|
||||
float max_brightness, LimitMode limit_mode) {
|
||||
float cur;
|
||||
state->remote_values.as_brightness(&cur);
|
||||
if ((limit_mode == LimitMode::DO_NOTHING) && ((cur < min_brightness) || (cur > max_brightness))) {
|
||||
return false;
|
||||
}
|
||||
float new_brightness = clamp(cur + relative_brightness, min_brightness, max_brightness);
|
||||
call.set_state(new_brightness != 0.0f);
|
||||
call.set_brightness(new_brightness);
|
||||
return true;
|
||||
}
|
||||
|
||||
void cycle_effect(LightState *state, bool forward, bool include_none) {
|
||||
size_t count = state->get_effect_count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
uint32_t current = state->get_current_effect_index();
|
||||
uint32_t next;
|
||||
if (include_none) {
|
||||
uint32_t total = static_cast<uint32_t>(count) + 1;
|
||||
next = forward ? (current + 1) % total : (current + total - 1) % total;
|
||||
} else if (forward) {
|
||||
next = (current % static_cast<uint32_t>(count)) + 1;
|
||||
} else {
|
||||
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
|
||||
}
|
||||
auto call = state->turn_on();
|
||||
call.set_effect(next);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
void addressableset_warn_about_scale(const char *field) {
|
||||
ESP_LOGW(TAG, "Lambda for parameter %s of light.addressable_set should return values in range 0-1 instead of 0-255.",
|
||||
field);
|
||||
|
||||
@@ -8,94 +8,13 @@ namespace esphome::light {
|
||||
|
||||
enum class LimitMode { CLAMP, DO_NOTHING };
|
||||
|
||||
template<bool HasTransitionLength, typename... Ts> class DimRelativeAction final : public Action<Ts...> {
|
||||
public:
|
||||
explicit DimRelativeAction(LightState *parent) : parent_(parent) {}
|
||||
/// Set the state and brightness of a light.dim_relative call on `state`. Returns false, and the call must not be
|
||||
/// performed, when `limit_mode` is DO_NOTHING and the current brightness is outside the limits.
|
||||
bool dim_relative(LightState *state, LightCall &call, float relative_brightness, float min_brightness = 0.0f,
|
||||
float max_brightness = 1.0f, LimitMode limit_mode = LimitMode::CLAMP);
|
||||
|
||||
TEMPLATABLE_VALUE(float, relative_brightness)
|
||||
|
||||
template<typename V>
|
||||
void set_transition_length(V value)
|
||||
requires(HasTransitionLength)
|
||||
{
|
||||
this->transition_length_ = value;
|
||||
}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
auto call = this->parent_->make_call();
|
||||
float rel = this->relative_brightness_.value(x...);
|
||||
float cur;
|
||||
this->parent_->remote_values.as_brightness(&cur);
|
||||
if ((limit_mode_ == LimitMode::DO_NOTHING) && ((cur < min_brightness_) || (cur > max_brightness_))) {
|
||||
return;
|
||||
}
|
||||
float new_brightness = clamp(cur + rel, min_brightness_, max_brightness_);
|
||||
call.set_state(new_brightness != 0.0f);
|
||||
call.set_brightness(new_brightness);
|
||||
|
||||
if constexpr (HasTransitionLength) {
|
||||
call.set_transition_length(this->transition_length_.optional_value(x...));
|
||||
}
|
||||
call.perform();
|
||||
}
|
||||
|
||||
void set_min_max_brightness(float min, float max) {
|
||||
this->min_brightness_ = min;
|
||||
this->max_brightness_ = max;
|
||||
}
|
||||
|
||||
void set_limit_mode(LimitMode limit_mode) { this->limit_mode_ = limit_mode; }
|
||||
|
||||
protected:
|
||||
LightState *parent_;
|
||||
float min_brightness_{0.0};
|
||||
float max_brightness_{1.0};
|
||||
LimitMode limit_mode_{LimitMode::CLAMP};
|
||||
struct NoTransition {};
|
||||
[[no_unique_address]] std::conditional_t<HasTransitionLength, TemplatableFn<uint32_t, Ts...>, NoTransition>
|
||||
transition_length_{};
|
||||
};
|
||||
|
||||
// Cycle through the light's configured effects. `Forward` selects direction
|
||||
// at compile time so the chosen branch is the only one that gets instantiated
|
||||
// per action site. `include_none` is runtime so a single set of templates
|
||||
// covers both the "wrap through None" and "skip None" variants.
|
||||
template<bool Forward, typename... Ts> class LightEffectCycleAction final : public Action<Ts...> {
|
||||
public:
|
||||
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
|
||||
|
||||
void set_include_none(bool include_none) { this->include_none_ = include_none; }
|
||||
|
||||
void play(const Ts &...) override {
|
||||
size_t count = this->parent_->get_effect_count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
uint32_t current = this->parent_->get_current_effect_index();
|
||||
uint32_t next;
|
||||
if (this->include_none_) {
|
||||
uint32_t total = static_cast<uint32_t>(count) + 1;
|
||||
if constexpr (Forward) {
|
||||
next = (current + 1) % total;
|
||||
} else {
|
||||
next = (current + total - 1) % total;
|
||||
}
|
||||
} else {
|
||||
if constexpr (Forward) {
|
||||
next = (current % static_cast<uint32_t>(count)) + 1;
|
||||
} else {
|
||||
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
|
||||
}
|
||||
}
|
||||
auto call = this->parent_->turn_on();
|
||||
call.set_effect(next);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
protected:
|
||||
LightState *parent_;
|
||||
bool include_none_{false};
|
||||
};
|
||||
/// Turn on the next or previous configured effect; with `include_none`, "None" is part of the cycle.
|
||||
void cycle_effect(LightState *state, bool forward, bool include_none);
|
||||
|
||||
class LightTurnOnTrigger final : public Trigger<>, public LightRemoteValuesListener {
|
||||
public:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
@@ -32,7 +33,7 @@ from esphome.const import (
|
||||
CONF_WHITE,
|
||||
)
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.cpp_generator import MockObj, SafeExpType, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
@@ -43,9 +44,8 @@ from .types import (
|
||||
AddressableLightState,
|
||||
AddressableSet,
|
||||
ColorMode,
|
||||
DimRelativeAction,
|
||||
LightEffectCycleAction,
|
||||
LightState,
|
||||
light_ns,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -340,47 +340,29 @@ LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.next",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_next_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.previous",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_previous_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
|
||||
|
||||
|
||||
async def _light_effect_cycle_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
forward: bool,
|
||||
) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
|
||||
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
|
||||
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
|
||||
return var
|
||||
light = automation.parent_ref(await cg.get_variable(config[CONF_ID]))
|
||||
return automation.apply_statements_action(
|
||||
action_id,
|
||||
template_arg,
|
||||
args,
|
||||
[f"{light_ns.cycle_effect(light, forward, config[CONF_INCLUDE_NONE])};"],
|
||||
)
|
||||
|
||||
|
||||
for _name, _forward in (("light.effect.next", True), ("light.effect.previous", False)):
|
||||
automation.register_action(
|
||||
_name,
|
||||
automation.ApplyAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)(partial(_light_effect_cycle_to_code, forward=_forward))
|
||||
|
||||
|
||||
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
|
||||
@@ -406,30 +388,52 @@ LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def _called_with_args(
|
||||
value: Any, args: TemplateArgsType, type_: SafeExpType
|
||||
) -> SafeExpType:
|
||||
"""A constant, or the user's lambda called with the trigger's by-value args (never reduced)."""
|
||||
if not isinstance(value, Lambda):
|
||||
return value
|
||||
return automation.immediate_lambda_call(
|
||||
await cg.process_lambda(value, args, return_type=type_)
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.dim_relative",
|
||||
DimRelativeAction,
|
||||
automation.ApplyAction,
|
||||
LIGHT_DIM_RELATIVE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_dim_relative_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
has_transition_length = CONF_TRANSITION_LENGTH in config
|
||||
dim_template_arg = cg.TemplateArguments(has_transition_length, *template_arg)
|
||||
var = cg.new_Pvariable(action_id, dim_template_arg, paren)
|
||||
templ = await cg.templatable(config[CONF_RELATIVE_BRIGHTNESS], args, cg.float_)
|
||||
cg.add(var.set_relative_brightness(templ))
|
||||
if has_transition_length:
|
||||
templ = await cg.templatable(config[CONF_TRANSITION_LENGTH], args, cg.uint32)
|
||||
cg.add(var.set_transition_length(templ))
|
||||
if conf := config.get(CONF_BRIGHTNESS_LIMITS):
|
||||
cg.add(
|
||||
var.set_min_max_brightness(
|
||||
conf[CONF_MIN_BRIGHTNESS], conf[CONF_MAX_BRIGHTNESS]
|
||||
)
|
||||
)
|
||||
cg.add(var.set_limit_mode(conf[CONF_LIMIT_MODE]))
|
||||
return var
|
||||
async def light_dim_relative_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
light = automation.parent_ref(await cg.get_variable(config[CONF_ID]))
|
||||
call = MockObj("esphome__call", ".")
|
||||
dim_args = [
|
||||
light,
|
||||
call,
|
||||
await _called_with_args(config[CONF_RELATIVE_BRIGHTNESS], args, cg.float_),
|
||||
]
|
||||
if limits := config.get(CONF_BRIGHTNESS_LIMITS):
|
||||
dim_args += [
|
||||
limits[CONF_MIN_BRIGHTNESS],
|
||||
limits[CONF_MAX_BRIGHTNESS],
|
||||
limits[CONF_LIMIT_MODE],
|
||||
]
|
||||
# The transition length is only read once the limits allow the call
|
||||
statements = [
|
||||
f"auto {call} = {light}->make_call();",
|
||||
f"if ({light_ns.dim_relative(*dim_args)}) {{",
|
||||
]
|
||||
if (transition := config.get(CONF_TRANSITION_LENGTH)) is not None:
|
||||
transition = await _called_with_args(transition, args, cg.uint32)
|
||||
statements.append(f" {call.set_transition_length(transition)};")
|
||||
statements += [f" {call.perform()};", "}"]
|
||||
return automation.apply_statements_action(action_id, template_arg, args, statements)
|
||||
|
||||
|
||||
LIGHT_ADDRESSABLE_SET_ACTION_SCHEMA = cv.Schema(
|
||||
|
||||
@@ -40,8 +40,6 @@ LimitMode = light_ns.enum("LimitMode", is_class=True)
|
||||
LIMIT_MODES = LimitMode.options("CLAMP", "DO_NOTHING")
|
||||
|
||||
# Actions
|
||||
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
|
||||
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
|
||||
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
|
||||
|
||||
# Triggers
|
||||
|
||||
@@ -47,3 +47,14 @@ script:
|
||||
- light.effect.previous:
|
||||
id: dim_light
|
||||
include_none: true
|
||||
- id: dim_skip_then_dim
|
||||
then:
|
||||
- light.dim_relative:
|
||||
id: dim_light
|
||||
relative_brightness: 10%
|
||||
brightness_limits:
|
||||
min_brightness: 90%
|
||||
limit_mode: DO_NOTHING
|
||||
- light.dim_relative:
|
||||
id: dim_light
|
||||
relative_brightness: -5%
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -18,28 +19,28 @@ def main_cpp(
|
||||
|
||||
|
||||
def test_dim_relative_without_transition_or_limits(main_cpp: str) -> None:
|
||||
"""No transition length and no limits: the defaults apply."""
|
||||
"""No transition length and no limits: the helper's default limits apply."""
|
||||
assert (
|
||||
"new(light_dimrelativeaction_id) light::DimRelativeAction<false>(dim_light);\n"
|
||||
"light_dimrelativeaction_id->set_relative_brightness([]() -> float {\n"
|
||||
" return 0.1f;\n"
|
||||
"});\n"
|
||||
"new(automation_id) Automation<>(dim_plain);\n"
|
||||
"static void esphome__applyaction_id__fn() {\n"
|
||||
" auto esphome__call = ::dim_light->make_call();\n"
|
||||
" if (light::dim_relative(::dim_light, esphome__call, 0.1f)) {\n"
|
||||
" esphome__call.perform();\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_dim_relative_with_transition_and_limits(main_cpp: str) -> None:
|
||||
"""A transition length, brightness limits and limit mode are all set."""
|
||||
"""The transition length is only set once the limits allow the call."""
|
||||
assert (
|
||||
"new(light_dimrelativeaction_id_2) light::DimRelativeAction<true>(dim_light);\n"
|
||||
"light_dimrelativeaction_id_2->set_relative_brightness([]() -> float {\n"
|
||||
" return -0.05f;\n"
|
||||
"});\n"
|
||||
"light_dimrelativeaction_id_2->set_transition_length([]() -> uint32_t {\n"
|
||||
" return 250;\n"
|
||||
"});\n"
|
||||
"light_dimrelativeaction_id_2->set_min_max_brightness(0.2f, 0.9f);\n"
|
||||
"light_dimrelativeaction_id_2->set_limit_mode(light::LimitMode::DO_NOTHING);\n"
|
||||
"static void esphome__applyaction_id_2__fn() {\n"
|
||||
" auto esphome__call = ::dim_light->make_call();\n"
|
||||
" if (light::dim_relative(::dim_light, esphome__call, -0.05f, 0.2f, 0.9f, "
|
||||
"light::LimitMode::DO_NOTHING)) {\n"
|
||||
" esphome__call.set_transition_length(250);\n"
|
||||
" esphome__call.perform();\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
@@ -50,22 +51,49 @@ def test_dim_relative_with_transition_and_limits(main_cpp: str) -> None:
|
||||
def test_dim_relative_lambdas_take_args_by_value(
|
||||
main_cpp: str, return_type: str, body: str
|
||||
) -> None:
|
||||
"""The user's lambdas get the script parameter by value, so they may modify it."""
|
||||
"""The user's lambdas get the script parameter by value, so they may modify it.
|
||||
|
||||
They are called rather than reduced, so even a single return modifies only its own copy.
|
||||
"""
|
||||
assert (
|
||||
"static void esphome__applyaction_id_3__fn(const std::remove_cvref_t<float> & step) {"
|
||||
in main_cpp
|
||||
)
|
||||
head = f"[](float step) -> {return_type} {{\n"
|
||||
start = main_cpp.index(head)
|
||||
assert f" {body}\n" in main_cpp[start : main_cpp.index("});", start)]
|
||||
assert f" {body}\n" in main_cpp[start : main_cpp.index("}(step)", start)]
|
||||
|
||||
|
||||
def test_effect_cycle(main_cpp: str) -> None:
|
||||
"""The direction is a template argument and include_none is always set."""
|
||||
"""Both actions call the shared helper, merged into one function."""
|
||||
assert (
|
||||
"new(light_lighteffectcycleaction_id) light::LightEffectCycleAction<true>(dim_light);\n"
|
||||
"light_lighteffectcycleaction_id->set_include_none(false);\n"
|
||||
"new(light_lighteffectcycleaction_id_2) light::LightEffectCycleAction<false>(dim_light);\n"
|
||||
"light_lighteffectcycleaction_id_2->set_include_none(true);\n"
|
||||
" {\n"
|
||||
" light::cycle_effect(::dim_light, true, false);\n"
|
||||
" }\n"
|
||||
" if (!esphome__running)\n"
|
||||
" return;\n"
|
||||
" {\n"
|
||||
" light::cycle_effect(::dim_light, false, true);\n"
|
||||
" }\n"
|
||||
) in main_cpp
|
||||
|
||||
|
||||
def test_dim_relative_limit_does_not_skip_merged_actions(main_cpp: str) -> None:
|
||||
"""The limit check is an if block, so an action merged after it still runs."""
|
||||
fn = re.search(
|
||||
r"Automation<>\(dim_skip_then_dim\);\nnew\(\w+\) ApplyRunAction<(\w+)>",
|
||||
main_cpp,
|
||||
)
|
||||
assert fn is not None
|
||||
start = main_cpp.index(
|
||||
f"static void {fn.group(1)}(const int & esphome__running) {{"
|
||||
)
|
||||
body = main_cpp[start : main_cpp.index("\n}\n", start)]
|
||||
assert body.count("light::dim_relative(") == 2
|
||||
# The only return is the stop check between the two actions
|
||||
assert body.count("return;") == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["light.effect.next", "light.effect.previous"])
|
||||
def test_effect_cycle_rejects_light_without_effects(
|
||||
action: str, tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
|
||||
@@ -29,7 +29,7 @@ button:
|
||||
id: test_light
|
||||
brightness: 50%
|
||||
|
||||
# Test 1: dim_relative without transition_length (HasTransitionLength=false)
|
||||
# Test 1: dim_relative without transition_length
|
||||
- platform: template
|
||||
id: btn_dim_up
|
||||
name: "Dim Up"
|
||||
@@ -38,7 +38,7 @@ button:
|
||||
id: test_light
|
||||
relative_brightness: 25%
|
||||
|
||||
# Test 2: dim_relative with transition_length (HasTransitionLength=true)
|
||||
# Test 2: dim_relative with transition_length
|
||||
- platform: template
|
||||
id: btn_dim_down
|
||||
name: "Dim Down"
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
"""Integration test for light::DimRelativeAction.
|
||||
|
||||
Tests both DimRelativeAction<HasTransitionLength=false> and
|
||||
DimRelativeAction<HasTransitionLength=true> instantiations.
|
||||
"""
|
||||
"""Integration test for light.dim_relative, with and without a transition length and limits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
Reference in new issue
Block a user