Compare commits

...
Author SHA1 Message Date
J. Nick Koston 8a02de27da Merge remote-tracking branch 'origin/dev' into HEAD 2026-11-14 08:37:57 -06:00
J. Nick Koston 769b05c4f8 Merge branch 'dev' into light-dim-relative-inline 2026-11-01 13:31:55 -10:00
J. Nick Koston f33ec7787a Merge remote-tracking branch 'upstream/dev' into light-dim-relative-inline
# Conflicts:
#	tests/component_tests/light/config/dim_relative_actions.yaml
#	tests/component_tests/light/test_dim_relative_codegen.py
2026-10-31 07:09:49 -10:00
J. Nick Koston 87e76ecfd7 Merge remote-tracking branch 'origin/light-dim-relative-tests' into light-dim-relative-inline
# Conflicts:
#	tests/component_tests/light/test_dim_relative_codegen.py
2026-10-31 00:02:19 -10:00
J. Nick Koston 2bbf636540 [light] Pin dim to zero turning off and effect cycle refusing lights without effects 2026-10-31 00:01:49 -10:00
J. Nick Koston ff3efba7cb [light] Generate dim_relative and effect next/previous as inline actions 2026-10-30 23:10:31 -10:00
J. Nick Koston ad5a6e8508 [light] Add tests for dim_relative and effect cycle code generation 2026-10-30 23:01:23 -10:00
8 changed files with 165 additions and 176 deletions

No files matched your search

+33
View File
@@ -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);
+6 -87
View File
@@ -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:
+61 -57
View File
@@ -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(
-2
View File
@@ -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