From 2563605fe75855a2aa372acecb8c171eee9a65f5 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 31 Oct 2026 05:03:18 +0100 Subject: [PATCH 001/170] [ld6004] Add text_sensor platform (#19842) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/ld6004/text_sensor.py | 13 +++ .../component_tests/ld6004/test_platforms.py | 92 +++++++++++++++++++ tests/components/ld6004/common.h | 15 ++- tests/components/ld6004/common.yaml | 8 ++ .../text_sensor/ld6004_text_sensor_test.cpp | 68 ++++++++++++++ 5 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 esphome/components/ld6004/text_sensor.py create mode 100644 tests/component_tests/ld6004/test_platforms.py create mode 100644 tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp diff --git a/esphome/components/ld6004/text_sensor.py b/esphome/components/ld6004/text_sensor.py new file mode 100644 index 00000000000..774ed95e027 --- /dev/null +++ b/esphome/components/ld6004/text_sensor.py @@ -0,0 +1,13 @@ +from esphome.components.ld600x import entities +from esphome.types import ConfigType + +from . import LD6004Component +from .const import CONF_LD6004_ID + +DEPENDENCIES = ["ld6004"] + +CONFIG_SCHEMA = entities.text_sensor_schema(LD6004Component, CONF_LD6004_ID) + + +async def to_code(config: ConfigType) -> None: + await entities.text_sensor_to_code(config, CONF_LD6004_ID) diff --git a/tests/component_tests/ld6004/test_platforms.py b/tests/component_tests/ld6004/test_platforms.py new file mode 100644 index 00000000000..8a399098b4f --- /dev/null +++ b/tests/component_tests/ld6004/test_platforms.py @@ -0,0 +1,92 @@ +"""Validate the keys, entity classes and hub reference of the LD6004 entity platforms.""" + +from importlib import import_module + +import pytest + +from esphome.components.ld6004 import LD6004Component +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from esphome.core import ID +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _schema(platform: str) -> cv.Schema: + return import_module(f"esphome.components.ld6004.{platform}").CONFIG_SCHEMA + + +def _cases( + platform: str, + entity_class: MockObjClass | None, + category: str, + *key_sets: tuple[str, ...], +) -> list[pytest.param]: + return [ + pytest.param( + platform, + keys, + entity_class, + category, + id=f"{platform}-{'-'.join(keys) or 'none'}", + ) + for keys in key_sets + ] + + +@pytest.mark.parametrize( + ("platform", "keys", "entity_class", "category"), + [ + *_cases( + "text_sensor", + None, + "diagnostic", + (), + ("work_mode",), + ("ota_version",), + ("work_mode", "ota_version"), + ), + ], +) +def test_platform_keys( + set_core_config: SetCoreConfigCallable, + platform: str, + keys: tuple[str, ...], + entity_class: MockObjClass | None, + category: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config: ConfigType = _schema(platform)( + {"ld6004_id": "radar", **{key: {"name": key} for key in keys}} + ) + hub_id: ID = config["ld6004_id"] + assert hub_id.type == LD6004Component + assert str(hub_id) == "radar" + assert set(config) == {"ld6004_id", *keys} + for key in keys: + if entity_class is not None: + assert config[key]["id"].type == entity_class + assert config[key]["entity_category"] == category + assert "unit_of_measurement" not in config[key] + + +@pytest.mark.parametrize( + ("platform", "config", "match"), + [ + ( + "text_sensor", + {"ld6002b_id": "radar", "work_mode": {"name": "Work Mode"}}, + "ld6002b_id", + ), + ], +) +def test_platform_rejects_key( + set_core_config: SetCoreConfigCallable, + platform: str, + config: ConfigType, + match: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + with pytest.raises(cv.Invalid, match=match): + _schema(platform)(config) diff --git a/tests/components/ld6004/common.h b/tests/components/ld6004/common.h index e1e9bf292fb..65ccfa90d89 100644 --- a/tests/components/ld6004/common.h +++ b/tests/components/ld6004/common.h @@ -134,11 +134,7 @@ class LD6004Test : public ::testing::Test { global_preferences = &this->prefs_; this->sut_.set_uart_parent(&this->uart_); - this->sut_.set_presence_binary_sensor(&this->presence_); - for (uint8_t i = 0; i < MAX_TARGETS; i++) - this->sut_.set_target_presence_binary_sensor(i, &this->target_presence_[i]); - for (uint8_t i = 0; i < AREA_COUNT; i++) - this->sut_.set_area_presence_binary_sensor(i, &this->area_presence_[i]); + this->configure_(); this->sut_.setup(); // Past setup()'s 100 ms boot timeout, which starts the streams and queues the queries @@ -146,6 +142,15 @@ class LD6004Test : public ::testing::Test { App.scheduler.call(millis()); } + /// Attach the entities under test before setup(); the default is the presence hub. + virtual void configure_() { + this->sut_.set_presence_binary_sensor(&this->presence_); + for (uint8_t i = 0; i < MAX_TARGETS; i++) + this->sut_.set_target_presence_binary_sensor(i, &this->target_presence_[i]); + for (uint8_t i = 0; i < AREA_COUNT; i++) + this->sut_.set_area_presence_binary_sensor(i, &this->area_presence_[i]); + } + void TearDown() override { host::setup_preferences(); unsetenv("ESPHOME_PREFDIR"); diff --git a/tests/components/ld6004/common.yaml b/tests/components/ld6004/common.yaml index cfbef8d323b..639e0caf089 100644 --- a/tests/components/ld6004/common.yaml +++ b/tests/components/ld6004/common.yaml @@ -1,6 +1,14 @@ ld6004: id: ld6004_radar +text_sensor: + - platform: ld6004 + ld6004_id: ld6004_radar + work_mode: + name: LD6004 Work Mode + ota_version: + name: LD6004 OTA Version + binary_sensor: - platform: ld6004 ld6004_id: ld6004_radar diff --git a/tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp b/tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp new file mode 100644 index 00000000000..ebaa233a284 --- /dev/null +++ b/tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp @@ -0,0 +1,68 @@ +#ifdef USE_HOST +#include "../common.h" + +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::ld6004::testing { + +namespace { + +constexpr uint32_t CMD_GET_WORK_MODE = 0x18; +// SOF, id, length, type and header checksum come before the payload. +constexpr size_t PAYLOAD_OFFSET = ld600x::TF_HEADER_LEN + 2; + +} // namespace + +/// Only the work_mode text sensor, which needs the boot query on its own. +class LD6004TextSensorTest : public LD6004Test { + protected: + void configure_() override { this->sut_.set_work_mode_text_sensor(&this->work_mode_); } + + /// ACK every boot command in turn and return the control codes that were sent. + std::vector drain_controls_() { + std::vector controls; + for (int i = 0; i < 32 && this->uart_.tx.size() >= ld600x::TF_FRAME_OVERHEAD; i++) { + const uint16_t type = encode_uint16(this->uart_.tx[5], this->uart_.tx[6]); + if (type == TYPE_CONTROL && this->uart_.tx.size() >= PAYLOAD_OFFSET + 4) + controls.push_back(encode_value_le(&this->uart_.tx[PAYLOAD_OFFSET])); + this->uart_.tx.clear(); + this->feed_(ack(type)); + } + EXPECT_TRUE(this->uart_.tx.empty()); + return controls; + } + + text_sensor::TextSensor work_mode_; +}; + +TEST_F(LD6004TextSensorTest, TextSensorAloneQueriesWorkModeAtBoot) { + const auto controls = this->drain_controls_(); + EXPECT_EQ(std::count(controls.begin(), controls.end(), CMD_GET_WORK_MODE), 1); + // The text sensor reads no target reports, so the target stream stays off. + EXPECT_EQ(std::count(controls.begin(), controls.end(), CMD_TARGET_DISPLAY_ON), 0); +} + +// Each 0x0A12 value maps to its own name, sent as one byte or as a uint32 +TEST_F(LD6004TextSensorTest, WorkModeReportPublishesEveryMode) { + static constexpr const char *NAMES[WORK_MODE_COUNT] = {"normal", "low_power", "radar_off_p20_high", + "radar_off_p20_low", "high_reflectivity"}; + for (bool wide : {false, true}) { + for (uint8_t mode = 0; mode < WORK_MODE_COUNT; mode++) { + this->feed_(wide ? tf_frame(TYPE_REPORT_WORK_MODE, u32_payload(mode)) : work_mode(mode)); + EXPECT_EQ(this->work_mode_.state, NAMES[mode]) << "mode " << int(mode) << (wide ? " as uint32" : ""); + } + } +} + +TEST_F(LD6004TextSensorTest, MalformedWorkModeReportDoesNotPublish) { + this->feed_(work_mode(WORK_MODE_LOW_POWER)); + this->feed_(work_mode(WORK_MODE_COUNT)); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, u32_payload(WORK_MODE_COUNT))); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {WORK_MODE_NORMAL, 0x00})); + this->feed_(tf_frame(TYPE_REPORT_WORK_MODE, {WORK_MODE_NORMAL, 0x00, 0x00})); + EXPECT_EQ(this->work_mode_.state, "low_power"); +} + +} // namespace esphome::ld6004::testing + +#endif // USE_HOST From ea40fba54ee8709c8413557922466ca61a1b4609 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:53:33 +1100 Subject: [PATCH 002/170] [core] Merge adjacent simple actions into one generated function (#21978) Co-authored-by: Claude Opus 5.5 --- .agents/skills/automations/SKILL.md | 6 + esphome/automation.py | 177 ++++++++++- esphome/core/base_automation.h | 11 + .../template/test_per_item_lists.py | 3 +- .../components/core/test_apply_run_action.cpp | 87 ++++++ .../fixtures/automation_merged_actions.yaml | 182 +++++++++++ .../test_automation_merged_actions.py | 100 ++++++ tests/unit_tests/test_automation.py | 289 ++++++++++++++++++ 8 files changed, 851 insertions(+), 4 deletions(-) create mode 100644 tests/components/core/test_apply_run_action.cpp create mode 100644 tests/integration/fixtures/automation_merged_actions.yaml create mode 100644 tests/integration/test_automation_merged_actions.py diff --git a/.agents/skills/automations/SKILL.md b/.agents/skills/automations/SKILL.md index f9fbf1aaf0b..1ac7edffac2 100644 --- a/.agents/skills/automations/SKILL.md +++ b/.agents/skills/automations/SKILL.md @@ -187,6 +187,12 @@ payload passes `automation.ApplyBytes(CONF_DATA, "send({})")` in the same call; flash and a lambda's vector is forwarded as data and size. `TEMPLATABLE_VALUE` with `cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values. +Adjacent `register_apply_action` and `lambda:` actions in a list merge into one generated function and one +object, which saves 12 to 16 bytes of RAM per merged action. An action with its own C++ class ends the merged +run, so prefer `register_apply_action` wherever the action allows it. Because of this, `build_action_list` can +return fewer objects than the config has entries. A builder that wraps `apply_action_builder` may do other work +first, but must return its result unchanged and not use it. + ## Conditions ```cpp diff --git a/esphome/automation.py b/esphome/automation.py index ef0cc7fefdf..071d871949c 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,4 +1,5 @@ from collections.abc import Awaitable, Callable, Sequence +from contextvars import ContextVar from dataclasses import dataclass, field import logging import math @@ -253,9 +254,81 @@ validate_condition = cv.validate_registry_entry("condition", CONDITION_REGISTRY) validate_condition_list = cv.validate_registry("condition", CONDITION_REGISTRY) ApplyAction = cg.esphome_ns.class_("ApplyAction", Action) +ApplyRunAction = cg.esphome_ns.class_("ApplyRunAction", Action) ApplyCondition = cg.esphome_ns.class_("ApplyCondition", Condition) +@dataclass(frozen=True) +class _InlineAction: + """An action whose builder only produced statements, not yet emitted as an object.""" + + id_: ID + statements: list[str] + emit: Callable[[], MockObj] + + +@dataclass +class _InlineCollector: + """Takes the statements of the action being built, instead of it emitting an object.""" + + action_id: ID + # For errors, from _builder_name + name: str + action: _InlineAction | None = None + + +_INLINE_COLLECTOR: ContextVar[_InlineCollector | None] = ContextVar( + "esphome_automation_inline_collector", default=None +) + + +class _CollectedAction(MockObj): + """What a collected builder returns: it names no C++ object, so any use of it is an error.""" + + __slots__ = () + + def _misuse(self) -> EsphomeError: + collector = _INLINE_COLLECTOR.get() + name = "an action" if collector is None else collector.name + return EsphomeError( + f"The builder of {name} used the result of its apply builder. It must return " + "that result unchanged, because a merged action has no C++ object" + ) + + def __getattr__(self, attr: str) -> MockObj: + # Python looks up dunder methods itself and expects AttributeError + if attr.startswith("__"): + raise AttributeError(attr) + raise self._misuse() + + def __call__(self, *args: SafeExpType) -> MockObj: + raise self._misuse() + + def __str__(self) -> str: + raise self._misuse() + + +_COLLECTED_ACTION = _CollectedAction("esphome__collected_action", "->") +_RUNNING_PARAM = "esphome__running" + + +def _collect_action( + id_: ID, statements: list[str], emit: Callable[[], MockObj] +) -> bool: + """Record ``id_`` as inline if it is the action being collected. + + Matching the id leaves the conditions and child actions a builder creates to emit normally. + """ + collector = _INLINE_COLLECTOR.get() + if collector is None or collector.action_id is not id_: + return False + # Replacing it would silently lose the statements already collected + if collector.action is not None: + raise EsphomeError(f"The builder of {collector.name} built its action twice") + collector.action = _InlineAction(id_, statements, emit) + return True + + def flash_string(config: ConfigType, value: str) -> str: """Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266.""" return str(cg.progmem_string(value)) @@ -494,8 +567,12 @@ def _apply_function( ) -> MockObj: """Emit the generated function and declare ``id_`` as the ``ApplyAction`` or ``ApplyCondition`` templated on it, so ``play()`` calls it directly.""" - fn = apply_static_function(id_, return_type, lambda_args, statements) - return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) + + def emit() -> MockObj: + fn = apply_static_function(id_, return_type, lambda_args, statements) + return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) + + return _COLLECTED_ACTION if _collect_action(id_, statements, emit) else emit() def apply_static_function( @@ -579,6 +656,9 @@ def apply_action_builder( A constant that is an id (``cv.use_id`` under ``cv.templatable``) is the object it names. With ``call`` every statement targets the call object ``auto apply_call = parent->call()``, and ``apply_call.perform()`` is appended. + + A wrapper may do other work first, but must return the builder's result unchanged and not + use it: when the action merges with its neighbors, the result names no C++ object. """ # An action stores the value, so a std::string constant stays in flash on ESP8266. _check_key_in_schema(name, schema, id_key) @@ -814,6 +894,19 @@ def new_lambda_pvariable( Returns: The created Pvariable """ + if ( + lambda_expr.capture == "" + and stateless_class is StatelessLambdaAction + and _collect_action( + id_obj, + [_lambda_call_statement(lambda_expr)], + lambda: new_lambda_pvariable( + id_obj, lambda_expr, stateless_class, template_arg + ), + ) + ): + return _COLLECTED_ACTION + # For stateless lambdas, use function pointer instead of std::function if lambda_expr.capture == "": id_obj = id_obj.copy() @@ -824,6 +917,12 @@ def new_lambda_pvariable( return cg.new_Pvariable(id_obj, lambda_expr) +def _lambda_call_statement(lambda_expr: LambdaExpression) -> str: + """Call a lambda on the spot, so a ``return`` in it ends only that lambda.""" + names = [MockObj(param.id) for param in lambda_expr.parameters.parameters] + return f"{CallExpression(lambda_expr, *names)};" + + def validate_automation(extra_schema=None, extra_validators=None, single=False): if extra_schema is None: extra_schema = {} @@ -1316,13 +1415,85 @@ async def build_action( return await builder(config, action_id, template_arg, args) +async def _build_action_or_inline( + full_config: ConfigType, template_arg: cg.TemplateArguments, args: TemplateArgsType +) -> MockObj | _InlineAction: + """Run the action's builder, and return what it collected if it emitted no object. + + An action with a manual ``type_id`` keeps its object: a lambda can reach it by that id. + """ + action_id = full_config[CONF_TYPE_ID] + if action_id.is_manual: + return await build_action(full_config, template_arg, args) + collector = _InlineCollector(action_id, _builder_name(full_config)) + token = _INLINE_COLLECTOR.set(collector) + try: + action = await build_action(full_config, template_arg, args) + finally: + _INLINE_COLLECTOR.reset(token) + # Returning anything else would silently drop the collected action from the firmware + if (action is _COLLECTED_ACTION) != (collector.action is not None): + raise EsphomeError( + f"The builder of {collector.name} must return the result of " + "its apply builder unchanged" + ) + return collector.action if action is _COLLECTED_ACTION else action + + +def _builder_name(full_config: ConfigType) -> str: + """For an error: the id is usually generated, so the registry key tells the author where to look.""" + name = next(key for key in full_config if key != CONF_TYPE_ID) + return f"'{name}' ({full_config[CONF_TYPE_ID]})" + + +def _emit_inline_run( + run: list[_InlineAction], + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + """Emit one object for adjacent inline actions; a single one is emitted as if never collected.""" + if len(run) == 1: + return run[0].emit() + id_ = run[0].id_.copy() + id_.type = ApplyRunAction + statements: list[str] = [] + for index, action in enumerate(run): + # Any action can stop this automation, and then the rest must not run + if index > 0: + statements.append(f"if (!{_RUNNING_PARAM})\n return;") + statements.extend(["{", *action.statements, "}"]) + fn = apply_static_function( + id_, + cg.void, + [ + (cg.RawExpression("const int &"), _RUNNING_PARAM), + *_apply_lambda_args(args), + ], + statements, + ) + return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) + + async def build_action_list( config: list[ConfigType], templ: cg.TemplateArguments, arg_type: TemplateArgsType ) -> list[MockObj]: + """Build the actions of ``config``, merging adjacent inline actions into one object. + + The result can therefore be shorter than ``config``. + """ actions: list[MockObj] = [] + run: list[_InlineAction] = [] for conf in config: - action = await build_action(conf, templ, arg_type) + action = await _build_action_or_inline(conf, templ, arg_type) + if isinstance(action, _InlineAction): + run.append(action) + continue + if run: + actions.append(_emit_inline_run(run, templ, arg_type)) + run = [] actions.append(action) + if run: + actions.append(_emit_inline_run(run, templ, arg_type)) return actions diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index f8187a02b9b..d21a8de0e98 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -275,6 +275,17 @@ template class ApplyAction final : public Action void play(const Ts &...x) override { Fn(x...); } }; +/// Runs several adjacent actions merged by codegen into one generated function. The function +/// reads num_running_ between actions: a stop during one action zeroes it, and the rest must not run. +template class ApplyRunAction final : public Action { + public: + void play(const Ts &...x) override { + // Any other type would bind the function's const int & to a temporary that never sees a stop + static_assert(std::is_same_vnum_running_), int>); + Fn(this->num_running_, x...); + } +}; + /// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. template class ApplyCondition final : public Condition { public: diff --git a/tests/component_tests/template/test_per_item_lists.py b/tests/component_tests/template/test_per_item_lists.py index adf1498e37e..aa6c55dc100 100644 --- a/tests/component_tests/template/test_per_item_lists.py +++ b/tests/component_tests/template/test_per_item_lists.py @@ -22,7 +22,8 @@ def test_filters_and_actions_are_added_per_item( assert len(re.findall(r"temp->add_filter\(", main_cpp)) == 3 assert len(re.findall(r"button->add_filter\(", main_cpp)) == 2 assert len(re.findall(r"label->add_filter\(", main_cpp)) == 2 - assert main_cpp.count("->add_then_action(") == 4 + # Adjacent actions in a branch merge into one object + assert main_cpp.count("->add_then_action(") == 3 assert main_cpp.count("->add_else_action(") == 1 assert main_cpp.count("->finish_then();") == 3 assert main_cpp.count("->finish_else();") == 1 diff --git a/tests/components/core/test_apply_run_action.cpp b/tests/components/core/test_apply_run_action.cpp new file mode 100644 index 00000000000..8cf36709548 --- /dev/null +++ b/tests/components/core/test_apply_run_action.cpp @@ -0,0 +1,87 @@ +#include +#include +#include + +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" + +namespace esphome::core::testing { + +namespace { + +// Trigger argument for the generated-style functions below, which are template arguments and cannot capture +struct RunState { + std::vector log{}; + ActionList *list_to_stop{nullptr}; + int running_seen{-1}; +}; + +// Generated functions have this shape: one block per action, with a check between them +void merged_run(const int &running, RunState *const &state) { + state->running_seen = running; + state->log.push_back(1); + if (state->list_to_stop != nullptr) + state->list_to_stop->stop(); + if (!running) + return; + state->log.push_back(2); +} + +void merged_run_with_args(const int &running, const int &number, std::string *const &text) { + *text = std::string("got ") + (running != 0 && number == 5 ? "5 while running" : "something else"); +} + +class RecordAction : public Action { + public: + void play(RunState *const &state) override { state->log.push_back(3); } +}; + +class ApplyRunActionTest : public ::testing::Test { + protected: + void SetUp() override { + this->list_.add_action(&this->run_); + this->list_.add_action(&this->next_); + } + + ApplyRunAction run_; + RecordAction next_; + ActionList list_; + RunState state_; +}; + +} // namespace + +TEST_F(ApplyRunActionTest, RunsEveryActionThenContinuesTheChain) { + this->list_.play(&this->state_); + + EXPECT_EQ(this->state_.log, (std::vector{1, 2, 3})); + EXPECT_FALSE(this->list_.is_running()); +} + +TEST_F(ApplyRunActionTest, PassesTheRunCountWhileRunning) { + this->list_.play(&this->state_); + + EXPECT_EQ(this->state_.running_seen, 1); +} + +TEST_F(ApplyRunActionTest, StopSkipsTheRestOfTheRunAndTheChain) { + this->state_.list_to_stop = &this->list_; + + this->list_.play(&this->state_); + + EXPECT_EQ(this->state_.log, (std::vector{1})); + EXPECT_FALSE(this->list_.is_running()); +} + +TEST(ApplyRunAction, PassesTriggerArguments) { + ApplyRunAction action; + ActionList list; + list.add_action(&action); + std::string text; + + list.play(5, &text); + + EXPECT_EQ(text, "got 5 while running"); +} + +} // namespace esphome::core::testing diff --git a/tests/integration/fixtures/automation_merged_actions.yaml b/tests/integration/fixtures/automation_merged_actions.yaml new file mode 100644 index 00000000000..966360725a6 --- /dev/null +++ b/tests/integration/fixtures/automation_merged_actions.yaml @@ -0,0 +1,182 @@ +esphome: + name: test-merged-actions + +host: + +logger: + level: DEBUG + +globals: + - id: running + type: int + initial_value: "7" + +switch: + - platform: template + id: stopper + optimistic: true + on_turn_on: + - script.stop: stop_by_callback + +api: + actions: + - action: run_stop_in_lambda + then: + - script.execute: stop_in_lambda + - logger.log: "STOP_LAMBDA done" + - action: run_stop_by_action + then: + - script.execute: stop_by_action + - logger.log: "STOP_ACTION done" + - action: run_stop_by_callback + then: + - switch.turn_off: stopper + - script.execute: stop_by_callback + - logger.log: "STOP_CALLBACK done" + - action: run_stop_in_branch + then: + - script.execute: stop_in_branch + - logger.log: "STOP_BRANCH done" + - action: run_restart_reentry + then: + - script.execute: + id: restart_reentry + depth: 0 + - action: run_parallel_limit + then: + - script.execute: parallel_limit + - logger.log: "PARALLEL done" + - action: run_is_running + then: + - script.execute: reports_running + - lambda: |- + ESP_LOGI("test", "RUNNING after %d", id(reports_running).is_running()); + - action: run_lambda_scope + then: + - script.execute: + id: lambda_scope + text: "abc" + - script.execute: + id: lambda_scope + text: "abc" + - logger.log: "SCOPE done" + - action: run_named_running + then: + - script.execute: + id: param_named_running + running: 5 + - script.execute: uses_global_running + - logger.log: "NAMED done" + - action: run_repeat + then: + - repeat: + count: 2 + then: + - logger.log: + format: "REPEAT first %u" + args: [iteration] + - lambda: |- + ESP_LOGI("test", "REPEAT second %u", (unsigned) iteration); + - logger.log: "REPEAT done" + - action: run_args + variables: + value: int + then: + - logger.log: + format: "ARGS first %d" + args: [value] + - lambda: |- + ESP_LOGI("test", "ARGS second %d", value); + - delay: 1ms + - logger.log: + format: "ARGS third %d" + args: [value] + - lambda: |- + ESP_LOGI("test", "ARGS fourth %d", value); + +script: + - id: stop_in_lambda + then: + - logger.log: "STOP_LAMBDA before" + - lambda: id(stop_in_lambda).stop(); + - logger.log: "STOP_LAMBDA after" + - id: stop_by_action + then: + - logger.log: "STOP_ACTION before" + - script.stop: + - logger.log: "STOP_ACTION after" + - id: stop_by_callback + then: + - logger.log: "STOP_CALLBACK before" + - switch.turn_on: stopper + - logger.log: "STOP_CALLBACK after" + - id: stop_in_branch + then: + - if: + condition: + lambda: return true; + then: + - logger.log: "STOP_BRANCH before" + - lambda: id(stop_in_branch).stop(); + - logger.log: "STOP_BRANCH after inner" + - logger.log: "STOP_BRANCH after outer" + - id: restart_reentry + mode: restart + parameters: + depth: int + then: + - logger.log: + format: "RESTART start %d" + args: [depth] + - lambda: |- + if (depth == 0) + id(restart_reentry).execute(1); + - logger.log: + format: "RESTART middle %d" + args: [depth] + - delay: 1ms + - logger.log: + format: "RESTART end %d" + args: [depth] + - id: parallel_limit + mode: parallel + max_runs: 1 + then: + - logger.log: "PARALLEL start" + - lambda: id(parallel_limit).execute(); + - logger.log: "PARALLEL end" + - id: reports_running + then: + - logger.log: "RUNNING first" + - lambda: |- + ESP_LOGI("test", "RUNNING during %d", id(reports_running).is_running()); + - id: lambda_scope + mode: queued + parameters: + text: string + then: + # A return ends only its own lambda, and each lambda gets its own copy of the argument + - lambda: |- + text += "!"; + ESP_LOGI("test", "SCOPE first %s", text.c_str()); + return; + - lambda: |- + static int count = 0; + count++; + ESP_LOGI("test", "SCOPE second %s %d", text.c_str(), count); + - id: param_named_running + parameters: + running: int + then: + - logger.log: + format: "NAMED param first %d" + args: [running] + - lambda: |- + ESP_LOGI("test", "NAMED param second %d", running); + - id: uses_global_running + then: + - logger.log: + format: "NAMED global first %d" + args: [id(running)] + - lambda: |- + ESP_LOGI("test", "NAMED global second %d", id(running)); diff --git a/tests/integration/test_automation_merged_actions.py b/tests/integration/test_automation_merged_actions.py new file mode 100644 index 00000000000..1111fa68fcb --- /dev/null +++ b/tests/integration/test_automation_merged_actions.py @@ -0,0 +1,100 @@ +"""Integration test that merged adjacent actions behave like a chain of separate actions.""" + +from __future__ import annotations + +import re + +from aioesphomeapi import UserService +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_automation_merged_actions( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Stops, re-entry, run counts, lambda scope and argument names are all unchanged.""" + waiter = LineWaiter() + + def count(text: str) -> int: + return sum(text in line for line in waiter.lines) + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service: dict[str, UserService] = {s.name: s for s in services} + + # A stop during one action keeps the following actions from running, whichever way + # it is reached, and execution returns to the caller. + for action, prefix in ( + ("run_stop_in_lambda", "STOP_LAMBDA"), + ("run_stop_by_action", "STOP_ACTION"), + ("run_stop_by_callback", "STOP_CALLBACK"), + ): + await client.execute_service(service[action], {}) + await waiter.wait_for(f"{prefix} done", timeout=5.0) + assert count(f"{prefix} before") == 1 + assert count(f"{prefix} after") == 0 + + await client.execute_service(service["run_stop_in_branch"], {}) + await waiter.wait_for("STOP_BRANCH done", timeout=5.0) + assert count("STOP_BRANCH before") == 1 + assert count("STOP_BRANCH after") == 0 + + # A restart from inside the run stops the outer run; only the inner one goes on + await client.execute_service(service["run_restart_reentry"], {}) + await waiter.wait_for("RESTART end 1", timeout=5.0) + await client.execute_service(service["run_is_running"], {}) + await waiter.wait_for("RUNNING after", timeout=5.0) + assert count("RESTART start 0") == 1 + assert count("RESTART start 1") == 1 + assert count("RESTART middle 1") == 1 + assert count("RESTART middle 0") == 0 + assert count("RESTART end 0") == 0 + + # A run holds one slot: with max_runs 1 the re-entrant execute is rejected + assert count("RUNNING during 1") == 1 + assert count("RUNNING after 0") == 1 + await client.execute_service(service["run_parallel_limit"], {}) + await waiter.wait_for("PARALLEL done", timeout=5.0) + assert count("PARALLEL start") == 1 + assert count("PARALLEL end") == 1 + assert count("maximum number of parallel runs exceeded") == 1 + + # A return ends only its own lambda, an argument change stays in that lambda, and a + # static local keeps one value across runs. + await client.execute_service(service["run_lambda_scope"], {}) + await waiter.wait_for("SCOPE done", timeout=5.0) + await waiter.wait_for("SCOPE second abc 2", timeout=5.0) + assert count("SCOPE first abc!") == 2 + assert count("SCOPE second abc 1") == 1 + + # A parameter or a global named `running` does not clash with the generated code + await client.execute_service(service["run_named_running"], {}) + await waiter.wait_for("NAMED done", timeout=5.0) + assert count("NAMED param first 5") == 1 + assert count("NAMED param second 5") == 1 + assert count("NAMED global first 7") == 1 + assert count("NAMED global second 7") == 1 + + # Merged actions inside a repeat see its iteration, in order + await client.execute_service(service["run_repeat"], {}) + await waiter.wait_for("REPEAT done", timeout=5.0) + order = [ + match.group(1) + for line in waiter.lines + if (match := re.search(r"REPEAT (first \d|second \d)", line)) + ] + assert order == ["first 0", "second 0", "first 1", "second 1"] + + # Arguments reach the merged actions on both sides of a delay + await client.execute_service(service["run_args"], {"value": 42}) + await waiter.wait_for("ARGS fourth 42", timeout=5.0) + for label in ("first", "second", "third"): + assert count(f"ARGS {label} 42") == 1 diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 3467216e709..b7fd4583946 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from esphome.automation import ( + _COLLECTED_ACTION, ACTION_REGISTRY, CONF_CHOICE_ID, CONF_CHOOSE, @@ -19,7 +20,9 @@ from esphome.automation import ( ApplyCondition, ApplyField, CallbackAutomation, + DelayAction, ForCondition, + IfAction, LambdaAction, LambdaCondition, NotCondition, @@ -31,7 +34,9 @@ from esphome.automation import ( TriggerOnTrueForwarder, WaitUntilAction, _shadow_iteration, + apply_action_builder, build_action, + build_action_list, build_callback_automation, build_callback_automations, build_condition, @@ -2217,3 +2222,287 @@ async def test_component_resume_action_update_interval_is_optional( } await build_action(config, TEMPLATE_ARG, []) assert ("resume->set_update_interval(" in CORE.cpp_main_section) == with_interval + + +COMPONENT_ID = ID("comp", type=cg.Component) + + +def _action(name: str, id_: str, is_manual: bool = False) -> ConfigType: + """Config for an action taking the component id; ``id_`` names the action object.""" + return { + name: {CONF_ID: COMPONENT_ID}, + CONF_TYPE_ID: ID( + id_, is_declaration=True, type=ApplyAction, is_manual=is_manual + ), + } + + +def _lambda_action(id_: str, body: str) -> ConfigType: + return { + "lambda": Lambda(body), + CONF_TYPE_ID: ID(id_, is_declaration=True, type=LambdaAction, is_manual=False), + } + + +@pytest.fixture +def apply_actions() -> Generator[None]: + """Declare the component and register apply actions ``my.a`` to ``my.c``.""" + CORE.config = {} + cg.new_Pvariable(ID("comp", is_declaration=True, type=cg.Component)) + schema = {cv.Required(CONF_ID): cv.use_id(cg.Component)} + actions = Registry() + actions.update(ACTION_REGISTRY) + with patch("esphome.automation.ACTION_REGISTRY", actions): + for name in ("a", "b", "c"): + register_apply_action(f"my.{name}", schema, ApplyCall(f"do_{name}()")) + yield + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_merges_adjacent_actions() -> None: + """Adjacent inline actions become one object, with a stop check between the actions.""" + config = [_action("my.a", "act_a"), _action("my.b", "act_b")] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert len(result) == 1 + assert "new(act_a) ApplyRunAction();" in CORE.cpp_main_section + assert "act_b" not in CORE.cpp_main_section + text = CORE.cpp_global_section + assert "void esphome__act_a__fn(const int & esphome__running)" in text + assert "esphome__act_b__fn" not in text + assert text.index("::comp->do_a();") < text.index("if (!esphome__running)") + assert text.index("if (!esphome__running)") < text.index("::comp->do_b();") + assert text.count("if (!esphome__running)") == 1 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_single_action_is_unchanged() -> None: + """A lone apply action keeps its plain ApplyAction and no stop check.""" + result = await build_action_list([_action("my.a", "act_a")], TEMPLATE_ARG, []) + + assert len(result) == 1 + assert "new(act_a) ApplyAction();" in CORE.cpp_main_section + assert "esphome__running" not in CORE.cpp_global_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_single_lambda_is_unchanged() -> None: + """A lone lambda keeps the StatelessLambdaAction that shares one vtable.""" + config = [_lambda_action("act_l", "do_it();")] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert len(result) == 1 + assert "new(act_l) StatelessLambdaAction<>(" in CORE.cpp_main_section + assert "esphome__running" not in CORE.cpp_global_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_merges_lambda_as_immediate_call() -> None: + """A lambda keeps its scope and argument copy; a leading one must not type the object.""" + config = [_lambda_action("act_l", "x += 1;\nreturn;"), _action("my.a", "act_a")] + args = [(cg.int_, "x")] + + result = await build_action_list(config, cg.TemplateArguments(cg.int_), args) + + assert len(result) == 1 + assert "new(act_l) ApplyRunAction();" in ( + CORE.cpp_main_section + ) + text = CORE.cpp_global_section + assert "const std::remove_cvref_t & x" in text + assert "[](int x) -> void {" in text + assert "}(x);" in text + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_object_action_ends_a_run() -> None: + """An action with its own class splits the runs on either side of it.""" + delay = { + "delay": 100, + CONF_TYPE_ID: ID("act_d", is_declaration=True, type=DelayAction), + } + config = [ + _action("my.a", "act_a"), + _action("my.b", "act_b"), + delay, + _action("my.c", "act_c"), + ] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert [str(action) for action in result] == ["act_a", "act_d", "act_c"] + text = CORE.cpp_main_section + assert "new(act_a) ApplyRunAction();" in text + assert "new(act_c) ApplyAction();" in text + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_manual_type_id_keeps_its_object() -> None: + """A lambda can reach an action by a manual type_id, so it stays a separate object.""" + config = [ + _action("my.a", "act_a"), + _action("my.b", "act_b"), + _action("my.c", "act_c", is_manual=True), + ] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert [str(action) for action in result] == ["act_a", "act_c"] + assert "new(act_c) ApplyAction();" in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_keeps_wrapper_side_effects() -> None: + """A builder that wraps an apply builder still runs, and its action still merges.""" + builder = apply_action_builder( + "my.wrapped", + {cv.Required(CONF_ID): cv.use_id(cg.Component)}, + ApplyCall("do_wrapped()"), + ) + + @register_action("my.wrapped", ApplyAction, {}, synchronous=True) + async def wrapped_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + cg.add_define("USE_MY_WRAPPED") + return await builder(config, action_id, template_arg, args) + + config = [_action("my.wrapped", "act_w"), _action("my.a", "act_a")] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert len(result) == 1 + assert any(define.name == "USE_MY_WRAPPED" for define in CORE.defines) + assert "::comp->do_wrapped();" in CORE.cpp_global_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_rejects_a_dropped_collected_action() -> None: + """A wrapper that returns another object would lose the collected action without a trace.""" + builder = apply_action_builder( + "my.wrapped", + {cv.Required(CONF_ID): cv.use_id(cg.Component)}, + ApplyCall("do_wrapped()"), + ) + + @register_action("my.wrapped", ApplyAction, {}, synchronous=True) + async def wrapped_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + await builder(config, action_id, template_arg, args) + return MockObj("something_else", "->") + + with pytest.raises( + EsphomeError, match=r"'my\.wrapped' \(act_w\) must return the result" + ): + await build_action_list([_action("my.wrapped", "act_w")], TEMPLATE_ARG, []) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_rejects_using_the_collected_result() -> None: + """A wrapper that uses the result fails here, not later as an undeclared name in C++.""" + builder = apply_action_builder( + "my.wrapped", + {cv.Required(CONF_ID): cv.use_id(cg.Component)}, + ApplyCall("do_wrapped()"), + ) + + @register_action("my.wrapped", ApplyAction, {}, synchronous=True) + async def wrapped_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + var = await builder(config, action_id, template_arg, args) + cg.add(var.set_x(1)) + return var + + with pytest.raises(EsphomeError, match=r"'my\.wrapped' \(act_w\) used the result"): + await build_action_list([_action("my.wrapped", "act_w")], TEMPLATE_ARG, []) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_rejects_building_an_action_twice() -> None: + """The second build would otherwise replace the first without a trace.""" + builder = apply_action_builder( + "my.wrapped", + {cv.Required(CONF_ID): cv.use_id(cg.Component)}, + ApplyCall("do_wrapped()"), + ) + + @register_action("my.wrapped", ApplyAction, {}, synchronous=True) + async def wrapped_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + await builder(config, action_id, template_arg, args) + return await builder(config, action_id, template_arg, args) + + with pytest.raises( + EsphomeError, match=r"'my\.wrapped' \(act_w\) built its action twice" + ): + await build_action_list([_action("my.wrapped", "act_w")], TEMPLATE_ARG, []) + + +@pytest.mark.parametrize( + "use", + [str, lambda result: result(), lambda result: result.set_x], + ids=["str", "call", "attribute"], +) +def test_collected_result_cannot_be_used(use: Callable[[MockObj], object]) -> None: + with pytest.raises(EsphomeError, match="The builder of an action used the result"): + use(_COLLECTED_ACTION) + + +def test_collected_result_leaves_python_protocols_alone() -> None: + """Python probes for dunder methods and expects them to be missing.""" + assert not hasattr(_COLLECTED_ACTION, "__wrapped__") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_nested_builders_emit_normally() -> None: + """An ``if`` keeps its object and condition, and merges inside its own branch.""" + condition = { + "lambda": Lambda("return true;"), + CONF_TYPE_ID: ID("cond", is_declaration=True, type=LambdaCondition), + } + if_action = { + "if": { + CONF_CONDITION: condition, + CONF_THEN: [_action("my.a", "act_a"), _action("my.b", "act_b")], + }, + CONF_TYPE_ID: ID("act_if", is_declaration=True, type=IfAction), + } + + result = await build_action_list( + [if_action, _action("my.c", "act_c")], TEMPLATE_ARG, [] + ) + + assert [str(action) for action in result] == ["act_if", "act_c"] + text = CORE.cpp_main_section + assert "new(cond) StatelessLambdaCondition<>(" in text + assert "new(act_a) ApplyRunAction();" in text + assert "act_if->add_then_action(act_a);" in text + assert "act_if->finish_then();" in text From 3a0520245d227c19e1b25142e75e26adc73b7a03 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:53:33 +1100 Subject: [PATCH 003/170] [core] Run callback automations directly when every action is inline (#21979) Co-authored-by: Claude Opus 5.5 --- .agents/skills/automations/SKILL.md | 19 +- esphome/automation.py | 195 +++++++++++---- .../alarm_control_panel/__init__.py | 69 ++---- esphome/components/media_player/__init__.py | 59 ++--- esphome/components/script/__init__.py | 22 +- esphome/components/script/script.h | 50 ---- .../esp32_camera/test_capture_delay.py | 20 +- .../nextion/test_custom_trigger_args.py | 6 +- .../fixtures/automation_merged_actions.yaml | 41 ++++ .../test_automation_merged_actions.py | 40 ++++ tests/unit_tests/test_automation.py | 224 +++++++++++++++++- 11 files changed, 540 insertions(+), 205 deletions(-) diff --git a/.agents/skills/automations/SKILL.md b/.agents/skills/automations/SKILL.md index 1ac7edffac2..c850fffeb21 100644 --- a/.agents/skills/automations/SKILL.md +++ b/.agents/skills/automations/SKILL.md @@ -70,6 +70,21 @@ await automation.build_callback_automation( `build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and `build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`. +When every action in a callback automation is inline (see Actions below), no `Automation` or action objects +are generated: the callback calls one generated function, at the same RAM cost as a hand-written lambda. A +custom forwarder blocks this unless its filter is also given as `params`, `forward` and `when`, so always pass +them together: +```python +automation.CallbackAutomation( + CONF_ON_IDLE, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(State.IDLE), + params=[(State, "state")], + forward=[], + when=f"state == {State.IDLE}", +) +``` + **C++: no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation): ```cpp @@ -191,7 +206,9 @@ Adjacent `register_apply_action` and `lambda:` actions in a list merge into one object, which saves 12 to 16 bytes of RAM per merged action. An action with its own C++ class ends the merged run, so prefer `register_apply_action` wherever the action allows it. Because of this, `build_action_list` can return fewer objects than the config has entries. A builder that wraps `apply_action_builder` may do other work -first, but must return its result unchanged and not use it. +first, but must return its result unchanged and not use it. A builder that must generate its own statements, and +is registered with `automation.ApplyAction`, can call `apply_statements_action` and still merge; `script.execute` +is an example. ## Conditions diff --git a/esphome/automation.py b/esphome/automation.py index 071d871949c..ea355e60c27 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -575,6 +575,22 @@ def _apply_function( return _COLLECTED_ACTION if _collect_action(id_, statements, emit) else emit() +def apply_statements_action( + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + statements: list[str], +) -> MockObj: + """Build an action from C++ statements, for a builder registered with ``ApplyAction``. + + Use it when the statements do not fit ``register_apply_action``. Generated names should + start with ``esphome__`` so a trigger argument cannot shadow them. + """ + return _apply_function( + action_id, cg.void, template_arg, _apply_lambda_args(args), statements + ) + + def apply_static_function( id_: ID, return_type: SafeExpType, @@ -899,7 +915,7 @@ def new_lambda_pvariable( and stateless_class is StatelessLambdaAction and _collect_action( id_obj, - [_lambda_call_statement(lambda_expr)], + [f"{immediate_lambda_call(lambda_expr)};"], lambda: new_lambda_pvariable( id_obj, lambda_expr, stateless_class, template_arg ), @@ -917,10 +933,10 @@ def new_lambda_pvariable( return cg.new_Pvariable(id_obj, lambda_expr) -def _lambda_call_statement(lambda_expr: LambdaExpression) -> str: - """Call a lambda on the spot, so a ``return`` in it ends only that lambda.""" +def immediate_lambda_call(lambda_expr: LambdaExpression) -> CallExpression: + """Call a lambda on the spot with its own parameter names; a ``return`` ends only the lambda.""" names = [MockObj(param.id) for param in lambda_expr.parameters.parameters] - return f"{CallExpression(lambda_expr, *names)};" + return CallExpression(lambda_expr, *names) def validate_automation(extra_schema=None, extra_validators=None, single=False): @@ -1474,6 +1490,33 @@ def _emit_inline_run( return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) +async def _build_action_plan( + config: list[ConfigType], templ: cg.TemplateArguments, arg_type: TemplateArgsType +) -> list[MockObj | list[_InlineAction]]: + """Build the actions of ``config``; adjacent inline actions stay together, not yet emitted.""" + plan: list[MockObj | list[_InlineAction]] = [] + for conf in config: + action = await _build_action_or_inline(conf, templ, arg_type) + if not isinstance(action, _InlineAction): + plan.append(action) + elif plan and isinstance(plan[-1], list): + plan[-1].append(action) + else: + plan.append([action]) + return plan + + +def _emit_action_plan( + plan: list[MockObj | list[_InlineAction]], + templ: cg.TemplateArguments, + arg_type: TemplateArgsType, +) -> list[MockObj]: + return [ + _emit_inline_run(item, templ, arg_type) if isinstance(item, list) else item + for item in plan + ] + + async def build_action_list( config: list[ConfigType], templ: cg.TemplateArguments, arg_type: TemplateArgsType ) -> list[MockObj]: @@ -1481,20 +1524,8 @@ async def build_action_list( The result can therefore be shorter than ``config``. """ - actions: list[MockObj] = [] - run: list[_InlineAction] = [] - for conf in config: - action = await _build_action_or_inline(conf, templ, arg_type) - if isinstance(action, _InlineAction): - run.append(action) - continue - if run: - actions.append(_emit_inline_run(run, templ, arg_type)) - run = [] - actions.append(action) - if run: - actions.append(_emit_inline_run(run, templ, arg_type)) - return actions + plan = await _build_action_plan(config, templ, arg_type) + return _emit_action_plan(plan, templ, arg_type) async def build_condition( @@ -1601,14 +1632,67 @@ async def _new_automation( args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: """Create the Automation for ``config`` with its actions.""" + plan = await _build_automation_plan(args, config) + return _automation_from_plan(plan, args, config, *ctor_args) + + +async def _build_automation_plan( + args: TemplateArgsType, config: ConfigType +) -> list[MockObj | list[_InlineAction]]: + templ = cg.TemplateArguments(*(arg[0] for arg in args)) + return await _build_action_plan(config[CONF_THEN], templ, args) + + +def _automation_from_plan( + plan: list[MockObj | list[_InlineAction]], + args: TemplateArgsType, + config: ConfigType, + *ctor_args: MockObj, +) -> MockObj: templ = cg.TemplateArguments(*(arg[0] for arg in args)) obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) - actions = await build_action_list(config[CONF_THEN], templ, args) - for action in actions: + for action in _emit_action_plan(plan, templ, args): cg.add(obj.add_action(action)) return obj +def _is_direct(plan: list[MockObj | list[_InlineAction]], config: ConfigType) -> bool: + """Whether a callback automation is all inline actions, so it needs no objects. + + Without a Trigger or a manual id nothing can reach such an automation to stop it, so + no stop checks are needed either. Only for callers that create no Trigger. + """ + return ( + not config[CONF_AUTOMATION_ID].is_manual + and len(plan) == 1 + and isinstance(plan[0], list) + ) + + +def _direct_function( + plan: list[MockObj | list[_InlineAction]], + args: TemplateArgsType, + config: ConfigType, +) -> MockObj | None: + if not _is_direct(plan, config): + return None + statements = [line for action in plan[0] for line in ("{", *action.statements, "}")] + return apply_static_function( + plan[0][0].id_, cg.void, _apply_lambda_args(args), statements + ) + + +def _when_call( + when: str | ApplyCall | None, parent: MockObj | None +) -> ApplyCall | None: + if when is None: + return None + call = when if isinstance(when, ApplyCall) else ApplyCall(when) + if parent is None and call.names_parent: + raise ValueError(f"trigger filter {call.target!r} names {{parent}}") + return call + + async def build_automation( trigger: MockObj, args: TemplateArgsType, config: ConfigType ) -> MockObj: @@ -1623,24 +1707,37 @@ async def build_trigger_callback( when: str | ApplyCall | None = None, parent: MockObj | None = None, ) -> LambdaExpression: - """Build the Automation for ``config`` and return a stateless callback that triggers it. + """Build ``config`` and return a stateless callback that runs its actions. + + An automation that is all inline actions becomes a function the callback calls, with no + Automation or action objects; otherwise the callback triggers an Automation. ``params`` are the parent callback's parameters, ``forward`` the expressions passed to - ``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``), + the actions (default: the parameter names; write the parent as ``parent_ref(var)``), ``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none of its keys is set. ``when`` may name ``parent`` as ``{parent}``, e.g. ``"{parent}->is_fully_open()"``. """ - members: list[tuple[Any, Any, Any]] = [] - if when is not None: - call = when if isinstance(when, ApplyCall) else ApplyCall(when) - members = call.members - if parent is None and call.names_parent: - raise ValueError(f"trigger filter {call.target!r} names {{parent}}") - obj = await _new_automation(args, config) + call = _when_call(when, parent) + plan = await _build_automation_plan(args, config) + return await _trigger_callback(plan, args, config, params, forward, call, parent) + + +async def _trigger_callback( + plan: list[MockObj | list[_InlineAction]], + args: TemplateArgsType, + config: ConfigType, + params: TemplateArgsType, + forward: Sequence[str | Expression] | None, + call: ApplyCall | None, + parent: MockObj | None, +) -> LambdaExpression: + if (target := _direct_function(plan, args, config)) is None: + target = f"{parent_ref(_automation_from_plan(plan, args, config))}->trigger" lambda_args = _apply_lambda_args(params) statements: list[str] = [] - if when is not None: + if call is not None: + members = call.members values = _apply_values(config, members) if _apply_call_active(members, values): check = await _render_check( @@ -1655,7 +1752,7 @@ async def build_trigger_callback( statements.append(f"if (!({check}))\n return;") if forward is None: forward = [name for _, name in params] - statements.append(f"{parent_ref(obj)}->trigger({', '.join(map(str, forward))});") + statements.append(f"{target}({', '.join(map(str, forward))});") return LambdaExpression( ["\n".join(statements)], lambda_args, capture="", return_type=cg.void ) @@ -1682,7 +1779,10 @@ async def build_callback_automation( and avoid heap allocation. With ``params``, ``forward`` or ``when`` the callback is instead the stateless - lambda of ``build_trigger_callback``; ``forwarder`` cannot be combined with them. + lambda of ``build_trigger_callback``. An automation that is all inline actions needs + no objects and is called directly, so it also takes that lambda when no filter is + given. A ``forwarder`` given with them is used when the automation does need objects; + they must then describe the same filter. A ``forwarder`` alone keeps its objects. :param parent: The component object (e.g., button, sensor). :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). @@ -1692,17 +1792,32 @@ async def build_callback_automation( TriggerForwarder. Pass any struct type whose aggregate init takes a single Automation pointer (e.g., TriggerOnTrueForwarder). """ - if params is not None or forward is not None or when is not None: - if forwarder is not None: - raise ValueError( - "forwarder cannot be combined with params, forward or when" - ) - callback = await build_trigger_callback( - args, config, args if params is None else params, forward, when, parent + custom = params is not None or forward is not None or when is not None + if not custom and ( + forwarder is TriggerOnTrueForwarder or forwarder is TriggerOnFalseForwarder + ): + if args: + raise ValueError(f"{forwarder} triggers an automation with no arguments") + params, forward, custom = [(cg.bool_, "state")], [], True + when = "state" if forwarder is TriggerOnTrueForwarder else "!state" + call = _when_call(when, parent) + plan = await _build_automation_plan(args, config) + # A forwarder alone says nothing about what it filters, so it cannot be replaced + if (custom and forwarder is None) or ( + (custom or forwarder is None) and _is_direct(plan, config) + ): + callback = await _trigger_callback( + plan, + args, + config, + args if params is None else params, + forward, + call, + parent, ) cg.add(getattr(parent, callback_method)(callback)) return - obj = await _new_automation(args, config) + obj = _automation_from_plan(plan, args, config) # Use template forwarder structs for deduplication. The compiler generates # one operator() per forwarder type; different automation pointers are just # data in the struct. diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py index e457862159e..212a6904f4e 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -105,57 +105,32 @@ ALARM_CONTROL_PANEL_CODE_ACTION_SCHEMA = maybe_simple_id( ALARM_CONTROL_PANEL_CONDITION_SCHEMA = ALARM_CONTROL_PANEL_ACTION_SCHEMA +def _on_state_enter(conf_key: str, state: cg.MockObj) -> automation.CallbackAutomation: + return automation.CallbackAutomation( + conf_key, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(state), + params=[(AlarmControlPanelState, "state")], + forward=[], + when=f"state == {state}", + ) + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( - CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder - ), - automation.CallbackAutomation( - CONF_ON_TRIGGERED, + CONF_ON_STATE, "add_on_state_callback", - forwarder=StateEnterForwarder.template( - AlarmControlPanelState.ACP_STATE_TRIGGERED - ), - ), - automation.CallbackAutomation( - CONF_ON_ARMING, - "add_on_state_callback", - forwarder=StateEnterForwarder.template(AlarmControlPanelState.ACP_STATE_ARMING), - ), - automation.CallbackAutomation( - CONF_ON_PENDING, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - AlarmControlPanelState.ACP_STATE_PENDING - ), - ), - automation.CallbackAutomation( - CONF_ON_ARMED_HOME, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - AlarmControlPanelState.ACP_STATE_ARMED_HOME - ), - ), - automation.CallbackAutomation( - CONF_ON_ARMED_NIGHT, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - AlarmControlPanelState.ACP_STATE_ARMED_NIGHT - ), - ), - automation.CallbackAutomation( - CONF_ON_ARMED_AWAY, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - AlarmControlPanelState.ACP_STATE_ARMED_AWAY - ), - ), - automation.CallbackAutomation( - CONF_ON_DISARMED, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - AlarmControlPanelState.ACP_STATE_DISARMED - ), + forwarder=StateAnyForwarder, + params=[(AlarmControlPanelState, "state")], + forward=[], ), + _on_state_enter(CONF_ON_TRIGGERED, AlarmControlPanelState.ACP_STATE_TRIGGERED), + _on_state_enter(CONF_ON_ARMING, AlarmControlPanelState.ACP_STATE_ARMING), + _on_state_enter(CONF_ON_PENDING, AlarmControlPanelState.ACP_STATE_PENDING), + _on_state_enter(CONF_ON_ARMED_HOME, AlarmControlPanelState.ACP_STATE_ARMED_HOME), + _on_state_enter(CONF_ON_ARMED_NIGHT, AlarmControlPanelState.ACP_STATE_ARMED_NIGHT), + _on_state_enter(CONF_ON_ARMED_AWAY, AlarmControlPanelState.ACP_STATE_ARMED_AWAY), + _on_state_enter(CONF_ON_DISARMED, AlarmControlPanelState.ACP_STATE_DISARMED), automation.CallbackAutomation(CONF_ON_CLEARED, "add_on_cleared_callback"), automation.CallbackAutomation(CONF_ON_CHIME, "add_on_chime_callback"), automation.CallbackAutomation(CONF_ON_READY, "add_on_ready_callback"), diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 36eb2e47c63..f22a90bf894 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -243,48 +243,33 @@ _STATE_CONDITIONS = ( MediaPlayerCommand = media_player_ns.enum("MediaPlayerCommand", is_class=True) +def _on_state_enter(conf_key: str, state: cg.MockObj) -> automation.CallbackAutomation: + return automation.CallbackAutomation( + conf_key, + "add_on_state_callback", + forwarder=StateEnterForwarder.template(state), + params=[(MediaPlayerState, "state")], + forward=[], + when=f"state == {state}", + ) + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( - CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder - ), - automation.CallbackAutomation( - CONF_ON_IDLE, + CONF_ON_STATE, "add_on_state_callback", - forwarder=StateEnterForwarder.template( - MediaPlayerState.MEDIA_PLAYER_STATE_IDLE - ), + forwarder=StateAnyForwarder, + params=[(MediaPlayerState, "state")], + forward=[], ), - automation.CallbackAutomation( - CONF_ON_PLAY, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING - ), - ), - automation.CallbackAutomation( - CONF_ON_PAUSE, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED - ), - ), - automation.CallbackAutomation( - CONF_ON_ANNOUNCEMENT, - "add_on_state_callback", - forwarder=StateEnterForwarder.template( - MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING - ), - ), - automation.CallbackAutomation( - CONF_ON_TURN_ON, - "add_on_state_callback", - forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_ON), - ), - automation.CallbackAutomation( - CONF_ON_TURN_OFF, - "add_on_state_callback", - forwarder=StateEnterForwarder.template(MediaPlayerState.MEDIA_PLAYER_STATE_OFF), + _on_state_enter(CONF_ON_IDLE, MediaPlayerState.MEDIA_PLAYER_STATE_IDLE), + _on_state_enter(CONF_ON_PLAY, MediaPlayerState.MEDIA_PLAYER_STATE_PLAYING), + _on_state_enter(CONF_ON_PAUSE, MediaPlayerState.MEDIA_PLAYER_STATE_PAUSED), + _on_state_enter( + CONF_ON_ANNOUNCEMENT, MediaPlayerState.MEDIA_PLAYER_STATE_ANNOUNCING ), + _on_state_enter(CONF_ON_TURN_ON, MediaPlayerState.MEDIA_PLAYER_STATE_ON), + _on_state_enter(CONF_ON_TURN_OFF, MediaPlayerState.MEDIA_PLAYER_STATE_OFF), ) diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index da149944f21..d23c9c176cc 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -13,7 +13,6 @@ CODEOWNERS = ["@esphome/core"] DOMAIN = "script" script_ns = cg.esphome_ns.namespace("script") Script = script_ns.class_("Script", automation.Trigger.template()) -ScriptExecuteAction = script_ns.class_("ScriptExecuteAction", automation.Action) ScriptWaitAction = script_ns.class_("ScriptWaitAction", automation.Action, cg.Component) SingleScript = script_ns.class_("SingleScript", Script) RestartScript = script_ns.class_("RestartScript", Script) @@ -210,7 +209,7 @@ async def to_code(config): @automation.register_action( "script.execute", - ScriptExecuteAction, + automation.ApplyAction, maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(Script), @@ -251,17 +250,20 @@ async def script_execute_action_to_code(config, action_id, template_arg, args): script = get_script(config[CONF_ID]) params = script.get(CONF_PARAMETERS, []) - template, script_params = parameters_to_template(params) + _, script_params = parameters_to_template(params) script_args = await get_ordered_args(config, script_params) - # We need to use the parent class 'Script' as the template argument - # to match the partial specialization of the ScriptExecuteAction template - template_arg = cg.TemplateArguments(Script.template(template), *template_arg) - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - cg.add(var.set_args(*script_args)) - return var + # Separate statements, because the order of evaluation inside one call is not defined + statements = [ + f"auto esphome__arg_{index} = {automation.immediate_lambda_call(arg)};" + for index, arg in enumerate(script_args) + ] + names = ", ".join( + f"std::move(esphome__arg_{index})" for index in range(len(script_args)) + ) + statements.append(f"{automation.parent_ref(paren)}->execute({names});") + return automation.apply_statements_action(action_id, template_arg, args, statements) def _require_script_stop_id(config: ConfigType) -> ConfigType: diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index a3c02258cc5..96539944e9c 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -46,19 +46,10 @@ template class Script : public ScriptLogger, public Triggerstop_action(); } #endif - // execute this script using a tuple that contains the arguments - void execute_tuple(const std::tuple &tuple) { - this->execute_tuple_(tuple, std::make_index_sequence{}); - } - // Internal function to give scripts readable names. void set_name(const LogString *name) { name_ = name; } protected: - template void execute_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->execute(std::get(tuple)...); - } - // Run the action chain with this script's name published as the current source (RAII save/restore, // so nesting composes), so deferred work inside the script is attributed to it in blocking // warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame). @@ -252,47 +243,6 @@ template class ParallelScript : public Script { int max_runs_ = 0; }; -template class ScriptExecuteAction; - -template class ScriptExecuteAction, Ts...> final : public Action { - public: - ScriptExecuteAction(Script *script) : script_(script) {} - - using Args = std::tuple...>; - - template void set_args(F... x) { args_ = Args{x...}; } - - void play(const Ts &...x) override { this->script_->execute_tuple(this->eval_args_(x...)); } - - protected: - // NOTE: - // `eval_args_impl` functions evaluates `I`th the functions in `args` member. - // and then recursively calls `eval_args_impl` for the `I+1`th arg. - // if `I` = `N` all args have been stored, and nothing is done. - - template - void eval_args_impl_(std::tuple & /*unused*/, std::integral_constant /*unused*/, - std::integral_constant /*unused*/, Ts... /*unused*/) {} - - template - void eval_args_impl_(std::tuple &evaled_args, std::integral_constant /*unused*/, - std::integral_constant n, Ts... x) { - std::get(evaled_args) = std::get(args_).value(x...); // NOTE: evaluate `i`th arg, and store in tuple. - eval_args_impl_(evaled_args, std::integral_constant{}, n, - x...); // NOTE: recurse to next index. - } - - std::tuple eval_args_(Ts... x) { - std::tuple evaled_args; - eval_args_impl_(evaled_args, std::integral_constant{}, std::tuple_size{}, x...); - return evaled_args; - } - - Script *script_; - // Empty tuple for scripts without parameters; takes no space then - [[no_unique_address]] Args args_; -}; - /** Wait for a script to finish before continuing. * * Uses queue-based storage to safely handle concurrent executions. diff --git a/tests/component_tests/esp32_camera/test_capture_delay.py b/tests/component_tests/esp32_camera/test_capture_delay.py index 452313c5df1..9af070e3d68 100644 --- a/tests/component_tests/esp32_camera/test_capture_delay.py +++ b/tests/component_tests/esp32_camera/test_capture_delay.py @@ -34,17 +34,19 @@ esp32_camera: START = " on_capture_start:\n - logger.log: start\n" END = " on_capture_end:\n - logger.log: end\n" -FORWARDERS = ("TriggerOnTrueForwarder", "TriggerOnFalseForwarder") +START_FILTER = "if (!(state))" +END_FILTER = "if (!(!state))" +FILTERS = (START_FILTER, END_FILTER) @pytest.mark.parametrize( - ("options", "delay", "forwarders"), + ("options", "delay", "filters"), [ ("", None, ()), (" capture_delay: 2s\n", 2000, ()), - (START, 0, ("TriggerOnTrueForwarder",)), - (END, 0, ("TriggerOnFalseForwarder",)), - (" capture_delay: 500ms\n" + START + END, 500, FORWARDERS), + (START, 0, (START_FILTER,)), + (END, 0, (END_FILTER,)), + (" capture_delay: 500ms\n" + START + END, 500, FILTERS), ], ) def test_capture_codegen( @@ -52,7 +54,7 @@ def test_capture_codegen( tmp_path: Path, options: str, delay: int | None, - forwarders: tuple[str, ...], + filters: tuple[str, ...], ) -> None: path = tmp_path / "test.yaml" path.write_text(BASE + options) @@ -62,9 +64,9 @@ def test_capture_codegen( assert "set_capture_delay" not in main_cpp else: assert f"cam->set_capture_delay({delay});" in main_cpp - for forwarder in FORWARDERS: - expected = int(forwarder in forwarders) - assert main_cpp.count(f"cam->add_on_capture_callback({forwarder}") == expected + assert main_cpp.count("cam->add_on_capture_callback(") == len(filters) + for filter_ in FILTERS: + assert main_cpp.count(filter_) == int(filter_ in filters) assert ("USE_ESP32_CAMERA_CAPTURE" in {d.name for d in CORE.defines}) == ( delay is not None ) diff --git a/tests/component_tests/nextion/test_custom_trigger_args.py b/tests/component_tests/nextion/test_custom_trigger_args.py index 765b7ec786c..c08f0df83d4 100644 --- a/tests/component_tests/nextion/test_custom_trigger_args.py +++ b/tests/component_tests/nextion/test_custom_trigger_args.py @@ -43,5 +43,9 @@ def test_generated_automation_types( ) -> None: main_cpp = generate_main(component_config_path("custom_triggers.yaml")) - assert "Automation" in main_cpp + # A synchronous automation is a direct function that needs no Automation + assert ( + "const std::remove_cvref_t & key, " + "const std::remove_cvref_t & value" in main_cpp + ) assert "Automation" in main_cpp diff --git a/tests/integration/fixtures/automation_merged_actions.yaml b/tests/integration/fixtures/automation_merged_actions.yaml index 966360725a6..c07f38cd87a 100644 --- a/tests/integration/fixtures/automation_merged_actions.yaml +++ b/tests/integration/fixtures/automation_merged_actions.yaml @@ -17,9 +17,42 @@ switch: optimistic: true on_turn_on: - script.stop: stop_by_callback + # Automations with only inline actions, which run directly from the callback + - platform: template + id: direct + optimistic: true + on_state: + - logger.log: + format: "DIRECT state first %d" + args: [x] + - lambda: |- + ESP_LOGI("test", "DIRECT state second %d", x); + on_turn_on: + - logger.log: "DIRECT on first" + - logger.log: "DIRECT on second" + on_turn_off: + - logger.log: "DIRECT off first" + - logger.log: "DIRECT off second" api: actions: + - action: run_direct + then: + - switch.turn_on: direct + - logger.log: "DIRECT done on" + - switch.turn_off: direct + - logger.log: "DIRECT done off" + - action: run_arg_order + then: + - script.execute: + id: arg_order + first: !lambda |- + ESP_LOGI("test", "ARG_ORDER eval first"); + return 1; + second: !lambda |- + ESP_LOGI("test", "ARG_ORDER eval second"); + return 2; + - logger.log: "ARG_ORDER done" - action: run_stop_in_lambda then: - script.execute: stop_in_lambda @@ -95,6 +128,14 @@ api: ESP_LOGI("test", "ARGS fourth %d", value); script: + - id: arg_order + parameters: + first: int + second: int + then: + - logger.log: + format: "ARG_ORDER run %d %d" + args: [first, second] - id: stop_in_lambda then: - logger.log: "STOP_LAMBDA before" diff --git a/tests/integration/test_automation_merged_actions.py b/tests/integration/test_automation_merged_actions.py index 1111fa68fcb..95914d6ca64 100644 --- a/tests/integration/test_automation_merged_actions.py +++ b/tests/integration/test_automation_merged_actions.py @@ -30,6 +30,46 @@ async def test_automation_merged_actions( _, services = await client.list_entities_services() service: dict[str, UserService] = {s.name: s for s in services} + # Automations that run directly from the callback get their arguments, in order, + # and the on and off filters still apply. + # The switch also publishes its state at boot, so only later lines count. + boot_lines = len(waiter.lines) + await client.execute_service(service["run_direct"], {}) + await waiter.wait_for("DIRECT done off", timeout=5.0) + direct = [ + match.group(1) + for line in waiter.lines[boot_lines:] + if (match := re.search(r"DIRECT ((?:state|on|off|done) \w+(?: \d)?)", line)) + ] + assert sorted(direct) == sorted( + [ + "state first 1", + "state second 1", + "on first", + "on second", + "done on", + "state first 0", + "state second 0", + "off first", + "off second", + "done off", + ] + ) + assert direct.index("on first") < direct.index("on second") + assert direct.index("off first") < direct.index("off second") + assert direct.index("state first 1") < direct.index("state second 1") + assert direct.index("done on") < direct.index("off first") + + # script.execute evaluates its arguments left to right + await client.execute_service(service["run_arg_order"], {}) + await waiter.wait_for("ARG_ORDER done", timeout=5.0) + order = [ + match.group(1) + for line in waiter.lines + if (match := re.search(r"ARG_ORDER (eval \w+|run \d \d)", line)) + ] + assert order == ["eval first", "eval second", "run 1 2"] + # A stop during one action keeps the following actions from running, whichever way # it is reached, and execution returns to the caller. for action, prefix in ( diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index b7fd4583946..f7132f1da39 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -19,6 +19,7 @@ from esphome.automation import ( ApplyCall, ApplyCondition, ApplyField, + Automation, CallbackAutomation, DelayAction, ForCondition, @@ -35,6 +36,7 @@ from esphome.automation import ( WaitUntilAction, _shadow_iteration, apply_action_builder, + apply_statements_action, build_action, build_action_list, build_callback_automation, @@ -1755,7 +1757,7 @@ async def test_build_parent_callback_automation(mock_cg: MockCodegen) -> None: @pytest.mark.asyncio async def test_build_callback_automation_lambda(mock_cg: MockCodegen) -> None: - """Reshaping keywords switch the registration to the lambda; forwarder cannot join them.""" + """Reshaping keywords switch the registration to the lambda.""" parent = MockObj("sel", "->") await build_callback_automation( parent, @@ -1769,15 +1771,26 @@ async def test_build_callback_automation_lambda(mock_cg: MockCodegen) -> None: "sel->add_cb([](const std::remove_cvref_t & index) -> void { " f"::{NEW_OBJ}->trigger(::sel->option_at(index), index); }})" ) - with pytest.raises(ValueError, match="forwarder"): - await build_callback_automation( - parent, - "add_cb", - [], - TRIGGER_CONF, - forwarder=TriggerOnTrueForwarder, - when="x", - ) + + +@pytest.mark.asyncio +async def test_build_callback_automation_forwarder_with_filter( + mock_cg: MockCodegen, +) -> None: + """When objects are needed, a forwarder given with a filter is the one registered.""" + await build_callback_automation( + MockObj("parent", "->"), + "add_cb", + [], + TRIGGER_CONF, + forwarder=TriggerOnTrueForwarder, + params=[(cg.bool_, "state")], + forward=[], + when="state", + ) + assert str(mock_cg.add.call_args.args[0]) == ( + f"parent->add_cb(TriggerOnTrueForwarder{{{NEW_OBJ}}})" + ) @pytest.mark.asyncio @@ -2506,3 +2519,194 @@ async def test_build_action_list_nested_builders_emit_normally() -> None: assert "new(act_a) ApplyRunAction();" in text assert "act_if->add_then_action(act_a);" in text assert "act_if->finish_then();" in text + + +def _automation_conf(*then: ConfigType, is_manual: bool = False) -> ConfigType: + return { + CONF_THEN: list(then), + CONF_AUTOMATION_ID: ID( + "auto", is_declaration=True, type=Automation, is_manual=is_manual + ), + } + + +def _delay(id_: str) -> ConfigType: + return { + "delay": 100, + CONF_TYPE_ID: ID(id_, is_declaration=True, type=DelayAction), + } + + +CALLBACK_PARENT = MockObj("parent", "->") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("forwarder", [TriggerOnTrueForwarder, TriggerOnFalseForwarder]) +async def test_callback_automation_core_forwarder_rejects_args( + forwarder: MockObj, +) -> None: + """These forwarders call an Automation<>, so arguments would only fail in the C++ build.""" + conf = _automation_conf() + + with pytest.raises(ValueError, match="no arguments"): + await build_callback_automation( + CALLBACK_PARENT, "add_cb", [(cg.bool_, "x")], conf, forwarder=forwarder + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_callback_automation_all_inline_has_no_objects() -> None: + """A callback runs an all-inline automation directly, with no objects or stop checks.""" + conf = _automation_conf(_action("my.a", "act_a"), _action("my.b", "act_b")) + + await build_callback_automation(CALLBACK_PARENT, "add_cb", [(cg.bool_, "x")], conf) + + main = CORE.cpp_main_section + assert "Automation" not in main + assert "Action" not in main + assert "parent->add_cb([](const std::remove_cvref_t & x) -> void {" in main + assert "esphome__act_a__fn(x);" in main + text = CORE.cpp_global_section + assert "::comp->do_a();" in text + assert "::comp->do_b();" in text + assert "esphome__running" not in text + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +@pytest.mark.parametrize( + ("forwarder", "check"), + [ + (TriggerOnTrueForwarder, "if (!(state))"), + (TriggerOnFalseForwarder, "if (!(!state))"), + ], +) +async def test_callback_automation_core_forwarder_becomes_filter( + forwarder: MockObj, check: str +) -> None: + """The on-true and on-false forwarders turn into the same filter in the lambda.""" + conf = _automation_conf(_action("my.a", "act_a")) + + await build_callback_automation( + CALLBACK_PARENT, "add_cb", [], conf, forwarder=forwarder + ) + + main = CORE.cpp_main_section + assert "Forwarder" not in main + assert "Automation" not in main + assert check in main + assert "esphome__act_a__fn();" in main + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_callback_automation_unknown_forwarder_keeps_objects() -> None: + """A forwarder alone may filter in ways codegen cannot see, so it is kept.""" + forwarder = cg.esphome_ns.class_("MyForwarder") + conf = _automation_conf(_action("my.a", "act_a")) + + await build_callback_automation( + CALLBACK_PARENT, "add_cb", [], conf, forwarder=forwarder + ) + + main = CORE.cpp_main_section + assert "new(auto) Automation<>();" in main + assert "new(act_a) ApplyAction();" in main + assert "parent->add_cb(MyForwarder{auto})" in main + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_callback_automation_manual_id_keeps_objects() -> None: + """A lambda can reach an automation by a manual id, so it needs its objects.""" + conf = _automation_conf(_action("my.a", "act_a"), is_manual=True) + + await build_callback_automation(CALLBACK_PARENT, "add_cb", [], conf) + + main = CORE.cpp_main_section + assert "new(auto) Automation<>();" in main + assert "parent->add_cb(TriggerForwarder<>{auto})" in main + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_callback_automation_with_object_action_keeps_objects() -> None: + """A deferring action needs the chain, so the inline actions stay objects around it.""" + conf = _automation_conf( + _action("my.a", "act_a"), _delay("act_d"), _action("my.b", "act_b") + ) + + await build_callback_automation(CALLBACK_PARENT, "add_cb", [], conf) + + main = CORE.cpp_main_section + assert "new(auto) Automation<>();" in main + assert "auto->add_action(act_a);" in main + assert "auto->add_action(act_d);" in main + assert "auto->add_action(act_b);" in main + assert "parent->add_cb(TriggerForwarder<>{auto})" in main + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_trigger_callback_all_inline_is_direct() -> None: + """Direct callers of build_trigger_callback get the direct call too.""" + conf = _automation_conf(_action("my.a", "act_a"), _action("my.b", "act_b")) + + callback = await build_trigger_callback( + [(cg.int_, "x")], conf, [(cg.int_, "index")], forward=["index + 1"] + ) + + assert "esphome__act_a__fn(index + 1);" in str(callback) + assert "Automation" not in CORE.cpp_main_section + + +@pytest.fixture +def statements_action() -> Generator[None]: + """Register ``my.statements``, a builder that generates its own statements.""" + + @register_action("my.statements", ApplyAction, {}, synchronous=True) + async def statements_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + return apply_statements_action( + action_id, template_arg, args, ["auto esphome__v = x;", "use(esphome__v);"] + ) + + yield + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions", "statements_action") +async def test_apply_statements_action_alone_is_an_apply_action() -> None: + """Alone, it is a plain ApplyAction taking the arguments by const reference.""" + config = [_action("my.statements", "act_s")] + + result = await build_action_list( + config, cg.TemplateArguments(cg.int_), [(cg.int_, "x")] + ) + + assert [str(action) for action in result] == ["act_s"] + assert "new(act_s) ApplyAction();" in CORE.cpp_main_section + text = CORE.cpp_global_section + assert "esphome__act_s__fn(const std::remove_cvref_t & x)" in text + assert "use(esphome__v);" in text + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions", "statements_action") +async def test_apply_statements_action_merges_with_neighbours() -> None: + """Next to another inline action, its statements join the merged function.""" + config = [_action("my.statements", "act_s"), _action("my.a", "act_a")] + + result = await build_action_list( + config, cg.TemplateArguments(cg.int_), [(cg.int_, "x")] + ) + + assert [str(action) for action in result] == ["act_s"] + assert "ApplyRunAction" in CORE.cpp_main_section + text = CORE.cpp_global_section + assert text.index("use(esphome__v);") < text.index("::comp->do_a();") From d47582b0c9a8986878bf3c6b95f738e19e3f638d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:53:33 +1100 Subject: [PATCH 004/170] [core] Generate if, choose, while and repeat as C++ control flow (#21980) Co-authored-by: Claude Opus 5.5 --- .agents/skills/automations/SKILL.md | 3 +- esphome/automation.py | 313 +++++++++-- .../automation/config/condition_groups.yaml | 5 + .../template/config/per_item_lists.yaml | 5 + .../template/test_per_item_lists.py | 6 +- .../fixtures/automation_merged_actions.yaml | 121 +++++ .../test_automation_merged_actions.py | 52 ++ tests/unit_tests/test_automation.py | 501 +++++++++++++++++- 8 files changed, 948 insertions(+), 58 deletions(-) diff --git a/.agents/skills/automations/SKILL.md b/.agents/skills/automations/SKILL.md index c850fffeb21..007d6d06209 100644 --- a/.agents/skills/automations/SKILL.md +++ b/.agents/skills/automations/SKILL.md @@ -208,7 +208,8 @@ run, so prefer `register_apply_action` wherever the action allows it. Because of return fewer objects than the config has entries. A builder that wraps `apply_action_builder` may do other work first, but must return its result unchanged and not use it. A builder that must generate its own statements, and is registered with `automation.ApplyAction`, can call `apply_statements_action` and still merge; `script.execute` -is an example. +is an example. `if`, `choose`, `while` and `repeat` whose bodies are all inline become C++ control flow in the +same function, and `register_apply_condition` and `lambda:` conditions become plain expressions there. ## Conditions diff --git a/esphome/automation.py b/esphome/automation.py index ea355e60c27..46569958c9a 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -264,6 +264,15 @@ class _InlineAction: id_: ID statements: list[str] + # None for control flow, which always needs the stop checks of a merged run + emit: Callable[[], MockObj] | None + + +@dataclass(frozen=True) +class _InlineCondition: + """A condition whose builder only produced an expression, not yet emitted as an object.""" + + expression: str emit: Callable[[], MockObj] @@ -310,18 +319,25 @@ class _CollectedAction(MockObj): _COLLECTED_ACTION = _CollectedAction("esphome__collected_action", "->") _RUNNING_PARAM = "esphome__running" +# One statement of its own, so a function that nothing can stop can leave them all out +_STOP_CHECK = f"if (!{_RUNNING_PARAM})\n return;" + + +def _is_collecting(id_: ID) -> bool: + collector = _INLINE_COLLECTOR.get() + return collector is not None and collector.action_id is id_ def _collect_action( - id_: ID, statements: list[str], emit: Callable[[], MockObj] + id_: ID, statements: list[str], emit: Callable[[], MockObj] | None ) -> bool: - """Record ``id_`` as inline if it is the action being collected. + """Record ``id_`` as inline if it is the action or condition being collected. Matching the id leaves the conditions and child actions a builder creates to emit normally. """ - collector = _INLINE_COLLECTOR.get() - if collector is None or collector.action_id is not id_: + if not _is_collecting(id_): return False + collector = _INLINE_COLLECTOR.get() # Replacing it would silently lose the statements already collected if collector.action is not None: raise EsphomeError(f"The builder of {collector.name} built its action twice") @@ -329,6 +345,71 @@ def _collect_action( return True +def _collect_control_flow(action_id: ID, statements: list[str]) -> MockObj: + _collect_action(action_id, statements, None) + return _COLLECTED_ACTION + + +def _control_flow_builders(action_id: ID) -> tuple[Callable, Callable]: + """How a control flow builder builds its condition and branches. + + Only while it is being collected can the result become statements, so only then are + they left unemitted. + """ + if _is_collecting(action_id): + return _build_condition_or_inline, _build_action_plan + return build_condition, build_action_list + + +def _plans_inline(*plans: list[MockObj | list[_InlineAction]] | None) -> bool: + return all( + not plan or (len(plan) == 1 and isinstance(plan[0], list)) for plan in plans + ) + + +def _run_statements(run: list[_InlineAction]) -> list[str]: + statements: list[str] = [] + for index, action in enumerate(run): + # Any action can stop this automation, and then the rest must not run + if index > 0: + statements.append(_STOP_CHECK) + statements.extend(["{", *action.statements, "}"]) + return statements + + +def _plan_statements(plan: list[MockObj | list[_InlineAction]] | None) -> list[str]: + return _run_statements(plan[0]) if plan else [] + + +def _if_statements( + condition: str, then: list[str], otherwise: list[str] | None +) -> list[str]: + # The condition itself can stop the automation + statements = [ + "{", + f"const bool esphome__condition = {condition};", + _STOP_CHECK, + "if (esphome__condition) {", + *then, + ] + if otherwise is not None: + statements.extend(["} else {", *otherwise]) + return [*statements, "}", "}"] + + +def _condition_expression( + condition: MockObj | _InlineCondition, args: TemplateArgsType +) -> str: + if isinstance(condition, _InlineCondition): + return f"({condition.expression})" + names = ", ".join(name for _, name in args) + return f"{parent_ref(condition)}->check({names})" + + +def _condition_object(condition: MockObj | _InlineCondition) -> MockObj: + return condition.emit() if isinstance(condition, _InlineCondition) else condition + + def flash_string(config: ConfigType, value: str) -> str: """Default renderer for ``std::string`` constants; copies the literal out of flash on ESP8266.""" return str(cg.progmem_string(value)) @@ -910,9 +991,13 @@ def new_lambda_pvariable( Returns: The created Pvariable """ + # Not ``in``: comparing two MockObj values gives a truthy MockObj if ( lambda_expr.capture == "" - and stateless_class is StatelessLambdaAction + and ( + stateless_class is StatelessLambdaAction + or stateless_class is StatelessLambdaCondition + ) and _collect_action( id_obj, [f"{immediate_lambda_call(lambda_expr)};"], @@ -1015,7 +1100,33 @@ async def condition_group_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + # Xor of several stays an object, which evaluates every condition in order; C++ does not + # define the order of the operands of ``+``. + if ( + config + and _is_collecting(condition_id) + and (len(config) == 1 or condition_id.type is not XorCondition) + ): + items = [ + await _build_condition_or_inline(c, template_arg, args) for c in config + ] + + def emit() -> MockObj: + return _new_condition_group( + condition_id, template_arg, [_condition_object(i) for i in items] + ) + + operator = " || " if condition_id.type is OrCondition else " && " + check = operator.join(_condition_expression(item, args) for item in items) + _collect_action(condition_id, [f"return {check};"], emit) + return _COLLECTED_ACTION conditions = await build_condition_list(config, template_arg, args) + return _new_condition_group(condition_id, template_arg, conditions) + + +def _new_condition_group( + condition_id: ID, template_arg: cg.TemplateArguments, conditions: list[MockObj] +) -> MockObj: # A group of one is that condition for and, or and xor; skip the wrapper. if len(conditions) == 1: return conditions[0] @@ -1031,6 +1142,16 @@ async def not_condition_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: + if _is_collecting(condition_id): + item = await _build_condition_or_inline(config, template_arg, args) + _collect_action( + condition_id, + [f"return !{_condition_expression(item, args)};"], + lambda: cg.new_Pvariable( + condition_id, template_arg, _condition_object(item) + ), + ) + return _COLLECTED_ACTION condition = await build_condition(config, template_arg, args) return cg.new_Pvariable(condition_id, template_arg, condition) @@ -1162,14 +1283,32 @@ async def if_action_to_code( # Prepend HasElse bool to template arguments: IfAction if_template_arg = cg.TemplateArguments(has_else, *template_arg) cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION)) - condition = await build_condition(config[cond_conf], template_arg, args) - var = cg.new_Pvariable(action_id, if_template_arg, condition) + build_cond, build_list = _control_flow_builders(action_id) + condition = await build_cond(config[cond_conf], template_arg, args) then_actions = else_actions = None if CONF_THEN in config: - then_actions = await build_action_list(config[CONF_THEN], template_arg, args) + then_actions = await build_list(config[CONF_THEN], template_arg, args) if has_else: - else_actions = await build_action_list(config[CONF_ELSE], template_arg, args) - _add_if_branches(var, then_actions, else_actions) + else_actions = await build_list(config[CONF_ELSE], template_arg, args) + if _is_collecting(action_id) and _plans_inline(then_actions, else_actions): + return _collect_control_flow( + action_id, + _if_statements( + _condition_expression(condition, args), + _plan_statements(then_actions), + _plan_statements(else_actions) if has_else else None, + ), + ) + var = cg.new_Pvariable(action_id, if_template_arg, _condition_object(condition)) + _add_if_branches( + var, + None + if then_actions is None + else _emit_action_plan(then_actions, template_arg, args), + None + if else_actions is None + else _emit_action_plan(else_actions, template_arg, args), + ) return var @@ -1246,21 +1385,44 @@ async def choose_action_to_code( ) -> MockObj: # Built as a chain of if/else actions: each choice's else branch holds the next choice, # and the last choice's else branch holds the `else` actions, if any. + build_cond, build_list = _control_flow_builders(action_id) else_actions: list[MockObj] | None = None if CONF_ELSE in config[-1]: - else_actions = await build_action_list( - config[-1][CONF_ELSE], template_arg, args - ) + else_actions = await build_list(config[-1][CONF_ELSE], template_arg, args) config = config[:-1] + built = [ + ( + await build_cond(choice[CONF_IF], template_arg, args), + await build_list(choice[CONF_THEN], template_arg, args), + ) + for choice in config + ] + # A lambda can reach a choice by a manual id, so those stay objects + if ( + _is_collecting(action_id) + and not any(choice[CONF_CHOICE_ID].is_manual for choice in config) + and _plans_inline(else_actions, *(then for _, then in built)) + ): + statements = None if else_actions is None else _plan_statements(else_actions) + for condition, then_actions in reversed(built): + statements = _if_statements( + _condition_expression(condition, args), + _plan_statements(then_actions), + statements, + ) + return _collect_control_flow(action_id, statements) + if else_actions is not None: + else_actions = _emit_action_plan(else_actions, template_arg, args) var: MockObj | None = None for index in reversed(range(len(config))): choice = config[index] + condition, then_actions = built[index] if_template_arg = cg.TemplateArguments(else_actions is not None, *template_arg) - condition = await build_condition(choice[CONF_IF], template_arg, args) choice_id = action_id if index == 0 else choice[CONF_CHOICE_ID] - var = cg.new_Pvariable(choice_id, if_template_arg, condition) - then_actions = await build_action_list(choice[CONF_THEN], template_arg, args) - _add_if_branches(var, then_actions, else_actions) + var = cg.new_Pvariable(choice_id, if_template_arg, _condition_object(condition)) + _add_if_branches( + var, _emit_action_plan(then_actions, template_arg, args), else_actions + ) else_actions = [var] return var @@ -1282,9 +1444,25 @@ async def while_action_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - condition = await build_condition(config[CONF_CONDITION], template_arg, args) - var = cg.new_Pvariable(action_id, template_arg, condition) - for action in await build_action_list(config[CONF_THEN], template_arg, args): + build_cond, build_list = _control_flow_builders(action_id) + condition = await build_cond(config[CONF_CONDITION], template_arg, args) + actions = await build_list(config[CONF_THEN], template_arg, args) + # An empty body exits the loop at once, unlike an empty C++ loop body + if _is_collecting(action_id) and actions and _plans_inline(actions): + return _collect_control_flow( + action_id, + [ + "while (true) {", + f"const bool esphome__condition = {_condition_expression(condition, args)};", + _STOP_CHECK, + "if (!esphome__condition)\n break;", + *_plan_statements(actions), + _STOP_CHECK, + "}", + ], + ) + var = cg.new_Pvariable(action_id, template_arg, _condition_object(condition)) + for action in _emit_action_plan(actions, template_arg, args): cg.add(var.add_then_action(action)) cg.add(var.finish_then()) return var @@ -1322,15 +1500,38 @@ async def repeat_action_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) count_template = await cg.templatable(config[CONF_COUNT], args, cg.uint32) + shadowed = _shadow_iteration(args) + then_template = cg.TemplateArguments(cg.uint32, *template_arg.args) + then_args = [(cg.uint32, "iteration"), *shadowed] + build_list = _build_action_plan if _is_collecting(action_id) else build_action_list + actions = await build_list(config[CONF_THEN], then_template, then_args) + if _is_collecting(action_id) and actions and _plans_inline(actions): + # The body reads an outer `iteration` under its new name, and the count is read + # again for every iteration, outside the scope of the body's own `iteration`. + aliases = [ + f"const auto &{new} = {old};" + for (_, old), (_, new) in zip(args, shadowed, strict=True) + if old != new + ] + count = immediate_lambda_call(count_template) + loop_condition = f"esphome__iteration < {count}" + return _collect_control_flow( + action_id, + [ + "{", + *aliases, + f"for (uint32_t esphome__iteration = 0; {loop_condition}; esphome__iteration++) {{", + "const uint32_t iteration = esphome__iteration;", + *_plan_statements(actions), + _STOP_CHECK, + "}", + "}", + ], + ) + var = cg.new_Pvariable(action_id, template_arg) cg.add(var.set_count(count_template)) - actions = await build_action_list( - config[CONF_THEN], - cg.TemplateArguments(cg.uint32, *template_arg.args), - [(cg.uint32, "iteration"), *_shadow_iteration(args)], - ) - for action in actions: + for action in _emit_action_plan(actions, then_template, then_args): cg.add(var.add_then_action(action)) cg.add(var.finish_then()) return var @@ -1447,13 +1648,17 @@ async def _build_action_or_inline( action = await build_action(full_config, template_arg, args) finally: _INLINE_COLLECTOR.reset(token) - # Returning anything else would silently drop the collected action from the firmware - if (action is _COLLECTED_ACTION) != (collector.action is not None): + _check_collected(action, collector) + return collector.action if action is _COLLECTED_ACTION else action + + +def _check_collected(result: MockObj, collector: _InlineCollector) -> None: + """Returning anything else would silently drop the collected item from the firmware.""" + if (result is _COLLECTED_ACTION) != (collector.action is not None): raise EsphomeError( f"The builder of {collector.name} must return the result of " "its apply builder unchanged" ) - return collector.action if action is _COLLECTED_ACTION else action def _builder_name(full_config: ConfigType) -> str: @@ -1462,22 +1667,52 @@ def _builder_name(full_config: ConfigType) -> str: return f"'{name}' ({full_config[CONF_TYPE_ID]})" +async def _build_condition_or_inline( + full_config: ConfigType, template_arg: cg.TemplateArguments, args: TemplateArgsType +) -> MockObj | _InlineCondition: + """Condition counterpart of ``_build_action_or_inline``.""" + condition_id = full_config[CONF_TYPE_ID] + if condition_id.is_manual: + return await build_condition(full_config, template_arg, args) + collector = _InlineCollector(condition_id, _builder_name(full_config)) + token = _INLINE_COLLECTOR.set(collector) + try: + condition = await build_condition(full_config, template_arg, args) + finally: + _INLINE_COLLECTOR.reset(token) + _check_collected(condition, collector) + if condition is not _COLLECTED_ACTION: + return condition + collected = collector.action + # ``return ;`` from a builder, or a lambda call with its ``;`` + if ( + len(collected.statements) != 1 + or not collected.statements[0].startswith(("return ", "[")) + or not collected.statements[0].endswith(";") + ): + raise EsphomeError( + f"The condition {collector.name} must generate one " + f"'return ;' statement, not {collected.statements}" + ) + expression = collected.statements[0].removeprefix("return ").removesuffix(";") + return _InlineCondition(expression, collected.emit) + + def _emit_inline_run( run: list[_InlineAction], template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - """Emit one object for adjacent inline actions; a single one is emitted as if never collected.""" - if len(run) == 1: + """Emit one object for adjacent inline actions. + + A single action is emitted as if never collected, unless it is control flow, which has no + other form once collected. + """ + if len(run) == 1 and run[0].emit is not None: return run[0].emit() + statements = _run_statements(run) id_ = run[0].id_.copy() id_.type = ApplyRunAction - statements: list[str] = [] - for index, action in enumerate(run): - # Any action can stop this automation, and then the rest must not run - if index > 0: - statements.append(f"if (!{_RUNNING_PARAM})\n return;") - statements.extend(["{", *action.statements, "}"]) fn = apply_static_function( id_, cg.void, @@ -1676,7 +1911,7 @@ def _direct_function( ) -> MockObj | None: if not _is_direct(plan, config): return None - statements = [line for action in plan[0] for line in ("{", *action.statements, "}")] + statements = [line for line in _run_statements(plan[0]) if line != _STOP_CHECK] return apply_static_function( plan[0][0].id_, cg.void, _apply_lambda_args(args), statements ) diff --git a/tests/component_tests/automation/config/condition_groups.yaml b/tests/component_tests/automation/config/condition_groups.yaml index 9624531862f..9ae5eb4f861 100644 --- a/tests/component_tests/automation/config/condition_groups.yaml +++ b/tests/component_tests/automation/config/condition_groups.yaml @@ -7,6 +7,7 @@ esp32: logger: +# The delays keep these actions and their conditions as objects binary_sensor: - platform: template id: single_and @@ -16,6 +17,7 @@ binary_sensor: - lambda: return true; then: - logger.log: single and + - delay: 1ms - platform: template id: single_or on_press: @@ -24,6 +26,7 @@ binary_sensor: - lambda: return true; then: - logger.log: single or + - delay: 1ms - platform: template id: double_and on_press: @@ -33,6 +36,7 @@ binary_sensor: - lambda: return false; then: - logger.log: double and + - delay: 1ms - platform: template id: single_xor on_press: @@ -42,3 +46,4 @@ binary_sensor: - lambda: return true; then: - logger.log: single xor + - delay: 1ms diff --git a/tests/component_tests/template/config/per_item_lists.yaml b/tests/component_tests/template/config/per_item_lists.yaml index 89f1a80ce2d..e7752356a84 100644 --- a/tests/component_tests/template/config/per_item_lists.yaml +++ b/tests/component_tests/template/config/per_item_lists.yaml @@ -41,6 +41,7 @@ text_sensor: interval: - interval: 10s then: + # The delays keep these actions as objects with lists - if: condition: and: @@ -49,14 +50,18 @@ interval: then: - lambda: id(counter) += 1; - logger.log: even + - delay: 1ms else: - logger.log: odd + - delay: 1ms - repeat: count: 2 then: - logger.log: again + - delay: 1ms - while: condition: lambda: return id(counter) < 3; then: - lambda: id(counter) += 1; + - delay: 1ms diff --git a/tests/component_tests/template/test_per_item_lists.py b/tests/component_tests/template/test_per_item_lists.py index aa6c55dc100..d7b22b1bfb5 100644 --- a/tests/component_tests/template/test_per_item_lists.py +++ b/tests/component_tests/template/test_per_item_lists.py @@ -22,9 +22,9 @@ def test_filters_and_actions_are_added_per_item( assert len(re.findall(r"temp->add_filter\(", main_cpp)) == 3 assert len(re.findall(r"button->add_filter\(", main_cpp)) == 2 assert len(re.findall(r"label->add_filter\(", main_cpp)) == 2 - # Adjacent actions in a branch merge into one object - assert main_cpp.count("->add_then_action(") == 3 - assert main_cpp.count("->add_else_action(") == 1 + # Adjacent actions in a branch merge into one object, so each branch has two + assert main_cpp.count("->add_then_action(") == 6 + assert main_cpp.count("->add_else_action(") == 2 assert main_cpp.count("->finish_then();") == 3 assert main_cpp.count("->finish_else();") == 1 # and/or conditions and the sensor `or` filter take their items as separate arguments diff --git a/tests/integration/fixtures/automation_merged_actions.yaml b/tests/integration/fixtures/automation_merged_actions.yaml index c07f38cd87a..dbc9593e43f 100644 --- a/tests/integration/fixtures/automation_merged_actions.yaml +++ b/tests/integration/fixtures/automation_merged_actions.yaml @@ -10,6 +10,9 @@ globals: - id: running type: int initial_value: "7" + - id: loops + type: int + initial_value: "0" switch: - platform: template @@ -36,6 +39,65 @@ switch: api: actions: + - action: run_control_flow + variables: + value: int + then: + - if: + condition: + and: + - lambda: return value > 0; + - not: + lambda: return value > 100; + then: + - logger.log: + format: "FLOW if then %d" + args: [value] + else: + - logger.log: + format: "FLOW if else %d" + args: [value] + - choose: + - if: + lambda: return value < 10; + then: + - logger.log: + format: "FLOW choose small %d" + args: [value] + - if: + lambda: return value < 100; + then: + - logger.log: + format: "FLOW choose medium %d" + args: [value] + - else: + - logger.log: + format: "FLOW choose large %d" + args: [value] + - repeat: + count: !lambda "return value > 0 ? 2 : 0;" + then: + - repeat: + count: 2 + then: + - lambda: |- + ESP_LOGI("test", "FLOW repeat %u %u %d", (unsigned) iteration_1, (unsigned) iteration, value); + - script.execute: while_counter + - logger.log: + format: "FLOW done %d" + args: [value] + - action: run_stop_in_loops + then: + - script.execute: stop_in_repeat + - script.execute: stop_in_while + - script.execute: stop_in_condition + - logger.log: "LOOP_STOP done" + - action: run_while_reentry + then: + - lambda: id(loops) = 0; + - script.execute: while_reenter + - lambda: |- + ESP_LOGI("test", "WHILE_REENTER running %d", id(while_reenter).is_running()); - action: run_direct then: - switch.turn_on: direct @@ -128,6 +190,65 @@ api: ESP_LOGI("test", "ARGS fourth %d", value); script: + - id: while_reenter + mode: parallel + then: + - while: + condition: + lambda: return id(loops) < 1; + then: + - lambda: id(loops) += 1; + - script.execute: while_reenter + - logger.log: "WHILE_REENTER after nested" + - logger.log: "WHILE_REENTER after while" + - id: while_counter + then: + - lambda: id(loops) = 0; + - while: + condition: + lambda: return id(loops) < 3; + then: + - lambda: id(loops) += 1; + - logger.log: + format: "FLOW while %d" + args: [id(loops)] + - id: stop_in_repeat + then: + - repeat: + count: 3 + then: + - logger.log: + format: "LOOP_STOP repeat %u" + args: [iteration] + - lambda: |- + if (iteration == 1) + id(stop_in_repeat).stop(); + - logger.log: "LOOP_STOP repeat after" + - id: stop_in_while + then: + - lambda: id(loops) = 0; + - while: + condition: + lambda: return id(loops) < 3; + then: + - lambda: id(loops) += 1; + - logger.log: + format: "LOOP_STOP while %d" + args: [id(loops)] + - lambda: |- + if (id(loops) == 2) + id(stop_in_while).stop(); + - logger.log: "LOOP_STOP while after" + - id: stop_in_condition + then: + - if: + condition: + lambda: |- + id(stop_in_condition).stop(); + return true; + then: + - logger.log: "LOOP_STOP condition then" + - logger.log: "LOOP_STOP condition after" - id: arg_order parameters: first: int diff --git a/tests/integration/test_automation_merged_actions.py b/tests/integration/test_automation_merged_actions.py index 95914d6ca64..d9a84cb5880 100644 --- a/tests/integration/test_automation_merged_actions.py +++ b/tests/integration/test_automation_merged_actions.py @@ -30,6 +30,58 @@ async def test_automation_merged_actions( _, services = await client.list_entities_services() service: dict[str, UserService] = {s.name: s for s in services} + # Conditions, branches and loops with inline bodies choose the same paths + for value, branch, choice in ( + (5, "then", "small"), + (50, "then", "medium"), + (500, "else", "large"), + (0, "else", "small"), + ): + start = len(waiter.lines) + await client.execute_service(service["run_control_flow"], {"value": value}) + await waiter.wait_for(f"FLOW done {value}", timeout=5.0) + flow = [ + match.group(1) + for line in waiter.lines[start:] + if (match := re.search(r"FLOW ([a-z ]+[\d ]*\d)", line)) + ] + repeats = [ + f"repeat {outer} {inner} {value}" + for outer in (0, 1) + for inner in (0, 1) + ] + assert flow == [ + f"if {branch} {value}", + f"choose {choice} {value}", + *(repeats if value > 0 else []), + "while 1", + "while 2", + "while 3", + f"done {value}", + ] + + # A stop in a loop body or in a condition ends the script at that point + await client.execute_service(service["run_stop_in_loops"], {}) + await waiter.wait_for("LOOP_STOP done", timeout=5.0) + assert count("LOOP_STOP repeat 0") == 1 + assert count("LOOP_STOP repeat 1") == 1 + assert count("LOOP_STOP repeat 2") == 0 + assert count("LOOP_STOP repeat after") == 0 + assert count("LOOP_STOP while 1") == 1 + assert count("LOOP_STOP while 2") == 1 + assert count("LOOP_STOP while 3") == 0 + assert count("LOOP_STOP while after") == 0 + assert count("LOOP_STOP condition then") == 0 + assert count("LOOP_STOP condition after") == 0 + + # With an inline body, a nested run whose while condition is false does not end the + # outer run. WhileAction, still used for a deferring body, stops its body there. + await client.execute_service(service["run_while_reentry"], {}) + await waiter.wait_for("WHILE_REENTER running", timeout=5.0) + assert count("WHILE_REENTER after nested") == 1 + assert count("WHILE_REENTER after while") == 2 + assert count("WHILE_REENTER running 0") == 1 + # Automations that run directly from the callback get their arguments, in order, # and the on and off filters still apply. # The switch also publishes its state at boot, so only later lines count. diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f7132f1da39..f72f4876a80 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -14,6 +14,8 @@ from esphome.automation import ( CONF_CHOOSE, IN_RANGE_CHECK, IN_RANGE_DEFAULTS, + Action, + AndCondition, ApplyAction, ApplyBytes, ApplyCall, @@ -27,6 +29,7 @@ from esphome.automation import ( LambdaAction, LambdaCondition, NotCondition, + OrCondition, RepeatAction, ResumeComponentAction, StatelessLambdaCondition, @@ -34,6 +37,8 @@ from esphome.automation import ( TriggerOnFalseForwarder, TriggerOnTrueForwarder, WaitUntilAction, + WhileAction, + XorCondition, _shadow_iteration, apply_action_builder, apply_statements_action, @@ -58,6 +63,7 @@ from esphome.automation import ( register_apply_condition, register_bare_action, register_bare_condition, + register_condition, register_parented_action, register_parented_condition, register_simple_action, @@ -2401,6 +2407,25 @@ async def test_build_action_list_keeps_wrapper_side_effects() -> None: assert "::comp->do_wrapped();" in CORE.cpp_global_section +def _inline_condition(id_: str = "cond") -> ConfigType: + return { + "lambda": Lambda("return true;"), + CONF_TYPE_ID: ID( + id_, is_declaration=True, type=LambdaCondition, is_manual=False + ), + } + + +def _if_action(*then: ConfigType, condition: ConfigType | None = None) -> ConfigType: + return { + "if": { + CONF_CONDITION: condition or _inline_condition(), + CONF_THEN: list(then), + }, + CONF_TYPE_ID: ID("act_if", is_declaration=True, type=IfAction, is_manual=False), + } + + @pytest.mark.asyncio @pytest.mark.usefixtures("apply_actions") async def test_build_action_list_rejects_a_dropped_collected_action() -> None: @@ -2495,32 +2520,478 @@ def test_collected_result_leaves_python_protocols_alone() -> None: @pytest.mark.asyncio @pytest.mark.usefixtures("apply_actions") -async def test_build_action_list_nested_builders_emit_normally() -> None: - """An ``if`` keeps its object and condition, and merges inside its own branch.""" - condition = { - "lambda": Lambda("return true;"), - CONF_TYPE_ID: ID("cond", is_declaration=True, type=LambdaCondition), - } - if_action = { - "if": { - CONF_CONDITION: condition, - CONF_THEN: [_action("my.a", "act_a"), _action("my.b", "act_b")], - }, - CONF_TYPE_ID: ID("act_if", is_declaration=True, type=IfAction), - } +async def test_build_action_list_inline_if_is_control_flow() -> None: + """An all-inline ``if`` merges with its neighbours and checks for a stop after the condition.""" + config = [ + _action("my.c", "act_c"), + _if_action(_action("my.a", "act_a"), _action("my.b", "act_b")), + ] + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert [str(action) for action in result] == ["act_c"] + main = CORE.cpp_main_section + assert "new(act_c) ApplyRunAction();" in main + assert "IfAction" not in main + assert "Condition" not in main + text = CORE.cpp_global_section + assert "const bool esphome__condition = ([]() -> bool {" in text + # Between the two outer actions, after the condition and between the two branch actions + assert text.count("if (!esphome__running)") == 3 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_single_inline_if_has_stop_checks() -> None: + """Alone, control flow still needs a run object, because its condition can stop it.""" result = await build_action_list( - [if_action, _action("my.c", "act_c")], TEMPLATE_ARG, [] + [_if_action(_action("my.a", "act_a"))], TEMPLATE_ARG, [] ) - assert [str(action) for action in result] == ["act_if", "act_c"] + assert [str(action) for action in result] == ["act_if"] + assert "new(act_if) ApplyRunAction();" in ( + CORE.cpp_main_section + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_build_action_list_if_with_object_branch_keeps_objects() -> None: + """An ``if`` with a deferring action keeps its object and condition.""" + delay = { + "delay": 100, + CONF_TYPE_ID: ID("act_d", is_declaration=True, type=DelayAction), + } + config = [_if_action(_action("my.a", "act_a"), _action("my.b", "act_b"), delay)] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert [str(action) for action in result] == ["act_if"] text = CORE.cpp_main_section assert "new(cond) StatelessLambdaCondition<>(" in text assert "new(act_a) ApplyRunAction();" in text assert "act_if->add_then_action(act_a);" in text + assert "act_if->add_then_action(act_d);" in text assert "act_if->finish_then();" in text +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +@pytest.mark.parametrize( + ("key", "type_", "expected"), + [ + ("and", AndCondition, ") && ("), + ("or", OrCondition, ") || ("), + ], + ids=["and", "or"], +) +async def test_inline_condition_group_operators( + key: str, type_: cg.MockObjClass, expected: str +) -> None: + """Each condition group becomes its operator.""" + group = { + key: [_inline_condition("c_1"), _inline_condition("c_2")], + CONF_TYPE_ID: ID("group", is_declaration=True, type=type_, is_manual=False), + } + config = [_if_action(_action("my.a", "act_a"), condition=group)] + + await build_action_list(config, TEMPLATE_ARG, []) + + assert expected in _squash(CORE.cpp_global_section) + assert "Condition" not in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_xor_of_several_stays_an_object() -> None: + """C++ does not define the order of ``+`` operands, and xor evaluates in order.""" + group = { + "xor": [_inline_condition("c_1"), _inline_condition("c_2")], + CONF_TYPE_ID: ID( + "group", is_declaration=True, type=XorCondition, is_manual=False + ), + } + + await build_action_list( + [_if_action(_action("my.a", "act_a"), condition=group)], TEMPLATE_ARG, [] + ) + + assert "new(group) XorCondition<2>(c_1, c_2);" in CORE.cpp_main_section + assert "::group->check()" in CORE.cpp_global_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_xor_of_one_is_that_condition() -> None: + group = { + "xor": [_inline_condition("c_1")], + CONF_TYPE_ID: ID( + "group", is_declaration=True, type=XorCondition, is_manual=False + ), + } + + await build_action_list( + [_if_action(_action("my.a", "act_a"), condition=group)], TEMPLATE_ARG, [] + ) + + assert "Condition" not in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_new_lambda_pvariable_keeps_other_stateless_classes() -> None: + """A builder using its own stateless class keeps that object, and ends the run.""" + my_action = cg.esphome_ns.class_("MyStatelessAction", Action) + + @register_action("my.stateless", my_action, {}, synchronous=True) + async def stateless_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + lambda_ = await cg.process_lambda(Lambda("do_it();"), args, return_type=cg.void) + return new_lambda_pvariable(action_id, lambda_, my_action, template_arg) + + config = [ + { + "my.stateless": {}, + CONF_TYPE_ID: ID( + "act_m", is_declaration=True, type=my_action, is_manual=False + ), + } + ] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert [str(action) for action in result] == ["act_m"] + assert "new(act_m) MyStatelessAction<>(" in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_condition_builder_must_return_collected_condition() -> None: + """A condition builder that drops what it collected fails during code generation.""" + builder_schema = {cv.Required(CONF_ID): cv.use_id(cg.Component)} + conditions = Registry() + with patch("esphome.automation.CONDITION_REGISTRY", conditions): + register_apply_condition("my.real", builder_schema, "is_ready()") + real = conditions["my.real"].fun + + @register_condition("my.drops", ApplyCondition, builder_schema) + async def drops_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + await real(config, condition_id, template_arg, args) + return MockObj("something_else", "->") + + condition = { + "my.drops": {CONF_ID: COMPONENT_ID}, + CONF_TYPE_ID: ID( + "cond", is_declaration=True, type=ApplyCondition, is_manual=False + ), + } + + with pytest.raises( + EsphomeError, match=r"'my\.drops' \(cond\) must return the result" + ): + await build_action_list( + [_if_action(_action("my.a", "act_a"), condition=condition)], + TEMPLATE_ARG, + [], + ) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +@pytest.mark.parametrize( + "statements", + [ + ["first();", "return second();"], + ["first(); return second();"], + ["ok = check();"], + ["return check()"], + [], + ], + ids=["two_entries", "two_in_one", "not_a_return", "no_semicolon", "empty"], +) +async def test_condition_must_generate_one_return_statement( + statements: list[str], +) -> None: + """Anything else cannot become an expression, and could still compile as one.""" + conditions = Registry() + with patch("esphome.automation.CONDITION_REGISTRY", conditions): + + @register_condition("my.bad", ApplyCondition, {}) + async def bad_to_code( + config: ConfigType, + condition_id: ID, + template_arg: cg.TemplateArguments, + args: list[tuple[object, str]], + ) -> MockObj: + return apply_statements_action(condition_id, template_arg, args, statements) + + condition = { + "my.bad": {}, + CONF_TYPE_ID: ID( + "cond", is_declaration=True, type=ApplyCondition, is_manual=False + ), + } + + with pytest.raises( + EsphomeError, match=r"'my\.bad' \(cond\) must generate one 'return" + ): + await build_action_list( + [_if_action(_action("my.a", "act_a"), condition=condition)], + TEMPLATE_ARG, + [], + ) + + +@pytest.mark.asyncio +async def test_condition_group_outside_control_flow_is_an_object() -> None: + """A group built on its own, as ``wait_until`` does, stays an object with its children.""" + CORE.config = {} + group = { + "and": [_inline_condition("c_1"), _inline_condition("c_2")], + CONF_TYPE_ID: ID( + "group", is_declaration=True, type=AndCondition, is_manual=False + ), + } + + result = await build_condition(group, TEMPLATE_ARG, []) + + assert str(result) == "group" + assert "new(group) AndCondition<2>(c_1, c_2);" in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_empty_condition_group_is_called_through_its_object() -> None: + """An empty group has no expression to join, so ``if`` calls its object.""" + group = { + "or": [], + CONF_TYPE_ID: ID( + "group", is_declaration=True, type=OrCondition, is_manual=False + ), + } + + await build_action_list( + [_if_action(_action("my.a", "act_a"), condition=group)], TEMPLATE_ARG, [] + ) + + assert "new(group) OrCondition<0>();" in CORE.cpp_main_section + assert "::group->check()" in CORE.cpp_global_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_inline_not_condition() -> None: + """``not`` negates its child without an object.""" + negated = { + "not": _inline_condition("inner"), + CONF_TYPE_ID: ID( + "neg", is_declaration=True, type=NotCondition, is_manual=False + ), + } + config = [_if_action(_action("my.a", "act_a"), condition=negated)] + + await build_action_list(config, TEMPLATE_ARG, []) + + assert "(!([]() -> bool {" in CORE.cpp_global_section + assert "new(neg)" not in CORE.cpp_main_section + assert "new(inner)" not in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_not_condition_is_an_object_when_its_if_keeps_one() -> None: + """A deferring action in the branch makes the collected ``not`` and its child emit objects.""" + negated = { + "not": _inline_condition("inner"), + CONF_TYPE_ID: ID( + "neg", is_declaration=True, type=NotCondition, is_manual=False + ), + } + config = [_if_action(_delay("act_d"), condition=negated)] + + await build_action_list(config, TEMPLATE_ARG, []) + + main = CORE.cpp_main_section + assert "new(inner) StatelessLambdaCondition<>(" in main + assert "new(neg) NotCondition<>(inner);" in main + assert "new(act_if) IfAction(neg);" in main + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_inline_while_checks_condition_and_stops() -> None: + config = [ + { + "while": { + CONF_CONDITION: _inline_condition(), + CONF_THEN: [_action("my.a", "act_a")], + }, + CONF_TYPE_ID: ID( + "act_w", is_declaration=True, type=WhileAction, is_manual=False + ), + } + ] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert [str(action) for action in result] == ["act_w"] + assert "WhileAction" not in CORE.cpp_main_section + text = CORE.cpp_global_section + assert "while (true) {" in text + assert "if (!esphome__condition)\n" in text + assert text.count("if (!esphome__running)") == 2 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_inline_while_with_empty_body_keeps_its_object() -> None: + """An empty body exits a WhileAction at once, which a C++ loop would not do.""" + config = [ + { + "while": {CONF_CONDITION: _inline_condition(), CONF_THEN: []}, + CONF_TYPE_ID: ID( + "act_w", is_declaration=True, type=WhileAction, is_manual=False + ), + } + ] + + await build_action_list(config, TEMPLATE_ARG, []) + + assert "new(act_w) WhileAction<>(" in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_inline_repeat_rereads_count_and_aliases_outer_iteration() -> None: + """The count is in the loop condition, and an outer iteration keeps its value.""" + config = [ + { + "repeat": { + CONF_COUNT: 3, + CONF_THEN: [_lambda_action("act_l", "use(iteration_1);")], + }, + CONF_TYPE_ID: ID( + "act_r", is_declaration=True, type=RepeatAction, is_manual=False + ), + } + ] + args = [(cg.uint32, "iteration")] + + result = await build_action_list(config, cg.TemplateArguments(cg.uint32), args) + + assert [str(action) for action in result] == ["act_r"] + assert "RepeatAction" not in CORE.cpp_main_section + text = CORE.cpp_global_section + assert "const auto &iteration_1 = iteration;" in text + assert "esphome__iteration < [](uint32_t iteration) -> uint32_t {" in text + assert "const uint32_t iteration = esphome__iteration;" in text + assert text.index("iteration_1 = iteration") < text.index("for (uint32_t") + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_inline_if_with_manual_condition_id_calls_the_object() -> None: + """A lambda can reach a condition by a manual id, so it keeps its object.""" + condition = { + "lambda": Lambda("return true;"), + CONF_TYPE_ID: ID( + "my_cond", is_declaration=True, type=LambdaCondition, is_manual=True + ), + } + + await build_action_list( + [_if_action(_action("my.a", "act_a"), condition=condition)], TEMPLATE_ARG, [] + ) + + assert "new(my_cond) StatelessLambdaCondition<>(" in CORE.cpp_main_section + assert "::my_cond->check()" in CORE.cpp_global_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_callback_automation_inline_if_drops_stop_checks() -> None: + """Nothing can stop a direct automation, so its control flow has no stop checks.""" + conf = _automation_conf( + _if_action(_action("my.a", "act_a"), _action("my.b", "act_b")) + ) + + await build_callback_automation(CALLBACK_PARENT, "add_cb", [], conf) + + assert "Action" not in CORE.cpp_main_section + text = CORE.cpp_global_section + assert "if (esphome__condition) {" in text + assert "esphome__running" not in text + + +def _choose_action(*choices: ConfigType) -> ConfigType: + return { + CONF_CHOOSE: list(choices), + CONF_TYPE_ID: ID("act_ch", is_declaration=True, type=IfAction, is_manual=False), + } + + +def _choice(id_: str, *then: ConfigType, is_manual: bool = False) -> ConfigType: + return { + "if": _inline_condition(f"cond_{id_}"), + CONF_THEN: list(then), + CONF_CHOICE_ID: ID( + id_, is_declaration=True, type=IfAction, is_manual=is_manual + ), + } + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_inline_choose_is_nested_if_else() -> None: + config = [ + _choose_action( + _choice("ch_a", _action("my.a", "act_a")), + _choice("ch_b", _action("my.b", "act_b")), + { + CONF_ELSE: [_action("my.c", "act_c")], + CONF_CHOICE_ID: ID("ch_c", is_declaration=True, is_manual=False), + }, + ) + ] + + result = await build_action_list(config, TEMPLATE_ARG, []) + + assert [str(action) for action in result] == ["act_ch"] + assert "IfAction" not in CORE.cpp_main_section + text = CORE.cpp_global_section + assert text.index("::comp->do_a();") < text.index("} else {") + assert text.index("::comp->do_a();") < text.index("::comp->do_b();") + assert text.index("::comp->do_b();") < text.index("::comp->do_c();") + assert text.count("} else {") == 2 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("apply_actions") +async def test_choose_manual_choice_id_keeps_objects() -> None: + """A lambda can reach a choice by a manual id, so the chain stays objects.""" + config = [ + _choose_action( + _choice("ch_a", _action("my.a", "act_a")), + _choice("ch_b", _action("my.b", "act_b"), is_manual=True), + ) + ] + + await build_action_list(config, TEMPLATE_ARG, []) + + main = CORE.cpp_main_section + assert "new(ch_b) IfAction(" in main + assert "new(act_ch) IfAction(" in main + + def _automation_conf(*then: ConfigType, is_manual: bool = False) -> ConfigType: return { CONF_THEN: list(then), From 7995cecca9c141cf3d2813b53a25ce56e52b231f Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 31 Oct 2026 06:09:37 +0100 Subject: [PATCH 005/170] [ld6004] Add select platform (#19844) --- esphome/components/ld6004/ld6004.cpp | 38 +++--- esphome/components/ld6004/ld6004.h | 2 +- esphome/components/ld6004/select/__init__.py | 59 ++++++++ esphome/components/ld600x/entities.py | 6 +- esphome/components/ld600x/ld600x.cpp | 46 +++---- esphome/components/ld600x/ld600x.h | 3 +- esphome/components/ld600x/ld600x_frame.cpp | 2 +- esphome/components/ld600x/ld600x_frame.h | 2 + .../ld6004/config/area_id_select.yaml | 7 + .../dwell_area_sensor_and_area_id_select.yaml | 13 ++ .../ld6004/config/sensitivity_select.yaml | 7 + .../component_tests/ld6004/test_area_kinds.py | 3 + .../component_tests/ld6004/test_platforms.py | 17 +++ .../ld6004/test_select_options.py | 43 ++++++ tests/components/ld6002b/common.h | 6 +- .../ld6002b/select/ld6002b_select_test.cpp | 9 ++ tests/components/ld6004/common.h | 31 +++++ tests/components/ld6004/common.yaml | 16 +++ .../ld6004/select/ld6004_select_test.cpp | 127 ++++++++++++++++++ .../text_sensor/ld6004_text_sensor_test.cpp | 18 +-- 20 files changed, 386 insertions(+), 69 deletions(-) create mode 100644 esphome/components/ld6004/select/__init__.py create mode 100644 tests/component_tests/ld6004/config/area_id_select.yaml create mode 100644 tests/component_tests/ld6004/config/dwell_area_sensor_and_area_id_select.yaml create mode 100644 tests/component_tests/ld6004/config/sensitivity_select.yaml create mode 100644 tests/component_tests/ld6004/test_select_options.py create mode 100644 tests/components/ld6004/select/ld6004_select_test.cpp diff --git a/esphome/components/ld6004/ld6004.cpp b/esphome/components/ld6004/ld6004.cpp index 4123479a272..8ced3434e10 100644 --- a/esphome/components/ld6004/ld6004.cpp +++ b/esphome/components/ld6004/ld6004.cpp @@ -56,12 +56,20 @@ PROGMEM_STRING_TABLE(WorkModeStrings, "normal", "low_power", "radar_off_p20_high static_assert(WorkModeStrings::COUNT == WORK_MODE_COUNT); #endif +// The mode reports carry one byte or a little-endian uint32. Any other length returns UINT32_MAX, which the +// callers' range checks reject. +static uint32_t read_mode(const uint8_t *data, uint16_t len) { + if (len == 1) + return data[0]; + if (len == 4) + return encode_value_le(data); + return UINT32_MAX; +} + bool LD6004Component::handle_model_report(uint16_t type, const uint8_t *data, uint16_t len) { switch (type) { case TYPE_REPORT_WORK_MODE: { - if (len != 1 && len != 4) - break; - const uint32_t mode = len == 4 ? encode_value_le(data) : data[0]; + const uint32_t mode = read_mode(data, len); if (mode >= WORK_MODE_COUNT) break; this->radar_off_ = mode == WORK_MODE_RADAR_OFF_P20_HIGH || mode == WORK_MODE_RADAR_OFF_P20_LOW; @@ -88,13 +96,15 @@ bool LD6004Component::handle_model_report(uint16_t type, const uint8_t *data, ui if (len >= 1 && data[0] == 0 && !this->target_presence_any_) this->clear_area_presence_(); return true; - case TYPE_REPORT_P20_MODE: - if (len != 1 || data[0] >= P20_MODE_COUNT) + case TYPE_REPORT_P20_MODE: { + const uint32_t mode = read_mode(data, len); + if (mode >= P20_MODE_COUNT) break; #ifdef USE_SELECT - PUBLISH_STATE(this->p20_mode_select_, data[0]); + PUBLISH_STATE(this->p20_mode_select_, mode); #endif return true; + } case TYPE_REPORT_DWELL_LIFETIME: if (len != 4) break; @@ -136,16 +146,12 @@ void LD6004Component::set_number_value(number::Number *entity, float value) { #endif // USE_NUMBER #ifdef USE_SELECT -void LD6004Component::set_select_value(select::Select *entity, size_t index) { - if (entity == this->work_mode_select_) { - if (index < WORK_MODE_COUNT) - this->send_control_command_(progmem_read_byte(&WORK_MODE_COMMANDS[index])); - } else if (entity == this->p20_mode_select_) { - if (index < P20_MODE_COUNT) - this->send_control_command_(progmem_read_byte(&P20_MODE_COMMANDS[index])); - } else { - this->LD600XComponent::set_select_value(entity, index); - } +bool LD6004Component::set_select_value(select::Select *entity, size_t index) { + if (entity == this->work_mode_select_) + return index < WORK_MODE_COUNT && this->send_control_command_(progmem_read_byte(&WORK_MODE_COMMANDS[index])); + if (entity == this->p20_mode_select_) + return index < P20_MODE_COUNT && this->send_control_command_(progmem_read_byte(&P20_MODE_COMMANDS[index])); + return this->LD600XComponent::set_select_value(entity, index); } #endif // USE_SELECT diff --git a/esphome/components/ld6004/ld6004.h b/esphome/components/ld6004/ld6004.h index fa08a3387a5..49e8f7527f8 100644 --- a/esphome/components/ld6004/ld6004.h +++ b/esphome/components/ld6004/ld6004.h @@ -25,7 +25,7 @@ class LD6004Component final : public ld600x::LD600XComponent { void set_number_value(number::Number *entity, float value) override; #endif #ifdef USE_SELECT - void set_select_value(select::Select *entity, size_t index) override; + bool set_select_value(select::Select *entity, size_t index) override; #endif #ifdef USE_BUTTON void press_button(uint8_t kind) override; diff --git a/esphome/components/ld6004/select/__init__.py b/esphome/components/ld6004/select/__init__.py new file mode 100644 index 00000000000..337e4a28ea8 --- /dev/null +++ b/esphome/components/ld6004/select/__init__.py @@ -0,0 +1,59 @@ +from esphome.components import select +from esphome.components.ld600x import entities +from esphome.components.ld600x.const import CONF_WORK_MODE +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import LD6004Component +from ..const import AREA_KINDS, CONF_LD6004_ID + +DEPENDENCIES = ["ld6004"] + +CONF_P20_MODE = "p20_mode" + +# The order is the wire contract: index i sends entry i of WORK_MODE_COMMANDS / P20_MODE_COMMANDS in ld6004.cpp. +# tests/component_tests/ld6004/test_select_options.py pins this order. The C++ test in tests/components/ld6004/select +# keeps its own copy of the names to pin the command each index sends, so it cannot see a reorder here. +# The work_mode names are written again in WorkModeStrings in ld6004.cpp, for the work mode text sensor. +WORK_MODE_OPTIONS = ( + "normal", + "low_power", + "radar_off_p20_high", + "radar_off_p20_low", + "high_reflectivity", +) +P20_MODE_OPTIONS = ( + "presence_high", + "presence_low", + "constant_low", + "constant_high", + "pulse_low", + "pulse_high", +) + +CONFIG_SCHEMA = entities.select_schema( + LD6004Component, + CONF_LD6004_ID, + extra={ + cv.Optional(key): select.select_schema( + select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG + ) + for key in (CONF_WORK_MODE, CONF_P20_MODE) + }, +) +FINAL_VALIDATE_SCHEMA = entities.unique_settings_final_validate( + select.DOMAIN, CONF_LD6004_ID, "ld6004" +) + + +async def to_code(config: ConfigType) -> None: + selects = await entities.select_to_code( + config, CONF_LD6004_ID, area_kinds=AREA_KINDS + ) + hub = selects.parent + for key, setter, options in ( + (CONF_WORK_MODE, hub.set_work_mode_select, WORK_MODE_OPTIONS), + (CONF_P20_MODE, hub.set_p20_mode_select, P20_MODE_OPTIONS), + ): + await selects(key, setter, options=list(options), **entities.SELECT_CONTROL) diff --git a/esphome/components/ld600x/entities.py b/esphome/components/ld600x/entities.py index c2999b75131..cd5fb9b3eb1 100644 --- a/esphome/components/ld600x/entities.py +++ b/esphome/components/ld600x/entities.py @@ -393,7 +393,8 @@ def select_schema( SELECT_CONTROL: dict[str, str] = { "apply": "set_select_value(entity, index)", - "publish": "before", + # A full command queue rejects the write, and the select then keeps its old option. + "publish": "on_success", } @@ -427,6 +428,9 @@ async def select_to_code( options=area_id_options(area_kinds), **SELECT_CONTROL, ) + if CONF_AREA_ID in config: + # The area_id select can pick an area of any kind, so the hub sizes them all. + request_model_sizes(hub, area_kinds=len(area_kinds)) return selects diff --git a/esphome/components/ld600x/ld600x.cpp b/esphome/components/ld600x/ld600x.cpp index 72f20590458..f1394bcd5e3 100644 --- a/esphome/components/ld600x/ld600x.cpp +++ b/esphome/components/ld600x/ld600x.cpp @@ -63,6 +63,10 @@ static constexpr uint32_t CMD_GET_TRIGGER = 0x11; static constexpr uint32_t CMD_GET_Z_RANGE = 0x12; static constexpr uint32_t CMD_INSTALL_TOP = 0x13; static constexpr uint32_t CMD_INSTALL_SIDE = 0x14; +// set_select_value() sends the first command of each run plus the option index. +static_assert(CMD_SENSITIVITY_MEDIUM == CMD_SENSITIVITY_LOW + 1 && CMD_SENSITIVITY_HIGH == CMD_SENSITIVITY_LOW + 2); +static_assert(CMD_TRIGGER_MEDIUM == CMD_TRIGGER_SLOW + 1 && CMD_TRIGGER_FAST == CMD_TRIGGER_SLOW + 2); +static_assert(CMD_INSTALL_SIDE == CMD_INSTALL_TOP + 1); static constexpr uint32_t CMD_GET_INSTALLATION = 0x15; // Both models answer 0x16 to 0x18 with report 0x0A12; the opcodes stay here for the verbose logs. static constexpr uint32_t CMD_LOW_POWER_ON = 0x16; @@ -1051,35 +1055,19 @@ void LD600XComponent::set_number_value(number::Number *entity, float value) { #endif #ifdef USE_SELECT -void LD600XComponent::set_select_value(select::Select *entity, size_t index) { - if (entity == this->sensitivity_select_) { - if (index == 0) { - this->send_control_command_(CMD_SENSITIVITY_LOW); - } else if (index == 1) { - this->send_control_command_(CMD_SENSITIVITY_MEDIUM); - } else if (index == 2) { - this->send_control_command_(CMD_SENSITIVITY_HIGH); - } - } else if (entity == this->trigger_speed_select_) { - if (index == 0) { - this->send_control_command_(CMD_TRIGGER_SLOW); - } else if (index == 1) { - this->send_control_command_(CMD_TRIGGER_MEDIUM); - } else if (index == 2) { - this->send_control_command_(CMD_TRIGGER_FAST); - } - } else if (entity == this->installation_select_) { - if (index == 0) { - this->send_control_command_(CMD_INSTALL_TOP); - } else if (index == 1) { - this->send_control_command_(CMD_INSTALL_SIDE); - } - } else { // Only registered entities call this, so the last one needs no compare - this->area_id_ = static_cast(index); - this->area_id_set_ = true; - this->update_area_numbers_for_id_(this->area_id_); - this->save_area_id_pref_(this->area_id_); - } +bool LD600XComponent::set_select_value(select::Select *entity, size_t index) { + if (entity == this->sensitivity_select_) + return index < 3 && this->send_control_command_(CMD_SENSITIVITY_LOW + index); + if (entity == this->trigger_speed_select_) + return index < 3 && this->send_control_command_(CMD_TRIGGER_SLOW + index); + if (entity == this->installation_select_) + return index < 2 && this->send_control_command_(CMD_INSTALL_TOP + index); + // Only registered entities call this, so the last one needs no compare + this->area_id_ = static_cast(index); + this->area_id_set_ = true; + this->update_area_numbers_for_id_(this->area_id_); + this->save_area_id_pref_(this->area_id_); + return true; } #endif diff --git a/esphome/components/ld600x/ld600x.h b/esphome/components/ld600x/ld600x.h index 7968c24c658..a2ee4c4f996 100644 --- a/esphome/components/ld600x/ld600x.h +++ b/esphome/components/ld600x/ld600x.h @@ -198,7 +198,8 @@ class LD600XComponent : public Component, public uart::UARTDevice { virtual void set_number_value(number::Number *entity, float value); #endif #ifdef USE_SELECT - virtual void set_select_value(select::Select *entity, size_t index); + // Returns false when the command was not queued, so the select keeps its old option. + virtual bool set_select_value(select::Select *entity, size_t index); #endif #ifdef USE_SWITCH // Returns false when the command was not queued, so the switch keeps its old state. diff --git a/esphome/components/ld600x/ld600x_frame.cpp b/esphome/components/ld600x/ld600x_frame.cpp index 11d28ee4ec0..bf619abb8e1 100644 --- a/esphome/components/ld600x/ld600x_frame.cpp +++ b/esphome/components/ld600x/ld600x_frame.cpp @@ -92,7 +92,7 @@ size_t encode_frame(uint16_t frame_id, uint16_t type, const uint8_t *data, uint1 out[6] = type & 0xFF; // TinyFrame checksums are the inverted XOR of the bytes they cover, SOF included for the header. out[TF_HEADER_LEN + 1] = static_cast(~xor_bytes(out, TF_HEADER_LEN + 1)); - size_t pos = TF_HEADER_LEN + 2; + size_t pos = TF_PAYLOAD_OFFSET; if (len > 0 && data != nullptr) { std::memcpy(out + pos, data, len); pos += len; diff --git a/esphome/components/ld600x/ld600x_frame.h b/esphome/components/ld600x/ld600x_frame.h index 0d35f7a443a..185113bb461 100644 --- a/esphome/components/ld600x/ld600x_frame.h +++ b/esphome/components/ld600x/ld600x_frame.h @@ -12,6 +12,8 @@ static constexpr uint8_t TF_SOF = 0x01; static constexpr uint8_t TF_HEADER_LEN = 6; // SOF, header, header checksum and data checksum around a payload. static constexpr uint8_t TF_FRAME_OVERHEAD = TF_HEADER_LEN + 3; +// SOF, header and header checksum come before the payload. +static constexpr uint8_t TF_PAYLOAD_OFFSET = TF_HEADER_LEN + 2; inline float read_f32_le(const uint8_t *data) { return bit_cast(encode_value_le(data)); } diff --git a/tests/component_tests/ld6004/config/area_id_select.yaml b/tests/component_tests/ld6004/config/area_id_select.yaml new file mode 100644 index 00000000000..5c24c7d6e67 --- /dev/null +++ b/tests/component_tests/ld6004/config/area_id_select.yaml @@ -0,0 +1,7 @@ +packages: + base: !include base.yaml + +select: + - platform: ld6004 + area_id: + name: Area ID diff --git a/tests/component_tests/ld6004/config/dwell_area_sensor_and_area_id_select.yaml b/tests/component_tests/ld6004/config/dwell_area_sensor_and_area_id_select.yaml new file mode 100644 index 00000000000..e9a6f21eb51 --- /dev/null +++ b/tests/component_tests/ld6004/config/dwell_area_sensor_and_area_id_select.yaml @@ -0,0 +1,13 @@ +packages: + base: !include base.yaml + +sensor: + - platform: ld6004 + dwell_area_1: + x_min: + name: Dwell X Min + +select: + - platform: ld6004 + area_id: + name: Area ID diff --git a/tests/component_tests/ld6004/config/sensitivity_select.yaml b/tests/component_tests/ld6004/config/sensitivity_select.yaml new file mode 100644 index 00000000000..1f330fa73b7 --- /dev/null +++ b/tests/component_tests/ld6004/config/sensitivity_select.yaml @@ -0,0 +1,7 @@ +packages: + base: !include base.yaml + +select: + - platform: ld6004 + sensitivity: + name: Sensitivity diff --git a/tests/component_tests/ld6004/test_area_kinds.py b/tests/component_tests/ld6004/test_area_kinds.py index 0874387b6f4..b3aefbb6676 100644 --- a/tests/component_tests/ld6004/test_area_kinds.py +++ b/tests/component_tests/ld6004/test_area_kinds.py @@ -14,6 +14,9 @@ from esphome.core import CORE ("binary_sensor_only.yaml", "2"), ("detection_area_sensor.yaml", "2"), ("dwell_area_sensor.yaml", "3"), + ("sensitivity_select.yaml", "2"), + ("area_id_select.yaml", "3"), + ("dwell_area_sensor_and_area_id_select.yaml", "3"), ], ) def test_area_kinds( diff --git a/tests/component_tests/ld6004/test_platforms.py b/tests/component_tests/ld6004/test_platforms.py index 8a399098b4f..5831261daf0 100644 --- a/tests/component_tests/ld6004/test_platforms.py +++ b/tests/component_tests/ld6004/test_platforms.py @@ -5,6 +5,7 @@ from importlib import import_module import pytest from esphome.components.ld6004 import LD6004Component +from esphome.components.select import ApplySelect import esphome.config_validation as cv from esphome.const import PlatformFramework from esphome.core import ID @@ -47,6 +48,22 @@ def _cases( ("ota_version",), ("work_mode", "ota_version"), ), + *_cases( + "select", + ApplySelect, + "config", + (), + ("work_mode",), + ("p20_mode",), + ( + "sensitivity", + "trigger_speed", + "installation_mode", + "area_id", + "work_mode", + "p20_mode", + ), + ), ], ) def test_platform_keys( diff --git a/tests/component_tests/ld6004/test_select_options.py b/tests/component_tests/ld6004/test_select_options.py new file mode 100644 index 00000000000..ad0302536c2 --- /dev/null +++ b/tests/component_tests/ld6004/test_select_options.py @@ -0,0 +1,43 @@ +"""The select options keep the order the C++ command tables index. + +This is the only Python-side guard against reordering an option list. The C++ test in +tests/components/ld6004/select keeps its own copy of the names, so it pins the command +each index sends but cannot see a reorder of the Python lists. +""" + +import pytest + +from esphome.components.ld6004.select import P20_MODE_OPTIONS, WORK_MODE_OPTIONS + + +@pytest.mark.parametrize( + ("options", "expected"), + [ + ( + WORK_MODE_OPTIONS, + ( + "normal", + "low_power", + "radar_off_p20_high", + "radar_off_p20_low", + "high_reflectivity", + ), + ), + ( + P20_MODE_OPTIONS, + ( + "presence_high", + "presence_low", + "constant_low", + "constant_high", + "pulse_low", + "pulse_high", + ), + ), + ], + ids=["work_mode", "p20_mode"], +) +def test_select_options_order( + options: tuple[str, ...], expected: tuple[str, ...] +) -> None: + assert options == expected diff --git a/tests/components/ld6002b/common.h b/tests/components/ld6002b/common.h index b5a39b83f15..9b2c3bc825c 100644 --- a/tests/components/ld6002b/common.h +++ b/tests/components/ld6002b/common.h @@ -133,7 +133,7 @@ inline std::vector concat(std::vector a, const std::vectorset_number_value(entity, value); } inline void apply_select(select::Select *entity, size_t index) { - entity->publish_state(index); - test_hub()->set_select_value(entity, index); + if (test_hub()->set_select_value(entity, index)) + entity->publish_state(index); } inline void apply_switch(switch_::Switch *entity, bool state) { if (test_hub()->set_switch_state(entity, state)) diff --git a/tests/components/ld6002b/select/ld6002b_select_test.cpp b/tests/components/ld6002b/select/ld6002b_select_test.cpp index 522dc1eec8d..d441364f0a3 100644 --- a/tests/components/ld6002b/select/ld6002b_select_test.cpp +++ b/tests/components/ld6002b/select/ld6002b_select_test.cpp @@ -26,6 +26,15 @@ TEST_F(LD6002BTest, InstallationReportPublishesOption) { EXPECT_EQ(this->installation_.current_option(), "side"); } +// A full command queue drops the write, and the select keeps showing the module's setting +TEST_F(LD6002BTest, SensitivityKeepsOptionWhenQueueFull) { + this->feed_(tf_frame(TYPE_REPORT_SENSITIVITY, {0x01})); + for (int i = 0; i < 32; i++) + this->trigger_speed_.make_call().set_index(0).perform(); + this->sensitivity_.make_call().set_index(2).perform(); + EXPECT_EQ(this->sensitivity_.current_option(), "medium"); +} + } // namespace esphome::ld6002b::testing #endif // USE_HOST diff --git a/tests/components/ld6004/common.h b/tests/components/ld6004/common.h index 65ccfa90d89..c82f643566f 100644 --- a/tests/components/ld6004/common.h +++ b/tests/components/ld6004/common.h @@ -17,6 +17,7 @@ #include "esphome/components/host/preferences.h" #include "esphome/components/ld6004/ld6004.h" #include "esphome/components/ld600x/ld600x_frame.h" +#include "esphome/components/select/select.h" #include "esphome/components/uart/uart_component.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -122,6 +123,20 @@ inline std::vector area_presence(const std::array return tf_frame(TYPE_REPORT_AREA_PRESENCE, p); } +// The generated apply entities forward to the hub through this pointer. +inline LD6004Component *&test_hub() { + static LD6004Component *hub = nullptr; + return hub; +} + +// What the generated publish="on_success" select does. +inline void apply_select(select::Select *entity, size_t index) { + if (test_hub()->set_select_value(entity, index)) + entity->publish_state(index); +} + +using TestSelect = select::ApplySelect; + /// A hub with the presence binary sensors, set up as codegen does and past its boot timeout. class LD6004Test : public ::testing::Test { protected: @@ -132,6 +147,7 @@ class LD6004Test : public ::testing::Test { setenv("ESPHOME_PREFDIR", this->temp_dir_.c_str(), 1); host::host_preferences = &this->prefs_; global_preferences = &this->prefs_; + test_hub() = &this->sut_; this->sut_.set_uart_parent(&this->uart_); this->configure_(); @@ -152,6 +168,7 @@ class LD6004Test : public ::testing::Test { } void TearDown() override { + test_hub() = nullptr; host::setup_preferences(); unsetenv("ESPHOME_PREFDIR"); std::filesystem::remove_all(this->temp_dir_); @@ -162,6 +179,20 @@ class LD6004Test : public ::testing::Test { this->sut_.loop(); } + /// ACK every boot command in turn until the queue is empty; returns the control codes that were sent. + std::vector drain_commands_() { + std::vector controls; + for (int i = 0; i < 32 && this->uart_.tx.size() >= ld600x::TF_FRAME_OVERHEAD; i++) { + const uint16_t type = encode_uint16(this->uart_.tx[5], this->uart_.tx[6]); + if (type == TYPE_CONTROL && this->uart_.tx.size() >= ld600x::TF_PAYLOAD_OFFSET + 4) + controls.push_back(encode_value_le(&this->uart_.tx[ld600x::TF_PAYLOAD_OFFSET])); + this->uart_.tx.clear(); + this->feed_(ack(type)); + } + EXPECT_TRUE(this->uart_.tx.empty()); + return controls; + } + host::HostPreferences prefs_; std::filesystem::path temp_dir_; MockUARTComponent uart_; diff --git a/tests/components/ld6004/common.yaml b/tests/components/ld6004/common.yaml index 639e0caf089..2f6067e3945 100644 --- a/tests/components/ld6004/common.yaml +++ b/tests/components/ld6004/common.yaml @@ -1,6 +1,22 @@ ld6004: id: ld6004_radar +select: + - platform: ld6004 + ld6004_id: ld6004_radar + sensitivity: + name: LD6004 Sensitivity + trigger_speed: + name: LD6004 Trigger Speed + installation_mode: + name: LD6004 Installation + area_id: + name: LD6004 Area ID + work_mode: + name: LD6004 Work Mode Select + p20_mode: + name: LD6004 P20 Mode + text_sensor: - platform: ld6004 ld6004_id: ld6004_radar diff --git a/tests/components/ld6004/select/ld6004_select_test.cpp b/tests/components/ld6004/select/ld6004_select_test.cpp new file mode 100644 index 00000000000..178797497ea --- /dev/null +++ b/tests/components/ld6004/select/ld6004_select_test.cpp @@ -0,0 +1,127 @@ +#ifdef USE_HOST +#include "../common.h" + +#include "esphome/components/select/select.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::ld6004::testing { + +namespace { + +constexpr uint16_t TYPE_REPORT_P20_MODE = 0x0A15; + +struct ModeCommand { + const char *option; + uint32_t command; +}; + +// The select options in the order select/__init__.py sends them, with the control code each one must send. +constexpr ModeCommand WORK_MODES[] = { + {"normal", 0x17}, + {"low_power", 0x16}, + {"radar_off_p20_high", 0x1B}, + {"radar_off_p20_low", 0x1C}, + {"high_reflectivity", 0x24}, +}; +constexpr ModeCommand P20_MODES[] = { + {"presence_high", 0x1D}, {"presence_low", 0x1E}, {"constant_low", 0x20}, + {"constant_high", 0x21}, {"pulse_low", 0x22}, {"pulse_high", 0x23}, +}; + +template void set_options(select::Select &entity, const ModeCommand (&modes)[N]) { + FixedVector options; + options.init(N); + for (const auto &mode : modes) + options.push_back(mode.option); + entity.traits.set_options(options); +} + +} // namespace + +class LD6004SelectTest : public LD6004Test { + protected: + void SetUp() override { + set_options(this->work_mode_, WORK_MODES); + set_options(this->p20_mode_, P20_MODES); + this->sut_.set_work_mode_select(&this->work_mode_); + this->sut_.set_p20_mode_select(&this->p20_mode_); + this->sut_.set_work_mode_text_sensor(&this->work_mode_text_); + LD6004Test::SetUp(); + // Each test starts with an idle queue + this->drain_commands_(); + } + + /// The control code of the command on the wire, which is then ACKed. + uint32_t take_control_() { + EXPECT_GE(this->uart_.tx.size(), ld600x::TF_PAYLOAD_OFFSET + 4); + if (this->uart_.tx.size() < ld600x::TF_PAYLOAD_OFFSET + 4) + return 0; + EXPECT_EQ(encode_uint16(this->uart_.tx[5], this->uart_.tx[6]), TYPE_CONTROL); + const uint32_t control = encode_value_le(&this->uart_.tx[ld600x::TF_PAYLOAD_OFFSET]); + this->uart_.tx.clear(); + this->feed_(ack(TYPE_CONTROL)); + return control; + } + + /// Every option sends its own control code and is then shown. + template void expect_options_send_commands_(select::Select &entity, const ModeCommand (&modes)[N]) { + ASSERT_TRUE(this->uart_.tx.empty()); + for (const auto &mode : modes) { + entity.make_call().set_option(mode.option).perform(); + EXPECT_EQ(this->take_control_(), mode.command) << mode.option; + EXPECT_EQ(entity.current_option(), mode.option); + } + } + + TestSelect work_mode_; + TestSelect p20_mode_; + text_sensor::TextSensor work_mode_text_; +}; + +TEST_F(LD6004SelectTest, WorkModeOptionsSendTheirCommands) { + this->expect_options_send_commands_(this->work_mode_, WORK_MODES); +} + +TEST_F(LD6004SelectTest, P20ModeOptionsSendTheirCommands) { + this->expect_options_send_commands_(this->p20_mode_, P20_MODES); +} + +// The select and the text sensor read the same 0x0A12 value as the same name +TEST_F(LD6004SelectTest, WorkModeReportPublishesMatchingOption) { + for (uint8_t i = 0; i < WORK_MODE_COUNT; i++) { + this->feed_(work_mode(i)); + EXPECT_EQ(this->work_mode_.current_option(), WORK_MODES[i].option); + EXPECT_EQ(this->work_mode_text_.state, WORK_MODES[i].option); + } +} + +// The module may send the P20 mode as one byte or as a uint32, like the work mode +TEST_F(LD6004SelectTest, P20ModeReportPublishesOption) { + for (bool wide : {false, true}) { + for (uint8_t i = 0; i < std::size(P20_MODES); i++) { + this->feed_(wide ? tf_frame(TYPE_REPORT_P20_MODE, u32_payload(i)) : tf_frame(TYPE_REPORT_P20_MODE, {i})); + EXPECT_EQ(this->p20_mode_.current_option(), P20_MODES[i].option) << (wide ? "as uint32" : "as byte"); + } + } +} + +TEST_F(LD6004SelectTest, MalformedP20ModeReportIsIgnored) { + this->feed_(tf_frame(TYPE_REPORT_P20_MODE, {2})); + this->feed_(tf_frame(TYPE_REPORT_P20_MODE, {6})); + this->feed_(tf_frame(TYPE_REPORT_P20_MODE, u32_payload(6))); + this->feed_(tf_frame(TYPE_REPORT_P20_MODE, {1, 0})); + EXPECT_EQ(this->p20_mode_.current_option(), "constant_low"); +} + +// A full command queue drops the write, and the select keeps showing the module's mode +TEST_F(LD6004SelectTest, WorkModeKeepsOptionWhenQueueFull) { + this->feed_(work_mode(0)); + for (int i = 0; i < 32; i++) + this->p20_mode_.make_call().set_index(0).perform(); + this->work_mode_.make_call().set_option("low_power").perform(); + EXPECT_EQ(this->work_mode_.current_option(), "normal"); +} + +} // namespace esphome::ld6004::testing + +#endif // USE_HOST diff --git a/tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp b/tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp index ebaa233a284..4ead05c504c 100644 --- a/tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp +++ b/tests/components/ld6004/text_sensor/ld6004_text_sensor_test.cpp @@ -8,8 +8,6 @@ namespace esphome::ld6004::testing { namespace { constexpr uint32_t CMD_GET_WORK_MODE = 0x18; -// SOF, id, length, type and header checksum come before the payload. -constexpr size_t PAYLOAD_OFFSET = ld600x::TF_HEADER_LEN + 2; } // namespace @@ -18,25 +16,11 @@ class LD6004TextSensorTest : public LD6004Test { protected: void configure_() override { this->sut_.set_work_mode_text_sensor(&this->work_mode_); } - /// ACK every boot command in turn and return the control codes that were sent. - std::vector drain_controls_() { - std::vector controls; - for (int i = 0; i < 32 && this->uart_.tx.size() >= ld600x::TF_FRAME_OVERHEAD; i++) { - const uint16_t type = encode_uint16(this->uart_.tx[5], this->uart_.tx[6]); - if (type == TYPE_CONTROL && this->uart_.tx.size() >= PAYLOAD_OFFSET + 4) - controls.push_back(encode_value_le(&this->uart_.tx[PAYLOAD_OFFSET])); - this->uart_.tx.clear(); - this->feed_(ack(type)); - } - EXPECT_TRUE(this->uart_.tx.empty()); - return controls; - } - text_sensor::TextSensor work_mode_; }; TEST_F(LD6004TextSensorTest, TextSensorAloneQueriesWorkModeAtBoot) { - const auto controls = this->drain_controls_(); + const auto controls = this->drain_commands_(); EXPECT_EQ(std::count(controls.begin(), controls.end(), CMD_GET_WORK_MODE), 1); // The text sensor reads no target reports, so the target stream stays off. EXPECT_EQ(std::count(controls.begin(), controls.end(), CMD_TARGET_DISPLAY_ON), 0); From 563ba82a95bfac53ca7f3ae51cb843d7474759d5 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:14:17 -1000 Subject: [PATCH 006/170] Bump aioesphomeapi from 46.23.0 to 46.24.0 (#21983) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1d9d80f6b78..80a1d9dc15d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.23.0 +aioesphomeapi==46.24.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 1d94b351ffff832ed47e951f192cc77afe6c8644 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 31 Oct 2026 06:16:37 +0100 Subject: [PATCH 007/170] [ld6004] Add button platform (#19846) --- esphome/components/ld6004/button/__init__.py | 29 +++++++++++++++++ .../component_tests/ld6004/test_platforms.py | 3 ++ tests/components/ld6004/common.yaml | 32 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 esphome/components/ld6004/button/__init__.py diff --git a/esphome/components/ld6004/button/__init__.py b/esphome/components/ld6004/button/__init__.py new file mode 100644 index 00000000000..f8d75d0be0f --- /dev/null +++ b/esphome/components/ld6004/button/__init__.py @@ -0,0 +1,29 @@ +from esphome.components.ld600x import entities +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import LD6004Component, ld6004_ns +from ..const import CONF_LD6004_ID + +DEPENDENCIES = ["ld6004"] + +CONF_CLEAR_DWELL = "clear_dwell" + +LD6004ButtonType = ld6004_ns.enum("LD6004ButtonType") + +CONFIG_SCHEMA = entities.button_schema( + LD6004Component, + CONF_LD6004_ID, + extra={ + cv.Optional(CONF_CLEAR_DWELL): entities.CONFIG_BUTTON_SCHEMA, + }, +) +FINAL_VALIDATE_SCHEMA = entities.button_final_validate(CONF_LD6004_ID, "ld6004") + + +async def to_code(config: ConfigType) -> None: + await entities.button_to_code( + config, + CONF_LD6004_ID, + extra={CONF_CLEAR_DWELL: LD6004ButtonType.BUTTON_CLEAR_DWELL}, + ) diff --git a/tests/component_tests/ld6004/test_platforms.py b/tests/component_tests/ld6004/test_platforms.py index 5831261daf0..b4bcaf7a049 100644 --- a/tests/component_tests/ld6004/test_platforms.py +++ b/tests/component_tests/ld6004/test_platforms.py @@ -4,6 +4,7 @@ from importlib import import_module import pytest +from esphome.components.button import ApplyButton from esphome.components.ld6004 import LD6004Component from esphome.components.select import ApplySelect import esphome.config_validation as cv @@ -64,6 +65,7 @@ def _cases( "p20_mode", ), ), + *_cases("button", ApplyButton, "config", (), ("clear_dwell",)), ], ) def test_platform_keys( @@ -96,6 +98,7 @@ def test_platform_keys( {"ld6002b_id": "radar", "work_mode": {"name": "Work Mode"}}, "ld6002b_id", ), + ("button", {"ld6004_id": "radar", "wake": {"name": "Wake"}}, "wake"), ], ) def test_platform_rejects_key( diff --git a/tests/components/ld6004/common.yaml b/tests/components/ld6004/common.yaml index 2f6067e3945..fb961ca7eb9 100644 --- a/tests/components/ld6004/common.yaml +++ b/tests/components/ld6004/common.yaml @@ -1,6 +1,38 @@ ld6004: id: ld6004_radar +button: + - platform: ld6004 + ld6004_id: ld6004_radar + apply_area: + name: LD6004 Apply Area + auto_interference: + name: LD6004 Auto Interference + get_areas: + name: LD6004 Get Areas + clear_interference: + name: LD6004 Clear Interference + reset_detection_area: + name: LD6004 Reset Detection + get_delay: + name: LD6004 Get Delay + get_sensitivity: + name: LD6004 Get Sensitivity + get_trigger_speed: + name: LD6004 Get Trigger Speed + get_z_range: + name: LD6004 Get Z Range + get_installation: + name: LD6004 Get Installation + get_low_power_mode: + name: LD6004 Get Low Power Mode + get_low_power_sleep_time: + name: LD6004 Get Low Power Sleep + reset_unattended: + name: LD6004 Reset Unattended + clear_dwell: + name: LD6004 Clear Dwell + select: - platform: ld6004 ld6004_id: ld6004_radar From f60408208ef426ef6f097517de670f9558b3d5d7 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 31 Oct 2026 06:35:36 +0100 Subject: [PATCH 008/170] [ld6004] Add number platform (#19843) --- esphome/components/ld6004/ld6004.cpp | 20 ++---- esphome/components/ld6004/ld6004.h | 2 +- esphome/components/ld6004/number/__init__.py | 47 +++++++++++++ esphome/components/ld600x/entities.py | 3 +- esphome/components/ld600x/ld600x.cpp | 63 +++++++++++------ esphome/components/ld600x/ld600x.h | 11 ++- .../component_tests/ld6004/test_platforms.py | 24 +++++++ tests/components/ld6002b/common.h | 13 +++- .../ld6002b/number/ld6002b_number_test.cpp | 66 +++++++++++++++-- .../ld6002b/select/ld6002b_select_test.cpp | 3 +- .../ld6002b/switch/ld6002b_switch_test.cpp | 3 +- tests/components/ld6004/common.h | 23 +++++- tests/components/ld6004/common.yaml | 29 ++++++++ .../ld6004/number/ld6004_number_test.cpp | 70 +++++++++++++++++++ .../ld6004/select/ld6004_select_test.cpp | 3 +- 15 files changed, 326 insertions(+), 54 deletions(-) create mode 100644 esphome/components/ld6004/number/__init__.py create mode 100644 tests/components/ld6004/number/ld6004_number_test.cpp diff --git a/esphome/components/ld6004/ld6004.cpp b/esphome/components/ld6004/ld6004.cpp index 8ced3434e10..303a04924bb 100644 --- a/esphome/components/ld6004/ld6004.cpp +++ b/esphome/components/ld6004/ld6004.cpp @@ -3,7 +3,6 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" -#include namespace esphome::ld6004 { @@ -128,20 +127,11 @@ bool LD6004Component::handle_model_report(uint16_t type, const uint8_t *data, ui } #ifdef USE_NUMBER -void LD6004Component::set_number_value(number::Number *entity, float value) { - if (entity != this->dwell_lifetime_number_ && entity != this->output_interval_number_) { - this->LD600XComponent::set_number_value(entity, value); - return; - } - // NumberCall already keeps the value inside the traits' range; the module takes whole numbers only. - if (std::floor(value) != value) { - ESP_LOGW(TAG, "Invalid number value: %f", value); - return; - } - uint8_t data[4]; - ld600x::write_u32_le(data, static_cast(value)); - this->queue_command_(entity == this->dwell_lifetime_number_ ? TYPE_SET_DWELL_LIFETIME : TYPE_SET_OUTPUT_INTERVAL, - data, sizeof(data)); +bool LD6004Component::set_number_value(number::Number *entity, float value) { + if (entity != this->dwell_lifetime_number_ && entity != this->output_interval_number_) + return this->LD600XComponent::set_number_value(entity, value); + return this->queue_u32_setting_( + entity == this->dwell_lifetime_number_ ? TYPE_SET_DWELL_LIFETIME : TYPE_SET_OUTPUT_INTERVAL, value); } #endif // USE_NUMBER diff --git a/esphome/components/ld6004/ld6004.h b/esphome/components/ld6004/ld6004.h index 49e8f7527f8..8fe474b71e0 100644 --- a/esphome/components/ld6004/ld6004.h +++ b/esphome/components/ld6004/ld6004.h @@ -22,7 +22,7 @@ class LD6004Component final : public ld600x::LD600XComponent { #endif #ifdef USE_NUMBER - void set_number_value(number::Number *entity, float value) override; + bool set_number_value(number::Number *entity, float value) override; #endif #ifdef USE_SELECT bool set_select_value(select::Select *entity, size_t index) override; diff --git a/esphome/components/ld6004/number/__init__.py b/esphome/components/ld6004/number/__init__.py new file mode 100644 index 00000000000..bf7918cd99c --- /dev/null +++ b/esphome/components/ld6004/number/__init__.py @@ -0,0 +1,47 @@ +from esphome.components import number +from esphome.components.ld600x import entities +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import LD6004Component +from ..const import CONF_LD6004_ID + +DEPENDENCIES = ["ld6004"] + +CONF_DWELL_LIFETIME = "dwell_lifetime" +CONF_OUTPUT_INTERVAL = "output_interval" +# The settings are uint32 on the wire, but the entity value is a float, which holds every +# whole number only up to 2**24. +MAX_WHOLE_FLOAT = 2**24 - 1 + +CONFIG_SCHEMA = entities.number_schema( + LD6004Component, + CONF_LD6004_ID, + extra={ + cv.Optional(CONF_DWELL_LIFETIME): number.number_schema( + number.ApplyNumber, entity_category=ENTITY_CATEGORY_CONFIG + ), + cv.Optional(CONF_OUTPUT_INTERVAL): number.number_schema( + number.ApplyNumber, entity_category=ENTITY_CATEGORY_CONFIG + ), + }, +) +FINAL_VALIDATE_SCHEMA = entities.number_final_validate(CONF_LD6004_ID, "ld6004") + + +async def to_code(config: ConfigType) -> None: + numbers = await entities.number_to_code(config, CONF_LD6004_ID) + hub = numbers.parent + for key, setter, min_value in ( + (CONF_DWELL_LIFETIME, hub.set_dwell_lifetime_number, 0), + (CONF_OUTPUT_INTERVAL, hub.set_output_interval_number, 1), + ): + await numbers( + key, + setter, + min_value=min_value, + max_value=MAX_WHOLE_FLOAT, + step=1, + **entities.NUMBER_CONTROL, + ) diff --git a/esphome/components/ld600x/entities.py b/esphome/components/ld600x/entities.py index cd5fb9b3eb1..2a7c5ade415 100644 --- a/esphome/components/ld600x/entities.py +++ b/esphome/components/ld600x/entities.py @@ -322,7 +322,8 @@ def number_final_validate( # The apply call picks the setting by entity pointer, so every number shares one generated class. NUMBER_CONTROL: dict[str, str] = { "apply": "set_number_value(entity, value)", - "publish": "before", + # A rejected value or a full command queue leaves the number at its old value. + "publish": "on_success", } diff --git a/esphome/components/ld600x/ld600x.cpp b/esphome/components/ld600x/ld600x.cpp index f1394bcd5e3..19575623926 100644 --- a/esphome/components/ld600x/ld600x.cpp +++ b/esphome/components/ld600x/ld600x.cpp @@ -945,23 +945,37 @@ bool LD600XComponent::send_control_command_(uint32_t command) { return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } -void LD600XComponent::send_z_range_() { - // One frame carries both bounds, so half a range cannot be written. - if (std::isnan(this->z_min_) || std::isnan(this->z_max_)) { +bool LD600XComponent::send_z_range_(float z_min, float z_max) { + // One frame carries both bounds, so half a range cannot be written; the bound is held until the other is known. + if (std::isnan(z_min) || std::isnan(z_max)) { ESP_LOGW(TAG, "Z range not written, other bound unknown"); - return; + return true; } // Both bounds are known and crossed; the frame has no way to say that. - if (this->z_min_ > this->z_max_) { + if (z_min > z_max) { ESP_LOGW(TAG, "Z range not written, min above max"); - return; + return false; } uint8_t data[8]; - write_f32_le(data, this->z_min_); - write_f32_le(data + 4, this->z_max_); - this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); + write_f32_le(data, z_min); + write_f32_le(data + 4, z_max); + return this->queue_command_(TYPE_SET_Z_RANGE, data, sizeof(data)); } +#ifdef USE_NUMBER +bool LD600XComponent::queue_u32_setting_(uint16_t type, float value) { + // NumberCall keeps the value inside the traits' range but does not round it to the step; + // the module takes whole numbers only. + if (std::floor(value) != value) { + ESP_LOGW(TAG, "Invalid number value: %f", value); + return false; + } + uint8_t data[4]; + write_u32_le(data, static_cast(value)); + return this->queue_command_(type, data, sizeof(data)); +} +#endif + void LD600XComponent::apply_area_config_() { if (!this->area_id_set_) { ESP_LOGW(TAG, "Area ID not selected; ignoring apply"); @@ -1020,19 +1034,23 @@ void LD600XComponent::apply_area_config_() { #ifdef USE_NUMBER // Each setter picks its setting by entity pointer, so the entities share one generated class. -void LD600XComponent::set_number_value(number::Number *entity, float value) { - if (entity == this->hold_delay_number_ || entity == this->low_power_sleep_number_) { - uint8_t data[4]; - write_u32_le(data, static_cast(value)); - this->queue_command_(entity == this->hold_delay_number_ ? TYPE_SET_HOLD_DELAY : TYPE_SET_LOW_POWER_SLEEP, data, - sizeof(data)); - } else if (entity == this->z_min_number_) { - this->z_min_ = value; - this->send_z_range_(); - } else if (entity == this->z_max_number_) { - this->z_max_ = value; - this->send_z_range_(); - } else if (entity == this->area_x_min_number_) { +bool LD600XComponent::set_number_value(number::Number *entity, float value) { + if (entity == this->hold_delay_number_) + return this->queue_u32_setting_(TYPE_SET_HOLD_DELAY, value); + if (entity == this->low_power_sleep_number_) + return this->queue_u32_setting_(TYPE_SET_LOW_POWER_SLEEP, value); + // The z bounds and the area edits are kept here until both bounds or the apply button write them. + if (entity == this->z_min_number_ || entity == this->z_max_number_) { + const bool is_min = entity == this->z_min_number_; + const float z_min = is_min ? value : this->z_min_; + const float z_max = is_min ? this->z_max_ : value; + if (!this->send_z_range_(z_min, z_max)) + return false; + this->z_min_ = z_min; + this->z_max_ = z_max; + return true; + } + if (entity == this->area_x_min_number_) { this->area_x_min_ = value; this->area_edits_.x_min = value; } else if (entity == this->area_x_max_number_) { @@ -1051,6 +1069,7 @@ void LD600XComponent::set_number_value(number::Number *entity, float value) { this->area_z_max_ = value; this->area_edits_.z_max = value; } + return true; } #endif diff --git a/esphome/components/ld600x/ld600x.h b/esphome/components/ld600x/ld600x.h index a2ee4c4f996..995a33e077a 100644 --- a/esphome/components/ld600x/ld600x.h +++ b/esphome/components/ld600x/ld600x.h @@ -195,7 +195,8 @@ class LD600XComponent : public Component, public uart::UARTDevice { // Each setter picks its setting by entity pointer, so the entities share one generated class. A model // with its own entities handles those first and passes the rest down. #ifdef USE_NUMBER - virtual void set_number_value(number::Number *entity, float value); + // Returns false when the value was rejected or its command not queued, so the number keeps its old value. + virtual bool set_number_value(number::Number *entity, float value); #endif #ifdef USE_SELECT // Returns false when the command was not queued, so the select keeps its old option. @@ -270,7 +271,13 @@ class LD600XComponent : public Component, public uart::UARTDevice { void write_frame_(uint16_t type, const uint8_t *data, uint8_t len, bool track); // Returns whether the command reached the queue; see queue_command_. bool send_control_command_(uint32_t command); - void send_z_range_(); + // Writes the candidate range; the caller stores it only on true. Returns false when it was not + // queued: crossed bounds or a full queue. A bound held until the other one is known counts as accepted. + bool send_z_range_(float z_min, float z_max); +#ifdef USE_NUMBER + // Writes a whole-number setting as a uint32; returns false for a fractional value or a full queue. + bool queue_u32_setting_(uint16_t type, float value); +#endif void apply_area_config_(); #ifdef USE_SENSOR diff --git a/tests/component_tests/ld6004/test_platforms.py b/tests/component_tests/ld6004/test_platforms.py index b4bcaf7a049..c4b9c29d227 100644 --- a/tests/component_tests/ld6004/test_platforms.py +++ b/tests/component_tests/ld6004/test_platforms.py @@ -6,6 +6,7 @@ import pytest from esphome.components.button import ApplyButton from esphome.components.ld6004 import LD6004Component +from esphome.components.number import ApplyNumber from esphome.components.select import ApplySelect import esphome.config_validation as cv from esphome.const import PlatformFramework @@ -66,6 +67,15 @@ def _cases( ), ), *_cases("button", ApplyButton, "config", (), ("clear_dwell",)), + *_cases( + "number", + ApplyNumber, + "config", + (), + ("dwell_lifetime",), + ("output_interval",), + ("dwell_lifetime", "output_interval"), + ), ], ) def test_platform_keys( @@ -110,3 +120,17 @@ def test_platform_rejects_key( set_core_config(PlatformFramework.ESP32_IDF) with pytest.raises(cv.Invalid, match=match): _schema(platform)(config) + + +def test_area_config_keys(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + keys = ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max") + config: ConfigType = _schema("number")( + {"area_config": {key: {"name": key} for key in keys}} + ) + assert config["ld6004_id"].type == LD6004Component + assert set(config["area_config"]) == set(keys) + for key in keys: + assert config["area_config"][key]["id"].type == ApplyNumber + assert config["area_config"][key]["unit_of_measurement"] == "m" + assert config["area_config"][key]["entity_category"] == "config" diff --git a/tests/components/ld6002b/common.h b/tests/components/ld6002b/common.h index 9b2c3bc825c..f72e8f896d1 100644 --- a/tests/components/ld6002b/common.h +++ b/tests/components/ld6002b/common.h @@ -140,8 +140,8 @@ inline LD6002BComponent *&test_hub() { } inline void apply_number(number::Number *entity, float value) { - entity->publish_state(value); - test_hub()->set_number_value(entity, value); + if (test_hub()->set_number_value(entity, value)) + entity->publish_state(value); } inline void apply_select(select::Select *entity, size_t index) { if (test_hub()->set_select_value(entity, index)) @@ -258,6 +258,15 @@ class LD6002BTest : public ::testing::Test { return next_id; } + /// Queue writes until the hub rejects one, which leaves the command queue full. + void fill_command_queue_() { + for (int i = 0; i < 64; i++) { + if (!this->sut_.set_select_value(&this->trigger_speed_, 0)) + return; + } + FAIL() << "Command queue never filled"; + } + host::HostPreferences prefs_; std::filesystem::path temp_dir_; MockUARTComponent uart_; diff --git a/tests/components/ld6002b/number/ld6002b_number_test.cpp b/tests/components/ld6002b/number/ld6002b_number_test.cpp index 3eec585cad6..ea5e617c62a 100644 --- a/tests/components/ld6002b/number/ld6002b_number_test.cpp +++ b/tests/components/ld6002b/number/ld6002b_number_test.cpp @@ -3,16 +3,28 @@ namespace esphome::ld6002b::testing { +namespace { + +std::vector z_payload(float z_min, float z_max) { + std::vector p; + put_f32(p, z_min); + put_f32(p, z_max); + return p; +} + +std::vector z_range(float z_min, float z_max) { + return tf_frame(TYPE_REPORT_Z_RANGE, z_payload(z_min, z_max)); +} + +} // namespace + TEST_F(LD6002BTest, DelayReportPublishesHoldDelay) { this->feed_(tf_frame(TYPE_REPORT_DELAY, u32_payload(300))); EXPECT_FLOAT_EQ(this->hold_delay_.state, 300.0f); } TEST_F(LD6002BTest, ZRangeReportPublishesLittleEndianFloats) { - std::vector p; - put_f32(p, -0.5f); - put_f32(p, 2.75f); - this->feed_(tf_frame(TYPE_REPORT_Z_RANGE, p)); + this->feed_(z_range(-0.5f, 2.75f)); EXPECT_FLOAT_EQ(this->z_min_.state, -0.5f); EXPECT_FLOAT_EQ(this->z_max_.state, 2.75f); } @@ -32,6 +44,52 @@ TEST_F(LD6002BTest, ReportOutsideTheNumberRangeIsClamped) { EXPECT_FLOAT_EQ(this->low_power_sleep_.state, 60000.0f); } +// The module takes whole numbers only, so a fraction is rejected rather than truncated +TEST_F(LD6002BTest, HoldDelayFractionIsNotWrittenOrPublished) { + this->feed_(tf_frame(TYPE_REPORT_DELAY, u32_payload(30))); + const uint16_t id = this->drain_commands_(); + this->hold_delay_.make_call().set_value(12.5f).perform(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_FLOAT_EQ(this->hold_delay_.state, 30.0f); + this->hold_delay_.make_call().set_value(60).perform(); + EXPECT_EQ(this->uart_.tx, command(id, TYPE_SET_HOLD_DELAY, u32_payload(60))); + EXPECT_FLOAT_EQ(this->hold_delay_.state, 60.0f); +} + +// A full command queue drops the write, and the number keeps showing the module's setting +TEST_F(LD6002BTest, HoldDelayKeepsValueWhenQueueFull) { + this->feed_(tf_frame(TYPE_REPORT_DELAY, u32_payload(30))); + this->fill_command_queue_(); + this->hold_delay_.make_call().set_value(60).perform(); + EXPECT_FLOAT_EQ(this->hold_delay_.state, 30.0f); +} + +// Crossed bounds cannot be written, so the number keeps the module's value and the next write uses the old bound +TEST_F(LD6002BTest, CrossedZRangeIsNotPublished) { + this->feed_(z_range(-0.5f, 2.75f)); + const uint16_t id = this->drain_commands_(); + this->z_min_.make_call().set_value(5.0f).perform(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_FLOAT_EQ(this->z_min_.state, -0.5f); + this->z_max_.make_call().set_value(3.0f).perform(); + EXPECT_EQ(this->uart_.tx, command(id, TYPE_SET_Z_RANGE, z_payload(-0.5f, 3.0f))); +} + +TEST_F(LD6002BTest, ZRangeIsNotPublishedWhenQueueFull) { + this->feed_(z_range(-0.5f, 2.75f)); + this->fill_command_queue_(); + this->z_max_.make_call().set_value(3.0f).perform(); + EXPECT_FLOAT_EQ(this->z_max_.state, 2.75f); +} + +// With the other bound unknown nothing can be written yet; the value is held and shown +TEST_F(LD6002BTest, ZBoundIsHeldAndPublishedWhileOtherIsUnknown) { + this->drain_commands_(); + this->z_min_.make_call().set_value(1.0f).perform(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_FLOAT_EQ(this->z_min_.state, 1.0f); +} + } // namespace esphome::ld6002b::testing #endif // USE_HOST diff --git a/tests/components/ld6002b/select/ld6002b_select_test.cpp b/tests/components/ld6002b/select/ld6002b_select_test.cpp index d441364f0a3..ffad3921844 100644 --- a/tests/components/ld6002b/select/ld6002b_select_test.cpp +++ b/tests/components/ld6002b/select/ld6002b_select_test.cpp @@ -29,8 +29,7 @@ TEST_F(LD6002BTest, InstallationReportPublishesOption) { // A full command queue drops the write, and the select keeps showing the module's setting TEST_F(LD6002BTest, SensitivityKeepsOptionWhenQueueFull) { this->feed_(tf_frame(TYPE_REPORT_SENSITIVITY, {0x01})); - for (int i = 0; i < 32; i++) - this->trigger_speed_.make_call().set_index(0).perform(); + this->fill_command_queue_(); this->sensitivity_.make_call().set_index(2).perform(); EXPECT_EQ(this->sensitivity_.current_option(), "medium"); } diff --git a/tests/components/ld6002b/switch/ld6002b_switch_test.cpp b/tests/components/ld6002b/switch/ld6002b_switch_test.cpp index 05f53443a67..ede9e940514 100644 --- a/tests/components/ld6002b/switch/ld6002b_switch_test.cpp +++ b/tests/components/ld6002b/switch/ld6002b_switch_test.cpp @@ -17,8 +17,7 @@ TEST_F(LD6002BTest, EmptyLowPowerReportIsIgnored) { } TEST_F(LD6002BTest, LowPowerSwitchKeepsStateWhenQueueFull) { - for (int i = 0; i < 32; i++) - this->sensitivity_.make_call().set_index(0).perform(); + this->fill_command_queue_(); this->low_power_.turn_on(); EXPECT_FALSE(this->low_power_.state); } diff --git a/tests/components/ld6004/common.h b/tests/components/ld6004/common.h index c82f643566f..5da75eaf369 100644 --- a/tests/components/ld6004/common.h +++ b/tests/components/ld6004/common.h @@ -17,6 +17,7 @@ #include "esphome/components/host/preferences.h" #include "esphome/components/ld6004/ld6004.h" #include "esphome/components/ld600x/ld600x_frame.h" +#include "esphome/components/number/number.h" #include "esphome/components/select/select.h" #include "esphome/components/uart/uart_component.h" #include "esphome/core/application.h" @@ -29,10 +30,13 @@ using ld600x::AREA_COUNT; static constexpr uint8_t MAX_TARGETS = LD600X_MAX_TARGETS; static constexpr uint16_t TYPE_CONTROL = 0x0201; +static constexpr uint16_t TYPE_SET_DWELL_LIFETIME = 0x0206; +static constexpr uint16_t TYPE_SET_OUTPUT_INTERVAL = 0x0207; static constexpr uint16_t TYPE_REPORT_TARGET = 0x0A04; static constexpr uint16_t TYPE_REPORT_AREA_PRESENCE = 0x0A0A; static constexpr uint16_t TYPE_REPORT_WORK_MODE = 0x0A12; static constexpr uint16_t TYPE_REPORT_WORK_STATUS = 0x0A14; +static constexpr uint16_t TYPE_REPORT_DWELL_LIFETIME = 0x0A17; static constexpr uint32_t CMD_POINT_CLOUD_OFF = 0x07; static constexpr uint32_t CMD_TARGET_DISPLAY_ON = 0x08; @@ -129,12 +133,17 @@ inline LD6004Component *&test_hub() { return hub; } -// What the generated publish="on_success" select does. +// What the generated publish="on_success" number and select do. +inline void apply_number(number::Number *entity, float value) { + if (test_hub()->set_number_value(entity, value)) + entity->publish_state(value); +} inline void apply_select(select::Select *entity, size_t index) { if (test_hub()->set_select_value(entity, index)) entity->publish_state(index); } +using TestNumber = number::ApplyNumber; using TestSelect = select::ApplySelect; /// A hub with the presence binary sensors, set up as codegen does and past its boot timeout. @@ -180,6 +189,7 @@ class LD6004Test : public ::testing::Test { } /// ACK every boot command in turn until the queue is empty; returns the control codes that were sent. + /// Each frame sent takes the next id, which next_command_id_ follows. std::vector drain_commands_() { std::vector controls; for (int i = 0; i < 32 && this->uart_.tx.size() >= ld600x::TF_FRAME_OVERHEAD; i++) { @@ -188,15 +198,26 @@ class LD6004Test : public ::testing::Test { controls.push_back(encode_value_le(&this->uart_.tx[ld600x::TF_PAYLOAD_OFFSET])); this->uart_.tx.clear(); this->feed_(ack(type)); + this->next_command_id_++; } EXPECT_TRUE(this->uart_.tx.empty()); return controls; } + /// Call queue_write until the hub rejects one, which leaves the command queue full. + template void fill_command_queue_(F queue_write) { + for (int i = 0; i < 64; i++) { + if (!queue_write()) + return; + } + FAIL() << "Command queue never filled"; + } + host::HostPreferences prefs_; std::filesystem::path temp_dir_; MockUARTComponent uart_; LD6004Component sut_; + uint16_t next_command_id_{FIRST_COMMAND_ID}; binary_sensor::BinarySensor presence_; binary_sensor::BinarySensor target_presence_[MAX_TARGETS]; diff --git a/tests/components/ld6004/common.yaml b/tests/components/ld6004/common.yaml index fb961ca7eb9..f05fd89766b 100644 --- a/tests/components/ld6004/common.yaml +++ b/tests/components/ld6004/common.yaml @@ -49,6 +49,35 @@ select: p20_mode: name: LD6004 P20 Mode +number: + - platform: ld6004 + ld6004_id: ld6004_radar + hold_delay: + name: LD6004 Hold Delay + z_min: + name: LD6004 Z Min + z_max: + name: LD6004 Z Max + low_power_sleep_time: + name: LD6004 Low Power Sleep + dwell_lifetime: + name: LD6004 Dwell Lifetime + output_interval: + name: LD6004 Output Interval + area_config: + x_min: + name: LD6004 Area X Min + x_max: + name: LD6004 Area X Max + y_min: + name: LD6004 Area Y Min + y_max: + name: LD6004 Area Y Max + z_min: + name: LD6004 Area Z Min + z_max: + name: LD6004 Area Z Max + text_sensor: - platform: ld6004 ld6004_id: ld6004_radar diff --git a/tests/components/ld6004/number/ld6004_number_test.cpp b/tests/components/ld6004/number/ld6004_number_test.cpp new file mode 100644 index 00000000000..2f44debfc34 --- /dev/null +++ b/tests/components/ld6004/number/ld6004_number_test.cpp @@ -0,0 +1,70 @@ +#ifdef USE_HOST +#include "../common.h" + +#include "esphome/components/number/number.h" + +namespace esphome::ld6004::testing { + +namespace { + +// The settings are uint32 on the wire, but a float holds every whole number only up to 2**24. +constexpr float MAX_WHOLE_FLOAT = 16777215; + +} // namespace + +class LD6004NumberTest : public LD6004Test { + protected: + void SetUp() override { + for (auto *n : {&this->dwell_lifetime_, &this->output_interval_}) { + n->traits.set_min_value(0); + n->traits.set_max_value(MAX_WHOLE_FLOAT); + n->traits.set_step(1); + } + this->sut_.set_dwell_lifetime_number(&this->dwell_lifetime_); + this->sut_.set_output_interval_number(&this->output_interval_); + LD6004Test::SetUp(); + // Each test starts with an idle queue + this->drain_commands_(); + } + + TestNumber dwell_lifetime_; + TestNumber output_interval_; +}; + +TEST_F(LD6004NumberTest, WholeValuesAreWrittenAndPublished) { + const struct { + TestNumber *number; + uint16_t type; + uint32_t value; + } cases[] = {{&this->dwell_lifetime_, TYPE_SET_DWELL_LIFETIME, 600}, + {&this->output_interval_, TYPE_SET_OUTPUT_INTERVAL, 250}}; + for (const auto &c : cases) { + ASSERT_TRUE(this->uart_.tx.empty()); + c.number->make_call().set_value(c.value).perform(); + EXPECT_EQ(this->uart_.tx, command(this->next_command_id_++, c.type, u32_payload(c.value))); + EXPECT_FLOAT_EQ(c.number->state, static_cast(c.value)); + this->uart_.tx.clear(); + this->feed_(ack(c.type)); + } +} + +// NumberCall does not round to the step, so the hub rejects a fraction and the number keeps the module's value +TEST_F(LD6004NumberTest, FractionalValueIsNotWrittenOrPublished) { + this->feed_(tf_frame(TYPE_REPORT_DWELL_LIFETIME, u32_payload(30))); + ASSERT_FLOAT_EQ(this->dwell_lifetime_.state, 30.0f); + this->uart_.tx.clear(); + this->dwell_lifetime_.make_call().set_value(12.5f).perform(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_FLOAT_EQ(this->dwell_lifetime_.state, 30.0f); +} + +TEST_F(LD6004NumberTest, ValueIsNotPublishedWhenQueueFull) { + this->feed_(tf_frame(TYPE_REPORT_DWELL_LIFETIME, u32_payload(30))); + this->fill_command_queue_([this] { return this->sut_.set_number_value(&this->output_interval_, 100); }); + this->dwell_lifetime_.make_call().set_value(60).perform(); + EXPECT_FLOAT_EQ(this->dwell_lifetime_.state, 30.0f); +} + +} // namespace esphome::ld6004::testing + +#endif // USE_HOST diff --git a/tests/components/ld6004/select/ld6004_select_test.cpp b/tests/components/ld6004/select/ld6004_select_test.cpp index 178797497ea..7ee4fa6fa9f 100644 --- a/tests/components/ld6004/select/ld6004_select_test.cpp +++ b/tests/components/ld6004/select/ld6004_select_test.cpp @@ -116,8 +116,7 @@ TEST_F(LD6004SelectTest, MalformedP20ModeReportIsIgnored) { // A full command queue drops the write, and the select keeps showing the module's mode TEST_F(LD6004SelectTest, WorkModeKeepsOptionWhenQueueFull) { this->feed_(work_mode(0)); - for (int i = 0; i < 32; i++) - this->p20_mode_.make_call().set_index(0).perform(); + this->fill_command_queue_([this] { return this->sut_.set_select_value(&this->p20_mode_, 0); }); this->work_mode_.make_call().set_option("low_power").perform(); EXPECT_EQ(this->work_mode_.current_option(), "normal"); } From e87e7abbcdf0767c01df8c7bcc9b7b7c8b33e208 Mon Sep 17 00:00:00 2001 From: Rick van Hattem Date: Sat, 31 Oct 2026 06:45:28 +0100 Subject: [PATCH 009/170] [ld6004] Add switch platform (#19845) --- esphome/components/ld6004/switch/__init__.py | 17 +++++++++++++++++ tests/component_tests/ld6004/test_platforms.py | 15 +++++++++++++++ tests/components/ld6004/common.yaml | 8 ++++++++ 3 files changed, 40 insertions(+) create mode 100644 esphome/components/ld6004/switch/__init__.py diff --git a/esphome/components/ld6004/switch/__init__.py b/esphome/components/ld6004/switch/__init__.py new file mode 100644 index 00000000000..90fd71e8986 --- /dev/null +++ b/esphome/components/ld6004/switch/__init__.py @@ -0,0 +1,17 @@ +from esphome.components import switch +from esphome.components.ld600x import entities +from esphome.types import ConfigType + +from .. import LD6004Component +from ..const import CONF_LD6004_ID + +DEPENDENCIES = ["ld6004"] + +CONFIG_SCHEMA = entities.switch_schema(LD6004Component, CONF_LD6004_ID) +FINAL_VALIDATE_SCHEMA = entities.unique_settings_final_validate( + switch.DOMAIN, CONF_LD6004_ID, "ld6004" +) + + +async def to_code(config: ConfigType) -> None: + await entities.switch_to_code(config, CONF_LD6004_ID) diff --git a/tests/component_tests/ld6004/test_platforms.py b/tests/component_tests/ld6004/test_platforms.py index c4b9c29d227..d3fcc41877f 100644 --- a/tests/component_tests/ld6004/test_platforms.py +++ b/tests/component_tests/ld6004/test_platforms.py @@ -8,6 +8,7 @@ from esphome.components.button import ApplyButton from esphome.components.ld6004 import LD6004Component from esphome.components.number import ApplyNumber from esphome.components.select import ApplySelect +from esphome.components.switch import ApplySwitch import esphome.config_validation as cv from esphome.const import PlatformFramework from esphome.core import ID @@ -76,6 +77,15 @@ def _cases( ("output_interval",), ("dwell_lifetime", "output_interval"), ), + *_cases( + "switch", + ApplySwitch, + "config", + (), + ("point_cloud",), + ("target_display",), + ("point_cloud", "target_display"), + ), ], ) def test_platform_keys( @@ -109,6 +119,11 @@ def test_platform_keys( "ld6002b_id", ), ("button", {"ld6004_id": "radar", "wake": {"name": "Wake"}}, "wake"), + ( + "switch", + {"ld6004_id": "radar", "low_power": {"name": "Low Power"}}, + "low_power", + ), ], ) def test_platform_rejects_key( diff --git a/tests/components/ld6004/common.yaml b/tests/components/ld6004/common.yaml index f05fd89766b..c7f28057367 100644 --- a/tests/components/ld6004/common.yaml +++ b/tests/components/ld6004/common.yaml @@ -33,6 +33,14 @@ button: clear_dwell: name: LD6004 Clear Dwell +switch: + - platform: ld6004 + ld6004_id: ld6004_radar + point_cloud: + name: LD6004 Point Cloud + target_display: + name: LD6004 Target Display + select: - platform: ld6004 ld6004_id: ld6004_radar From c50d09ee1d7eac82e6910c2bf9663bdece6554da Mon Sep 17 00:00:00 2001 From: John <34163498+CircuitSetup@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:05:01 -0400 Subject: [PATCH 010/170] [atm90e32] Verify gain calibration writes (#18731) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/atm90e32/atm90e32.cpp | 321 ++++---- esphome/components/atm90e32/atm90e32.h | 55 +- ...calibration_register_verification_test.cpp | 719 ++++++++++++++++++ .../offset_register_verification_test.cpp | 62 -- 4 files changed, 910 insertions(+), 247 deletions(-) create mode 100644 tests/components/atm90e32/calibration_register_verification_test.cpp delete mode 100644 tests/components/atm90e32/offset_register_verification_test.cpp diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 9c7fb2b273b..f9cfb068fd0 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -120,6 +120,10 @@ const char *ATM90E32Component::get_calibration_id_() { return this->instance_id_ void ATM90E32Component::setup() { this->spi_setup(); + this->configure_chip_(); +} + +void ATM90E32Component::configure_chip_() { const char *cs = this->get_calibration_id_(); char legacy_cs[GPIO_SUMMARY_MAX_LEN]; this->get_cs_summary_(legacy_cs); @@ -249,27 +253,23 @@ void ATM90E32Component::setup() { } this->restore_gain_calibrations_(); - - if (!this->using_saved_calibrations_) { - for (uint8_t phase = 0; phase < 3; ++phase) { - this->write16_(voltage_gain_registers[phase], this->phase_[phase].voltage_gain_); - this->write16_(current_gain_registers[phase], this->phase_[phase].ct_gain_); - } - } } else { ESP_LOGI(TAG, "[CALIBRATION][%s] Gain calibration is disabled. Using config file values.", cs); for (uint8_t phase = 0; phase < 3; ++phase) { - this->write16_(voltage_gain_registers[phase], this->phase_[phase].voltage_gain_); - this->write16_(current_gain_registers[phase], this->phase_[phase].ct_gain_); + this->gain_phase_[phase].voltage_gain = this->phase_[phase].voltage_gain_; + this->gain_phase_[phase].current_gain = this->phase_[phase].ct_gain_; } + this->write_gains_to_registers_(); } + // gain_phase_ holds the gains written to the chip above, restored from flash or from the config. // Sag threshold (78%) - uint16_t sagth = calculate_voltage_threshold(line_freq_, this->phase_[0].voltage_gain_, 0.78f); + uint16_t sagth = calculate_voltage_threshold(line_freq_, this->gain_phase_[0].voltage_gain, 0.78f); // Overvoltage threshold (122%) - uint16_t ovth = calculate_voltage_threshold(line_freq_, this->phase_[0].voltage_gain_, 1.22f); + uint16_t ovth = calculate_voltage_threshold(line_freq_, this->gain_phase_[0].voltage_gain, 1.22f); - // Write to registers + // Calibration helpers close config access before returning. + this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); this->write16_(ATM90E32_REGISTER_SAGTH, sagth); this->write16_(ATM90E32_REGISTER_OVTH, ovth); @@ -658,6 +658,8 @@ void ATM90E32Component::run_gain_calibrations() { cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs); + GainCalibration previous_gains[3] = {this->gain_phase_[0], this->gain_phase_[1], this->gain_phase_[2]}; + for (uint8_t phase = 0; phase < 3; phase++) { float measured_voltage = this->get_phase_voltage_avg_(phase); float measured_current = this->get_phase_current_avg_(phase); @@ -728,81 +730,66 @@ void ATM90E32Component::run_gain_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->save_gain_calibration_to_memory_(); this->write_gains_to_registers_(); - this->verify_gain_writes_(); + this->finish_gain_calibration_(previous_gains); } -void ATM90E32Component::save_gain_calibration_to_memory_() { +template +void ATM90E32Component::finish_calibration_(const LogString *name, const LogString *noun, + ESPPreferenceObject &preference, const T (&values)[3], bool writes_verified, + bool &has_stored, bool &restored, bool (&mismatches)[3], F &&rollback) { + static_assert(sizeof(values) == CALIBRATION_PAYLOAD_SIZE, "Calibration payload size mismatch"); const char *cs = this->get_calibration_id_(); - bool success = this->gain_calibration_pref_.save(&this->gain_phase_); - global_preferences->sync(); - if (success) { - this->using_saved_calibrations_ = true; - ESP_LOGI(TAG, "[CALIBRATION][%s] Gain calibration saved to memory.", cs); - } else { - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save gain calibration to memory!", cs); - } -} - -void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, - bool previous_using_saved, OffsetCalibrationType type) { - const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; - const char *cs = this->get_calibration_id_(); - const LogString *name = offset_calibration_name(power_offsets); - OffsetCalibration(*offsets)[3] = power_offsets ? &this->power_offset_phase_ : &this->offset_phase_; - ESPPreferenceObject *preference = power_offsets ? &this->power_offset_pref_ : &this->offset_pref_; - bool *has_stored = - power_offsets ? &this->has_stored_power_offset_calibration_ : &this->has_stored_offset_calibration_; - bool *restored = power_offsets ? &this->restored_power_offset_calibration_ : &this->restored_offset_calibration_; - bool *mismatches = power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_; - - const bool writes_verified = this->verify_offset_writes_(type); - bool saved = false; - bool synced = false; - if (writes_verified) { - saved = preference->save(offsets); - synced = global_preferences->sync(); + const bool previous_has_stored = has_stored; + const bool previous_restored = restored; + // Raw stored bytes, re-saved on rollback; all zero (the cleared sentinel) when nothing was stored. + uint8_t previous_stored[CALIBRATION_PAYLOAD_SIZE]{}; + bool attempt = writes_verified; + if (writes_verified && previous_has_stored && !preference.load(&previous_stored)) { + attempt = false; + ESP_LOGE(TAG, + "[CALIBRATION][%s] Failed to load stored %s calibration " + "values; refusing to overwrite them. " + "Clear stored %s calibrations and retry.", + cs, LOG_STR_ARG(noun), LOG_STR_ARG(noun)); } - if (writes_verified && saved && synced) { - this->using_saved_calibrations_ = true; - *has_stored = true; - *restored = true; - for (uint8_t phase = 0; phase < 3; phase++) - mismatches[phase] = false; + bool persisted = false; + if (attempt) { + const bool saved = preference.save(&values); + const bool synced = global_preferences->sync(); + persisted = saved && synced; + } + + if (persisted) { + has_stored = true; + restored = true; + for (bool &mismatch : mismatches) + mismatch = false; ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration saved to memory. %s calibration completed and verified.", cs, LOG_STR_ARG(name), LOG_STR_ARG(name)); return; } - if (writes_verified) { - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(name)); + if (attempt) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to save %s calibration to memory!", cs, LOG_STR_ARG(noun)); } - for (uint8_t phase = 0; phase < 3; phase++) { - this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type); - } - const bool rollback_verified = this->verify_offset_writes_(type); + const bool rollback_verified = rollback(); - bool rollback_persisted = false; - if (writes_verified) { - OffsetCalibration rollback[3]{}; - prepare_offset_rollback(previous, previous_restored, rollback); - const bool rollback_saved = preference->save(&rollback); + if (attempt) { + const bool rollback_saved = preference.save(&previous_stored); const bool rollback_synced = global_preferences->sync(); - rollback_persisted = rollback_saved && rollback_synced; if (!rollback_saved || !rollback_synced) { - ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(name)); + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to persist restored %s calibration values!", cs, LOG_STR_ARG(noun)); } } - *restored = previous_restored; - if (rollback_persisted) - *has_stored = previous_restored; - this->using_saved_calibrations_ = previous_using_saved; + restored = previous_restored && rollback_verified; + has_stored = previous_has_stored; if (!rollback_verified) { + for (bool &mismatch : mismatches) + mismatch = false; ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; rollback readback verification failed.", cs, LOG_STR_ARG(name)); return; @@ -810,6 +797,33 @@ void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&pre ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration failed; previous values restored.", cs, LOG_STR_ARG(name)); } +void ATM90E32Component::finish_gain_calibration_(const GainCalibration (&previous)[3]) { + this->finish_calibration_(LOG_STR("Gain"), LOG_STR("gain"), this->gain_calibration_pref_, this->gain_phase_, + this->verify_gain_writes_(), this->has_stored_gain_calibration_, + this->restored_gain_calibration_, this->gain_calibration_mismatch_, [this, &previous]() { + for (uint8_t phase = 0; phase < 3; phase++) + this->gain_phase_[phase] = previous[phase]; + this->write_gains_to_registers_(); + return this->verify_gain_writes_(); + }); +} + +void ATM90E32Component::finish_offset_calibration_(const OffsetCalibration (&previous)[3], OffsetCalibrationType type) { + const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + this->finish_calibration_( + offset_calibration_name(power_offsets), offset_calibration_name(power_offsets), + power_offsets ? this->power_offset_pref_ : this->offset_pref_, + power_offsets ? this->power_offset_phase_ : this->offset_phase_, this->verify_offset_writes_(type), + power_offsets ? this->has_stored_power_offset_calibration_ : this->has_stored_offset_calibration_, + power_offsets ? this->restored_power_offset_calibration_ : this->restored_offset_calibration_, + power_offsets ? this->power_offset_calibration_mismatch_ : this->offset_calibration_mismatch_, + [this, &previous, type]() { + for (uint8_t phase = 0; phase < 3; phase++) + this->write_offsets_to_registers_(phase, previous[phase].first_offset, previous[phase].second_offset, type); + return this->verify_offset_writes_(type); + }); +} + void ATM90E32Component::run_offset_calibrations() { const char *cs = this->get_calibration_id_(); if (!this->enable_offset_calibration_) { @@ -826,8 +840,6 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] ------------------------------------------------------------------", cs); OffsetCalibration previous_offsets[3] = {this->offset_phase_[0], this->offset_phase_[1], this->offset_phase_[2]}; - const bool previous_restored = this->restored_offset_calibration_; - const bool previous_using_saved = this->using_saved_calibrations_; for (uint8_t phase = 0; phase < 3; phase++) { int16_t voltage_offset = calibrate_offset(phase, true); @@ -842,8 +854,7 @@ void ATM90E32Component::run_offset_calibrations() { ESP_LOGI(TAG, "[CALIBRATION][%s] ==================================================================\n", cs); - this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, - OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); + this->finish_offset_calibration_(previous_offsets, OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT); } void ATM90E32Component::run_power_offset_calibrations() { @@ -864,8 +875,6 @@ void ATM90E32Component::run_power_offset_calibrations() { OffsetCalibration previous_offsets[3] = {this->power_offset_phase_[0], this->power_offset_phase_[1], this->power_offset_phase_[2]}; - const bool previous_restored = this->restored_power_offset_calibration_; - const bool previous_using_saved = this->using_saved_calibrations_; for (uint8_t phase = 0; phase < 3; ++phase) { int16_t active_offset = calibrate_power_offset(phase, false); @@ -879,8 +888,7 @@ void ATM90E32Component::run_power_offset_calibrations() { } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - this->finish_offset_calibration_(previous_offsets, previous_restored, previous_using_saved, - OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); + this->finish_offset_calibration_(previous_offsets, OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER); } void ATM90E32Component::write_gains_to_registers_() { @@ -925,52 +933,58 @@ void ATM90E32Component::restore_gain_calibrations_() { this->gain_phase_[i] = this->config_gain_phase_[i]; } - bool have_data = this->gain_calibration_pref_.load(&this->gain_phase_); - + const bool have_data = this->gain_calibration_pref_.load(&this->gain_phase_); + bool all_zero = true; + bool same_as_config = true; if (have_data) { - bool all_zero = true; - bool same_as_config = true; for (uint8_t phase = 0; phase < 3; ++phase) { - const auto &cfg = this->config_gain_phase_[phase]; + const auto &config = this->config_gain_phase_[phase]; const auto &saved = this->gain_phase_[phase]; if (saved.voltage_gain != 0 || saved.current_gain != 0) all_zero = false; - if (saved.voltage_gain != cfg.voltage_gain || saved.current_gain != cfg.current_gain) + if (saved.voltage_gain != config.voltage_gain || saved.current_gain != config.current_gain) same_as_config = false; } - - if (!all_zero && !same_as_config) { - for (uint8_t phase = 0; phase < 3; ++phase) { - bool mismatch = false; - if (this->has_config_voltage_gain_[phase] && - this->gain_phase_[phase].voltage_gain != this->config_gain_phase_[phase].voltage_gain) - mismatch = true; - if (this->has_config_current_gain_[phase] && - this->gain_phase_[phase].current_gain != this->config_gain_phase_[phase].current_gain) - mismatch = true; - if (mismatch) - this->gain_calibration_mismatch_[phase] = true; - } - - this->write_gains_to_registers_(); - - if (this->verify_gain_writes_()) { - this->using_saved_calibrations_ = true; - this->restored_gain_calibration_ = true; - return; - } - - this->using_saved_calibrations_ = false; - ESP_LOGE(TAG, "[CALIBRATION][%s] Gain verification failed! Calibration may not be applied correctly.", cs); - } } - this->using_saved_calibrations_ = false; - for (uint8_t i = 0; i < 3; ++i) - this->gain_phase_[i] = this->config_gain_phase_[i]; - this->write_gains_to_registers_(); + // A stored table equal to the config still counts as stored, so it is backed up and can be cleared; + // it is only not reported as restored from flash. + this->has_stored_gain_calibration_ = have_data && !all_zero; + if (!this->has_stored_gain_calibration_) { + for (uint8_t phase = 0; phase < 3; ++phase) + this->gain_phase_[phase] = this->config_gain_phase_[phase]; + ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); + } - ESP_LOGW(TAG, "[CALIBRATION][%s] No stored gain calibrations found. Using config file values.", cs); + this->write_gains_to_registers_(); + const bool initial_values_verified = this->verify_gain_writes_(); + if (initial_values_verified) { + for (uint8_t phase = 0; phase < 3; ++phase) { + this->gain_calibration_mismatch_[phase] = + this->has_stored_gain_calibration_ && + ((this->has_config_voltage_gain_[phase] && + this->gain_phase_[phase].voltage_gain != this->config_gain_phase_[phase].voltage_gain) || + (this->has_config_current_gain_[phase] && + this->gain_phase_[phase].current_gain != this->config_gain_phase_[phase].current_gain)); + } + this->restored_gain_calibration_ = this->has_stored_gain_calibration_ && !same_as_config; + return; + } + + for (uint8_t phase = 0; phase < 3; ++phase) { + this->gain_calibration_mismatch_[phase] = false; + this->gain_phase_[phase] = this->config_gain_phase_[phase]; + } + this->write_gains_to_registers_(); + const bool fallback_verified = this->verify_gain_writes_(); + this->restored_gain_calibration_ = false; + if (fallback_verified) { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs, + LOG_STR_LITERAL("gain")); + } else { + ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore and config fallback both failed verification.", cs, + LOG_STR_LITERAL("gain")); + } } void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) { @@ -1003,15 +1017,6 @@ void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) } *has_stored = have_data && !all_zero; - *restored = false; - for (uint8_t phase = 0; phase < 3; phase++) { - mismatches[phase] = false; - if (*has_stored) { - mismatches[phase] = - (has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) || - (has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset); - } - } if (!*has_stored) { for (uint8_t phase = 0; phase < 3; phase++) @@ -1024,22 +1029,26 @@ void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) } const bool initial_values_verified = this->verify_offset_writes_(type); if (initial_values_verified) { - const auto state = resolve_offset_restore_state(*has_stored, true, false); - *restored = state.restored; + for (uint8_t phase = 0; phase < 3; phase++) { + mismatches[phase] = + *has_stored && + ((has_first[phase] && (*offsets)[phase].first_offset != (*config_offsets)[phase].first_offset) || + (has_second[phase] && (*offsets)[phase].second_offset != (*config_offsets)[phase].second_offset)); + } + *restored = *has_stored; ESP_LOGI(TAG, "[CALIBRATION][%s] %s calibration values verified.", cs, LOG_STR_ARG(name)); return; } - this->using_saved_calibrations_ = false; for (uint8_t phase = 0; phase < 3; phase++) mismatches[phase] = false; for (uint8_t phase = 0; phase < 3; phase++) { (*offsets)[phase] = (*config_offsets)[phase]; this->write_offsets_to_registers_(phase, (*offsets)[phase].first_offset, (*offsets)[phase].second_offset, type); } - const auto state = resolve_offset_restore_state(*has_stored, false, this->verify_offset_writes_(type)); - *restored = state.restored; - if (state.values_verified) { + const bool fallback_verified = this->verify_offset_writes_(type); + *restored = false; + if (fallback_verified) { ESP_LOGE(TAG, "[CALIBRATION][%s] %s calibration restore failed verification; config values verified.", cs, LOG_STR_ARG(name)); } else { @@ -1048,9 +1057,22 @@ void ATM90E32Component::restore_offset_calibrations_(OffsetCalibrationType type) } } +bool ATM90E32Component::clear_calibration_pref_(ESPPreferenceObject &preference, bool &has_stored, bool &restored, + bool (&mismatches)[3]) { + const uint8_t cleared[CALIBRATION_PAYLOAD_SIZE]{}; + const bool saved = preference.save(&cleared); + const bool synced = global_preferences->sync(); + if (saved && synced) + has_stored = false; + restored = false; + for (bool &mismatch : mismatches) + mismatch = false; + return saved && synced; +} + void ATM90E32Component::clear_gain_calibrations() { const char *cs = this->get_calibration_id_(); - if (!this->using_saved_calibrations_) { + if (!this->has_stored_gain_calibration_) { ESP_LOGI(TAG, "[CALIBRATION][%s] No stored gain calibrations to clear. Current values:", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs); ESP_LOGI(TAG, "[CALIBRATION][%s] | Phase | voltage_gain | current_gain |", cs); @@ -1081,16 +1103,8 @@ void ATM90E32Component::clear_gain_calibrations() { } ESP_LOGI(TAG, "[CALIBRATION][%s] ==========================================================\n", cs); - GainCalibration zero_gains[3]{{0, 0}, {0, 0}, {0, 0}}; - bool success = this->gain_calibration_pref_.save(&zero_gains); - global_preferences->sync(); - - this->using_saved_calibrations_ = false; - this->restored_gain_calibration_ = false; - for (bool &phase : this->gain_calibration_mismatch_) - phase = false; - - if (!success) { + if (!this->clear_calibration_pref_(this->gain_calibration_pref_, this->has_stored_gain_calibration_, + this->restored_gain_calibration_, this->gain_calibration_mismatch_)) { ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to clear gain calibrations!", cs); } @@ -1129,16 +1143,12 @@ void ATM90E32Component::clear_offset_calibrations() { } ESP_LOGI(TAG, "[CALIBRATION][%s] ==============================================================\n", cs); - OffsetCalibration zero_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; - this->offset_pref_.save(&zero_offsets); // Clear stored values in flash - global_preferences->sync(); - - this->has_stored_offset_calibration_ = false; - this->restored_offset_calibration_ = false; - for (bool &phase : this->offset_calibration_mismatch_) - phase = false; - - ESP_LOGI(TAG, "[CALIBRATION][%s] Offsets cleared.", cs); + if (!this->clear_calibration_pref_(this->offset_pref_, this->has_stored_offset_calibration_, + this->restored_offset_calibration_, this->offset_calibration_mismatch_)) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to clear offset calibrations!", cs); + } else { + ESP_LOGI(TAG, "[CALIBRATION][%s] Offsets cleared.", cs); + } } void ATM90E32Component::clear_power_offset_calibrations() { @@ -1173,16 +1183,13 @@ void ATM90E32Component::clear_power_offset_calibrations() { } ESP_LOGI(TAG, "[CALIBRATION][%s] =====================================================================\n", cs); - OffsetCalibration zero_power_offsets[3]{{0, 0}, {0, 0}, {0, 0}}; - this->power_offset_pref_.save(&zero_power_offsets); - global_preferences->sync(); - - this->has_stored_power_offset_calibration_ = false; - this->restored_power_offset_calibration_ = false; - for (bool &phase : this->power_offset_calibration_mismatch_) - phase = false; - - ESP_LOGI(TAG, "[CALIBRATION][%s] Power offsets cleared.", cs); + if (!this->clear_calibration_pref_(this->power_offset_pref_, this->has_stored_power_offset_calibration_, + this->restored_power_offset_calibration_, + this->power_offset_calibration_mismatch_)) { + ESP_LOGE(TAG, "[CALIBRATION][%s] Failed to clear power offsets!", cs); + } else { + ESP_LOGI(TAG, "[CALIBRATION][%s] Power offsets cleared.", cs); + } } int16_t ATM90E32Component::calibrate_offset(uint8_t phase, bool voltage) { diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index 06902b4099f..ea6d5a93edb 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -22,31 +22,24 @@ struct OffsetCalibration { int16_t second_offset{0}; }; -static_assert(sizeof(OffsetCalibration[3]) == 12, "Offset calibration preference layout must remain compatible"); +struct GainCalibration { + uint16_t voltage_gain{1}; + uint16_t current_gain{1}; +}; + +// Stored size of each calibration table; an all-zero payload marks a table as cleared. +static constexpr size_t CALIBRATION_PAYLOAD_SIZE = 12; + +static_assert(sizeof(OffsetCalibration[3]) == CALIBRATION_PAYLOAD_SIZE, + "Offset calibration preference layout must remain compatible"); +static_assert(sizeof(GainCalibration[3]) == CALIBRATION_PAYLOAD_SIZE, + "Gain calibration preference layout must remain compatible"); enum class OffsetCalibrationType : uint8_t { OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, OFFSET_CALIBRATION_TYPE_POWER, }; -struct OffsetRestoreState { - bool restored; - bool values_verified; -}; - -inline OffsetRestoreState resolve_offset_restore_state(bool has_stored_values, bool initial_values_verified, - bool fallback_values_verified) { - if (initial_values_verified) - return {has_stored_values, true}; - return {false, fallback_values_verified}; -} - -inline void prepare_offset_rollback(const OffsetCalibration (&previous)[3], bool had_stored_values, - OffsetCalibration (&rollback)[3]) { - for (uint8_t phase = 0; phase < 3; phase++) - rollback[phase] = had_stored_values ? previous[phase] : OffsetCalibration{}; -} - class ATM90E32Component final : public PollingComponent, public spi::SPIDevice { @@ -156,7 +149,6 @@ class ATM90E32Component final : public PollingComponent, return 5.0f; // Default current #endif } - bool using_saved_calibrations_ = false; // Track if stored calibrations are being used #ifdef USE_TEXT_SENSOR void check_phase_status(); void check_freq_status(); @@ -205,9 +197,17 @@ class ATM90E32Component final : public PollingComponent, void set_publish_interval_flag_(bool flag) { publish_interval_flag_ = flag; }; void restore_offset_calibrations_(OffsetCalibrationType type); void restore_gain_calibrations_(); - void save_gain_calibration_to_memory_(); - void finish_offset_calibration_(const OffsetCalibration (&previous)[3], bool previous_restored, - bool previous_using_saved, OffsetCalibrationType type); + void finish_gain_calibration_(const GainCalibration (&previous)[3]); + void finish_offset_calibration_(const OffsetCalibration (&previous)[3], OffsetCalibrationType type); + // Persists verified register values; otherwise rollback() rewrites the previous values and returns their readback. + // name starts a sentence; noun is used mid-sentence, keeping the log text the calibration helper matches. + template + void finish_calibration_(const LogString *name, const LogString *noun, ESPPreferenceObject &preference, + const T (&values)[3], bool writes_verified, bool &has_stored, bool &restored, + bool (&mismatches)[3], F &&rollback); + bool clear_calibration_pref_(ESPPreferenceObject &preference, bool &has_stored, bool &restored, + bool (&mismatches)[3]); + void configure_chip_(); void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, OffsetCalibrationType type); void write_gains_to_registers_(); @@ -256,11 +256,7 @@ class ATM90E32Component final : public PollingComponent, OffsetCalibration power_offset_phase_[3]; OffsetCalibration config_power_offset_phase_[3]; - struct GainCalibration { - uint16_t voltage_gain{1}; - uint16_t current_gain{1}; - } gain_phase_[3]; - + GainCalibration gain_phase_[3]; GainCalibration config_gain_phase_[3]; bool has_config_voltage_offset_[3]{false, false, false}; @@ -274,6 +270,8 @@ class ATM90E32Component final : public PollingComponent, ESPPreferenceObject power_offset_pref_; ESPPreferenceObject gain_calibration_pref_; + friend class ATM90E32CalibrationTestAccess; + #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *phase_status_text_sensor_[3]{nullptr}; #endif @@ -287,6 +285,7 @@ class ATM90E32Component final : public PollingComponent, const char *instance_id_{nullptr}; bool has_stored_offset_calibration_{false}; bool has_stored_power_offset_calibration_{false}; + bool has_stored_gain_calibration_{false}; bool restored_offset_calibration_{false}; bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; diff --git a/tests/components/atm90e32/calibration_register_verification_test.cpp b/tests/components/atm90e32/calibration_register_verification_test.cpp new file mode 100644 index 00000000000..933df5ebbbc --- /dev/null +++ b/tests/components/atm90e32/calibration_register_verification_test.cpp @@ -0,0 +1,719 @@ +#include + +#include + +#include "esphome/components/atm90e32/atm90e32.h" +#ifdef USE_HOST +#include +#include +#include +#include + +#include "esphome/components/host/preferences.h" +#endif +#include "esphome/components/logger/logger.h" + +namespace esphome::atm90e32::testing { + +TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) { + EXPECT_TRUE(offset_register_value_matches(0x007B, 123)); + EXPECT_TRUE(offset_register_value_matches(0xFF85, -123)); +} + +TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) { + EXPECT_FALSE(offset_register_value_matches(0x007C, 123)); + EXPECT_FALSE(offset_register_value_matches(0xFF84, -123)); +} + +} // namespace esphome::atm90e32::testing + +namespace esphome::atm90e32 { + +#ifdef USE_HOST + +namespace fs = std::filesystem; + +template static std::string capture_calibration_logs(F action) { + const auto previous_baud_rate = logger::global_logger->get_baud_rate(); + ::testing::internal::CaptureStdout(); + logger::global_logger->set_baud_rate(115200); + action(); + logger::global_logger->set_baud_rate(previous_baud_rate); + return ::testing::internal::GetCapturedStdout(); +} + +class CalibrationRegisterDelegate final : public spi::SPIDelegate { + public: + explicit CalibrationRegisterDelegate(const ATM90E32Component &component) : component_(component) {} + + uint8_t transfer(uint8_t) override { return 0; } + + void transfer(uint8_t *data, size_t length) override { + if (length != 4 || (data[0] & 0x80) == 0) + return; + + const uint16_t address = static_cast(((data[0] & 0x03) << 8) | data[1]); + const uint16_t value = this->registers_[address]; + data[2] = static_cast(value >> 8); + data[3] = static_cast(value); + } + + void write_array(const uint8_t *data, size_t length) override { + if (length != 4) + return; + + const uint16_t address = static_cast(((data[0] & 0x03) << 8) | data[1]); + const uint16_t requested = static_cast((data[2] << 8) | data[3]); + if ((address == ATM90E32_REGISTER_SAGTH || address == ATM90E32_REGISTER_OVTH || + (address >= ATM90E32_REGISTER_UGAINA && address <= ATM90E32_REGISTER_IOFFSETC)) && + this->registers_[ATM90E32_REGISTER_CFGREGACCEN] != 0x55AA) { + this->registers_[ATM90E32_REGISTER_LASTSPIDATA] = requested; + return; + } + uint16_t value = requested; + if (this->is_calibration_register_(address)) { + if (this->drop_calibration_writes_ > 0) { + --this->drop_calibration_writes_; + this->registers_[ATM90E32_REGISTER_LASTSPIDATA] = requested; + return; + } else if (this->corrupt_calibration_writes_) { + value++; + } + } + this->registers_[address] = value; + this->registers_[ATM90E32_REGISTER_LASTSPIDATA] = requested; + } + + void set_gain_values(uint8_t phase, uint16_t voltage, uint16_t current) { + this->registers_[this->component_.voltage_gain_registers[phase]] = voltage; + this->registers_[this->component_.current_gain_registers[phase]] = current; + } + + void set_offset_values(OffsetCalibrationType type, uint8_t phase, int16_t first, int16_t second) { + const bool power = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; + const uint16_t *first_registers = + power ? this->component_.power_offset_registers : this->component_.voltage_offset_registers; + const uint16_t *second_registers = + power ? this->component_.reactive_power_offset_registers : this->component_.current_offset_registers; + this->registers_[first_registers[phase]] = static_cast(first); + this->registers_[second_registers[phase]] = static_cast(second); + } + + void drop_next_calibration_write() { this->drop_calibration_writes_++; } + void corrupt_calibration_writes() { this->corrupt_calibration_writes_ = true; } + + private: + bool is_calibration_register_(uint16_t address) const { + const auto &c = this->component_; + for (const uint16_t *table : + {c.voltage_gain_registers, c.current_gain_registers, c.voltage_offset_registers, c.current_offset_registers, + c.power_offset_registers, c.reactive_power_offset_registers}) { + for (uint8_t phase = 0; phase < 3; ++phase) { + if (address == table[phase]) + return true; + } + } + return false; + } + + const ATM90E32Component &component_; + std::array registers_{}; + uint8_t drop_calibration_writes_{0}; + bool corrupt_calibration_writes_{false}; +}; + +struct CalibrationTraits { + const char *name; + bool gain; + ESPPreferenceObject ATM90E32Component::*preference; + bool ATM90E32Component::*has_stored; + bool ATM90E32Component::*restored; + // A restored flag of another calibration kind, which this kind must leave alone. + bool ATM90E32Component::*other_restored; + bool (ATM90E32Component::*mismatches)[3]; + OffsetCalibration (ATM90E32Component::*offsets)[3]; + OffsetCalibrationType offset_type; + void (ATM90E32Component::*clear)(); +}; + +enum class SavedValue : uint8_t { SAVED_VALUE_OLD, SAVED_VALUE_NEW, SAVED_VALUE_ZERO }; + +static GainCalibration gain_values(SavedValue value, uint8_t phase) { + switch (value) { + case SavedValue::SAVED_VALUE_OLD: + return {static_cast(1000 + phase), static_cast(2000 + phase)}; + case SavedValue::SAVED_VALUE_NEW: + return {static_cast(300 + phase), static_cast(400 + phase)}; + case SavedValue::SAVED_VALUE_ZERO: + return {0, 0}; + } + return {0, 0}; +} + +static GainCalibration previous_gain_values(bool restored, uint8_t phase) { + if (restored) + return gain_values(SavedValue::SAVED_VALUE_OLD, phase); + return {static_cast(100 + phase), static_cast(200 + phase)}; +} + +static OffsetCalibration offset_values(SavedValue value, uint8_t phase) { + switch (value) { + case SavedValue::SAVED_VALUE_OLD: + return {static_cast(11 + phase), static_cast(-21 - phase)}; + case SavedValue::SAVED_VALUE_NEW: + return {static_cast(31 + phase), static_cast(-41 - phase)}; + case SavedValue::SAVED_VALUE_ZERO: + return {0, 0}; + } + return {0, 0}; +} + +static OffsetCalibration previous_offset_values(bool restored, uint8_t phase) { + if (restored) + return offset_values(SavedValue::SAVED_VALUE_OLD, phase); + return {static_cast(101 + phase), static_cast(-201 - phase)}; +} + +// Owns a component wired to a register-backed SPI delegate and to scratch host preferences, and reaches its +// protected members for the tests. +class ATM90E32CalibrationTestAccess { + public: + using C = ATM90E32Component; + static constexpr CalibrationTraits GAIN{"Gain", + true, + &C::gain_calibration_pref_, + &C::has_stored_gain_calibration_, + &C::restored_gain_calibration_, + &C::restored_power_offset_calibration_, + &C::gain_calibration_mismatch_, + nullptr, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, + &C::clear_gain_calibrations}; + static constexpr CalibrationTraits OFFSET{"Offset", + false, + &C::offset_pref_, + &C::has_stored_offset_calibration_, + &C::restored_offset_calibration_, + &C::restored_power_offset_calibration_, + &C::offset_calibration_mismatch_, + &C::offset_phase_, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_VOLTAGE_CURRENT, + &C::clear_offset_calibrations}; + static constexpr CalibrationTraits POWER_OFFSET{"PowerOffset", + false, + &C::power_offset_pref_, + &C::has_stored_power_offset_calibration_, + &C::restored_power_offset_calibration_, + &C::restored_offset_calibration_, + &C::power_offset_calibration_mismatch_, + &C::power_offset_phase_, + OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER, + &C::clear_power_offset_calibrations}; + + ATM90E32CalibrationTestAccess() + : previous_preferences_(global_preferences), previous_host_preferences_(host::host_preferences) { + const char *prefdir = getenv("ESPHOME_PREFDIR"); + if (prefdir != nullptr) { + this->had_prefdir_ = true; + this->previous_prefdir_ = prefdir; + } + + static uint32_t test_id = 0; + char directory[48]; + snprintf(directory, sizeof(directory), "esphome_atm90e32_%u", ++test_id); + this->prefdir_ = fs::temp_directory_path() / directory; + fs::create_directories(this->prefdir_); + this->allow_sync(); + global_preferences = &this->preferences_; + host::host_preferences = &this->preferences_; + + this->component.delegate_ = &this->delegate; + this->component.instance_id_ = "test"; + } + + ~ATM90E32CalibrationTestAccess() { + global_preferences = this->previous_preferences_; + host::host_preferences = this->previous_host_preferences_; + if (this->had_prefdir_) { + setenv("ESPHOME_PREFDIR", this->previous_prefdir_.c_str(), 1); + } else { + unsetenv("ESPHOME_PREFDIR"); + } + std::error_code ec; + fs::remove_all(this->prefdir_, ec); + } + + // Call before the first preference is made so every sync fails until allow_sync(). + void fail_sync() { setenv("ESPHOME_PREFDIR", "/dev/null", 1); } + void allow_sync() { setenv("ESPHOME_PREFDIR", this->prefdir_.string().c_str(), 1); } + + uint16_t read16(uint16_t reg) { return this->component.read16_(reg); } + void write16(uint16_t reg, uint16_t value) { this->component.write16_(reg, value); } + void configure_chip() { this->component.configure_chip_(); } + + ESPPreferenceObject &preference(const CalibrationTraits &kind) { return this->component.*kind.preference; } + + ESPPreferenceObject &make_preference(const CalibrationTraits &kind, uint32_t key = next_preference_key()) { + auto &preference = this->preference(kind); + if (kind.gain) { + preference = global_preferences->make_preference(key, true); + } else { + preference = global_preferences->make_preference(key, true); + } + return preference; + } + + void set_state(const CalibrationTraits &kind, bool has_stored, bool restored) { + this->component.*kind.has_stored = has_stored; + this->component.*kind.restored = restored; + } + + void set_new_calibration(const CalibrationTraits &kind) { + for (uint8_t phase = 0; phase < 3; ++phase) { + if (kind.gain) { + const auto value = gain_values(SavedValue::SAVED_VALUE_NEW, phase); + this->component.gain_phase_[phase] = value; + this->delegate.set_gain_values(phase, value.voltage_gain, value.current_gain); + } else { + const auto value = offset_values(SavedValue::SAVED_VALUE_NEW, phase); + this->component.write_offsets_to_registers_(phase, value.first_offset, value.second_offset, kind.offset_type); + this->delegate.set_offset_values(kind.offset_type, phase, value.first_offset, value.second_offset); + } + } + } + + ESPPreferenceObject &prepare_finish(const CalibrationTraits &kind, uint32_t key = next_preference_key()) { + auto &preference = this->make_preference(kind, key); + this->set_new_calibration(kind); + return preference; + } + + void finish(const CalibrationTraits &kind, bool previous_restored) { + if (kind.gain) { + GainCalibration previous[3]; + for (uint8_t phase = 0; phase < 3; ++phase) + previous[phase] = previous_gain_values(previous_restored, phase); + this->component.finish_gain_calibration_(previous); + return; + } + OffsetCalibration previous[3]; + for (uint8_t phase = 0; phase < 3; ++phase) + previous[phase] = previous_offset_values(previous_restored, phase); + this->component.finish_offset_calibration_(previous, kind.offset_type); + } + + void restore(const CalibrationTraits &kind) { + if (kind.gain) { + this->component.restore_gain_calibrations_(); + } else { + this->component.restore_offset_calibrations_(kind.offset_type); + } + } + + void expect_previous_runtime_values(const CalibrationTraits &kind, bool restored = false) { + for (uint8_t phase = 0; phase < 3; ++phase) { + if (kind.gain) { + const auto expected = previous_gain_values(restored, phase); + EXPECT_EQ(this->component.gain_phase_[phase].voltage_gain, expected.voltage_gain); + EXPECT_EQ(this->component.gain_phase_[phase].current_gain, expected.current_gain); + } else { + const auto expected = previous_offset_values(restored, phase); + const auto &actual = (this->component.*kind.offsets)[phase]; + EXPECT_EQ(actual.first_offset, expected.first_offset); + EXPECT_EQ(actual.second_offset, expected.second_offset); + } + } + } + + void expect_mismatches_cleared(const CalibrationTraits &kind) { + for (const bool mismatch : this->component.*kind.mismatches) + EXPECT_FALSE(mismatch); + } + + static uint32_t next_preference_key() { + static uint32_t key = 0x600000; + return key++; + } + + private: + host::HostPreferences preferences_; + ESPPreferences *previous_preferences_; + host::HostPreferences *previous_host_preferences_; + fs::path prefdir_; + std::string previous_prefdir_; + bool had_prefdir_{false}; + + public: + ATM90E32Component component; + CalibrationRegisterDelegate delegate{component}; +}; + +namespace testing { + +using Access = ATM90E32CalibrationTestAccess; +static constexpr CalibrationTraits CALIBRATIONS[]{Access::GAIN, Access::OFFSET, Access::POWER_OFFSET}; + +static void save_values(ESPPreferenceObject &preference, const CalibrationTraits &kind, SavedValue value) { + if (kind.gain) { + GainCalibration values[3]; + for (uint8_t phase = 0; phase < 3; ++phase) + values[phase] = gain_values(value, phase); + ASSERT_TRUE(preference.save(&values)); + return; + } + + OffsetCalibration values[3]; + for (uint8_t phase = 0; phase < 3; ++phase) + values[phase] = offset_values(value, phase); + ASSERT_TRUE(preference.save(&values)); +} + +static void expect_values(ESPPreferenceObject &preference, const CalibrationTraits &kind, SavedValue value) { + if (kind.gain) { + GainCalibration actual[3]{}; + ASSERT_TRUE(preference.load(&actual)); + for (uint8_t phase = 0; phase < 3; ++phase) { + const auto expected = gain_values(value, phase); + EXPECT_EQ(actual[phase].voltage_gain, expected.voltage_gain); + EXPECT_EQ(actual[phase].current_gain, expected.current_gain); + } + return; + } + + OffsetCalibration actual[3]{}; + ASSERT_TRUE(preference.load(&actual)); + for (uint8_t phase = 0; phase < 3; ++phase) { + const auto expected = offset_values(value, phase); + EXPECT_EQ(actual[phase].first_offset, expected.first_offset); + EXPECT_EQ(actual[phase].second_offset, expected.second_offset); + } +} + +TEST(ATM90E32GainCalibration, FailedRollbackDoesNotReportVerifiedGainsOnReconnect) { + const auto &kind = Access::GAIN; + for (const bool mismatch : {false, true}) { + for (uint8_t offsets = 0; offsets < 4; offsets++) { + SCOPED_TRACE(::testing::Message() << "mismatch=" << mismatch << ", offsets=" << +offsets); + Access access; + auto &component = access.component; + component.set_enable_gain_calibration(true); + component.set_enable_offset_calibration(true); + access.set_state(kind, true, true); + component.*Access::OFFSET.restored = (offsets & 1) != 0; + component.*Access::POWER_OFFSET.restored = (offsets & 2) != 0; + for (bool &phase : component.*kind.mismatches) + phase = mismatch; + access.delegate.corrupt_calibration_writes(); + + access.finish(kind, true); + + EXPECT_FALSE(component.*kind.restored); + EXPECT_TRUE(component.*kind.has_stored); + EXPECT_EQ(component.*Access::OFFSET.restored, (offsets & 1) != 0); + EXPECT_EQ(component.*Access::POWER_OFFSET.restored, (offsets & 2) != 0); + access.expect_mismatches_cleared(kind); + + const auto output = capture_calibration_logs([&]() { component.dump_config(); }); + EXPECT_EQ(output.find("Gain calibration loaded and verified successfully."), std::string::npos); + EXPECT_EQ(output.find("Gain mismatch: using flash values"), std::string::npos); + EXPECT_EQ(output.find("Restoring saved gain calibrations to registers"), std::string::npos); + } + } +} + +TEST(ATM90E32RegisterAccess, HandlesFullTenBitAddress) { + Access access; + + access.write16(0x3FF, 0xABCD); + + EXPECT_EQ(access.read16(0x3FF), 0xABCD); +} + +TEST(ATM90E32Setup, ConfiguresGainsAndThresholdsWithCalibrationEnabledOrDisabled) { + for (const bool offsets : {false, true}) { + for (const bool gains : {false, true}) { + SCOPED_TRACE(::testing::Message() << "offsets=" << offsets << ", gains=" << gains); + Access access; + auto &component = access.component; + spi::NullPin cs; + component.set_cs_pin(&cs); + component.set_enable_offset_calibration(offsets); + component.set_enable_gain_calibration(gains); + for (uint8_t phase = 0; phase < 3; ++phase) { + component.set_volt_gain(phase, 7305); + component.set_ct_gain(phase, 27518); + } + + access.configure_chip(); + + ASSERT_FALSE(component.is_failed()); + for (uint8_t phase = 0; phase < 3; ++phase) { + EXPECT_EQ(access.read16(component.voltage_gain_registers[phase]), 7305); + EXPECT_EQ(access.read16(component.current_gain_registers[phase]), 27518); + } + EXPECT_NE(access.read16(ATM90E32_REGISTER_SAGTH), 0); + EXPECT_NE(access.read16(ATM90E32_REGISTER_OVTH), 0); + EXPECT_EQ(access.read16(ATM90E32_REGISTER_CFGREGACCEN), 0); + } + } +} + +TEST(ATM90E32Setup, ThresholdsUseRestoredGains) { + Access access; + auto &component = access.component; + spi::NullPin cs; + component.set_cs_pin(&cs); + component.set_enable_gain_calibration(true); + GainCalibration stored[3]; + for (uint8_t phase = 0; phase < 3; ++phase) { + component.set_volt_gain(phase, 7305); + component.set_ct_gain(phase, 27518); + stored[phase] = {9000, 27518}; + } + auto preference = global_preferences->make_preference( + fnv1_hash_extend(fnv1_hash("_gain_calibration_"), "test"), true); + ASSERT_TRUE(preference.save(&stored)); + + access.configure_chip(); + + EXPECT_EQ(access.read16(component.voltage_gain_registers[0]), 9000); + EXPECT_EQ(access.read16(ATM90E32_REGISTER_SAGTH), component.calculate_voltage_threshold(60, 9000, 0.78f)); + EXPECT_EQ(access.read16(ATM90E32_REGISTER_OVTH), component.calculate_voltage_threshold(60, 9000, 1.22f)); +} + +class ATM90E32CalibrationPersistence : public ::testing::TestWithParam {}; +class ATM90E32OffsetPersistence : public ATM90E32CalibrationPersistence {}; + +INSTANTIATE_TEST_SUITE_P(CalibrationKinds, ATM90E32CalibrationPersistence, ::testing::ValuesIn(CALIBRATIONS), + [](const auto &info) { return info.param.name; }); +INSTANTIATE_TEST_SUITE_P(CalibrationKinds, ATM90E32OffsetPersistence, + ::testing::Values(Access::OFFSET, Access::POWER_OFFSET), + [](const auto &info) { return info.param.name; }); + +TEST_P(ATM90E32CalibrationPersistence, RestoreVerifiesStoredValuesAndConfigFallback) { + const auto &kind = GetParam(); + for (const bool stored : {false, true}) { + for (const bool corrupt : {false, true}) { + for (const bool drop : {false, true}) { + SCOPED_TRACE(::testing::Message() << "stored=" << stored << ", corrupt=" << corrupt << ", drop=" << drop); + Access access; + auto &component = access.component; + auto &preference = access.make_preference(kind); + save_values(preference, kind, stored ? SavedValue::SAVED_VALUE_OLD : SavedValue::SAVED_VALUE_ZERO); + for (uint8_t phase = 0; phase < 3; ++phase) { + if (kind.gain) { + component.set_volt_gain(phase, 100 + phase); + component.set_ct_gain(phase, 200 + phase); + } else { + (component.*kind.offsets)[phase] = previous_offset_values(false, phase); + } + } + if (corrupt) + access.delegate.corrupt_calibration_writes(); + if (drop) + access.delegate.drop_next_calibration_write(); + + const auto output = capture_calibration_logs([&]() { access.restore(kind); }); + + const bool restored = stored && !corrupt && !drop; + EXPECT_EQ(component.*kind.has_stored, stored); + EXPECT_EQ(component.*kind.restored, restored); + expect_values(preference, kind, stored ? SavedValue::SAVED_VALUE_OLD : SavedValue::SAVED_VALUE_ZERO); + if (!restored) + access.expect_previous_runtime_values(kind); + if (corrupt) { + EXPECT_NE(output.find("config fallback both failed verification"), std::string::npos); + } else if (drop) { + EXPECT_NE(output.find("config values verified"), std::string::npos); + } + } + } + } +} + +TEST_P(ATM90E32CalibrationPersistence, FailedPersistencePreservesStoredPayloadAndPreviousRuntimeState) { + const auto &kind = GetParam(); + for (const bool restored : {false, true}) { + SCOPED_TRACE(restored); + Access access; + access.fail_sync(); + auto &preference = access.prepare_finish(kind); + save_values(preference, kind, SavedValue::SAVED_VALUE_OLD); + access.set_state(kind, true, restored); + + const auto output = capture_calibration_logs([&]() { access.finish(kind, restored); }); + + expect_values(preference, kind, SavedValue::SAVED_VALUE_OLD); + access.expect_previous_runtime_values(kind, restored); + EXPECT_TRUE(access.component.*kind.has_stored); + EXPECT_EQ(access.component.*kind.restored, restored); + EXPECT_EQ(output.find("completed and verified"), std::string::npos); + // The CircuitSetup calibration helper matches this line, so its text must not change. + if (kind.gain) + EXPECT_NE(output.find("Failed to save gain calibration to memory!"), std::string::npos); + } +} + +TEST_P(ATM90E32CalibrationPersistence, StoredPayloadLoadFailureRefusesOverwrite) { + const auto &kind = GetParam(); + Access access; + const uint32_t key = Access::next_preference_key(); + access.prepare_finish(kind, key); + auto raw_preference = global_preferences->make_preference(key, true); + constexpr uint32_t raw_value = 0xA5A55A5A; + ASSERT_TRUE(raw_preference.save(&raw_value)); + ASSERT_TRUE(global_preferences->sync()); + access.set_state(kind, true, false); + + const auto output = capture_calibration_logs([&]() { access.finish(kind, false); }); + + uint32_t actual = 0; + ASSERT_TRUE(raw_preference.load(&actual)); + EXPECT_EQ(actual, raw_value); + EXPECT_TRUE(access.component.*kind.has_stored); + EXPECT_FALSE(access.component.*kind.restored); + EXPECT_NE(output.find("refusing to overwrite them"), std::string::npos); + EXPECT_EQ(output.find("completed and verified"), std::string::npos); +} + +TEST_P(ATM90E32CalibrationPersistence, FreshPersistenceUsesZeroSentinelAndCanRetry) { + const auto &kind = GetParam(); + Access access; + access.fail_sync(); + auto &preference = access.prepare_finish(kind); + access.set_state(kind, false, false); + + const auto output = capture_calibration_logs([&]() { access.finish(kind, false); }); + + expect_values(preference, kind, SavedValue::SAVED_VALUE_ZERO); + EXPECT_FALSE(access.component.*kind.has_stored); + EXPECT_FALSE(access.component.*kind.restored); + EXPECT_EQ(output.find("completed and verified"), std::string::npos); + + access.allow_sync(); + access.set_new_calibration(kind); + const auto retry_output = capture_calibration_logs([&]() { access.finish(kind, false); }); + + expect_values(preference, kind, SavedValue::SAVED_VALUE_NEW); + EXPECT_TRUE(access.component.*kind.has_stored); + EXPECT_TRUE(access.component.*kind.restored); + EXPECT_NE(retry_output.find("completed and verified"), std::string::npos); +} + +static void check_clear_retry(const CalibrationTraits &kind, bool other_restored) { + Access access; + auto &component = access.component; + access.fail_sync(); + auto &preference = access.prepare_finish(kind); + save_values(preference, kind, SavedValue::SAVED_VALUE_OLD); + access.set_state(kind, true, true); + component.*kind.other_restored = other_restored; + (component.*kind.mismatches)[1] = true; + + const auto first_output = capture_calibration_logs([&]() { (component.*kind.clear)(); }); + + EXPECT_TRUE(component.*kind.has_stored); + EXPECT_FALSE(component.*kind.restored); + EXPECT_EQ(component.*kind.other_restored, other_restored); + access.expect_mismatches_cleared(kind); + EXPECT_NE(first_output.find("Failed to clear"), std::string::npos); + EXPECT_EQ(first_output.find("Offsets cleared."), std::string::npos); + EXPECT_EQ(first_output.find("Power offsets cleared."), std::string::npos); + + access.allow_sync(); + const auto second_output = capture_calibration_logs([&]() { (component.*kind.clear)(); }); + + EXPECT_FALSE(component.*kind.has_stored); + EXPECT_FALSE(component.*kind.restored); + EXPECT_EQ(component.*kind.other_restored, other_restored); + expect_values(preference, kind, SavedValue::SAVED_VALUE_ZERO); + EXPECT_EQ(second_output.find("Failed to clear"), std::string::npos); +} + +TEST_P(ATM90E32CalibrationPersistence, ClearFailureRetainsRetryEligibility) { + check_clear_retry(GetParam(), false); + check_clear_retry(GetParam(), true); +} + +TEST_P(ATM90E32OffsetPersistence, RollbackReadbackFailureInvalidatesState) { + const auto &kind = GetParam(); + Access access; + auto &component = access.component; + access.fail_sync(); + auto &preference = access.prepare_finish(kind); + save_values(preference, kind, SavedValue::SAVED_VALUE_OLD); + access.set_state(kind, true, true); + component.*kind.other_restored = true; + for (bool &mismatch : component.*kind.mismatches) + mismatch = true; + access.delegate.corrupt_calibration_writes(); + + const auto output = capture_calibration_logs([&]() { access.finish(kind, true); }); + + EXPECT_TRUE(component.*kind.has_stored); + EXPECT_FALSE(component.*kind.restored); + EXPECT_TRUE(component.*kind.other_restored); + access.expect_mismatches_cleared(kind); + EXPECT_NE(output.find("rollback readback verification failed"), std::string::npos); + EXPECT_EQ(output.find("completed and verified"), std::string::npos); +} + +TEST(ATM90E32CalibrationPersistence, OffsetRestoreFallbackPreservesOtherSavedCategories) { + Access access; + auto &component = access.component; + save_values(access.make_preference(Access::OFFSET), Access::OFFSET, SavedValue::SAVED_VALUE_OLD); + component.*Access::POWER_OFFSET.restored = true; + component.*Access::GAIN.restored = true; + for (uint8_t phase = 0; phase < 3; ++phase) { + component.set_voltage_offset(phase, static_cast(101 + phase)); + component.set_current_offset(phase, static_cast(-201 - phase)); + } + access.delegate.drop_next_calibration_write(); + + const auto output = capture_calibration_logs([&]() { access.restore(Access::OFFSET); }); + + EXPECT_FALSE(component.*Access::OFFSET.restored); + EXPECT_TRUE(component.*Access::POWER_OFFSET.restored); + EXPECT_TRUE(component.*Access::GAIN.restored); + access.expect_mismatches_cleared(Access::OFFSET); + EXPECT_NE(output.find("config values verified"), std::string::npos); +} + +TEST(ATM90E32CalibrationPersistence, GainTableEqualToConfigIsBackedUpAndCleared) { + const auto &kind = Access::GAIN; + Access access; + auto &component = access.component; + access.fail_sync(); + auto &preference = access.make_preference(kind); + save_values(preference, kind, SavedValue::SAVED_VALUE_OLD); + for (uint8_t phase = 0; phase < 3; ++phase) { + const auto value = gain_values(SavedValue::SAVED_VALUE_OLD, phase); + component.set_volt_gain(phase, value.voltage_gain); + component.set_ct_gain(phase, value.current_gain); + } + + access.restore(kind); + + EXPECT_TRUE(component.*kind.has_stored); + EXPECT_FALSE(component.*kind.restored); + + access.set_new_calibration(kind); + access.finish(kind, false); + + EXPECT_TRUE(component.*kind.has_stored); + expect_values(preference, kind, SavedValue::SAVED_VALUE_OLD); + + access.allow_sync(); + const auto output = capture_calibration_logs([&]() { component.clear_gain_calibrations(); }); + + EXPECT_FALSE(component.*kind.has_stored); + expect_values(preference, kind, SavedValue::SAVED_VALUE_ZERO); + EXPECT_EQ(output.find("No stored gain calibrations to clear"), std::string::npos); + EXPECT_EQ(output.find("Failed to clear"), std::string::npos); +} + +} // namespace testing + +#endif // USE_HOST + +} // namespace esphome::atm90e32 diff --git a/tests/components/atm90e32/offset_register_verification_test.cpp b/tests/components/atm90e32/offset_register_verification_test.cpp deleted file mode 100644 index 3bb3eb76ea9..00000000000 --- a/tests/components/atm90e32/offset_register_verification_test.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include - -#include "esphome/components/atm90e32/atm90e32.h" - -namespace esphome::atm90e32::testing { - -TEST(ATM90E32OffsetRegisterVerification, AcceptsExactSignedReadback) { - EXPECT_TRUE(offset_register_value_matches(0x007B, 123)); - EXPECT_TRUE(offset_register_value_matches(0xFF85, -123)); -} - -TEST(ATM90E32OffsetRegisterVerification, RejectsMismatchedReadback) { - EXPECT_FALSE(offset_register_value_matches(0x007C, 123)); - EXPECT_FALSE(offset_register_value_matches(0xFF84, -123)); -} - -TEST(ATM90E32OffsetRestoreState, ReportsVerifiedStoredValuesAsRestored) { - const auto state = resolve_offset_restore_state(true, true, false); - - EXPECT_TRUE(state.restored); - EXPECT_TRUE(state.values_verified); -} - -TEST(ATM90E32OffsetRestoreState, ReportsVerifiedConfigFallbackAsNotRestored) { - const auto state = resolve_offset_restore_state(true, false, true); - - EXPECT_FALSE(state.restored); - EXPECT_TRUE(state.values_verified); -} - -TEST(ATM90E32OffsetRestoreState, ReportsFailedConfigFallbackAsUnverified) { - const auto state = resolve_offset_restore_state(true, false, false); - - EXPECT_FALSE(state.restored); - EXPECT_FALSE(state.values_verified); -} - -TEST(ATM90E32OffsetRestoreState, ReportsConfigWithoutStoredValuesAsNotRestored) { - const auto state = resolve_offset_restore_state(false, true, false); - - EXPECT_FALSE(state.restored); - EXPECT_TRUE(state.values_verified); -} - -TEST(ATM90E32OffsetPersistence, RollsBackStoredValuesOrZeroSentinel) { - const OffsetCalibration previous[3]{{1, -1}, {2, -2}, {3, -3}}; - OffsetCalibration rollback[3]{}; - - prepare_offset_rollback(previous, true, rollback); - for (uint8_t phase = 0; phase < 3; phase++) { - EXPECT_EQ(rollback[phase].first_offset, previous[phase].first_offset); - EXPECT_EQ(rollback[phase].second_offset, previous[phase].second_offset); - } - - prepare_offset_rollback(previous, false, rollback); - for (const auto &phase : rollback) { - EXPECT_EQ(phase.first_offset, 0); - EXPECT_EQ(phase.second_offset, 0); - } -} - -} // namespace esphome::atm90e32::testing From 1f70f86b471645a99b9fe0bd45d28c066c0c7bfd Mon Sep 17 00:00:00 2001 From: John <34163498+CircuitSetup@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:50:06 -0400 Subject: [PATCH 011/170] [atm90e32] Add configurable thresholds (#15244) --- esphome/components/atm90e32/atm90e32.cpp | 160 +++++---- esphome/components/atm90e32/atm90e32.h | 30 +- esphome/components/atm90e32/sensor.py | 191 ++++++++++- tests/components/atm90e32/__init__.py | 10 +- ...calibration_register_verification_test.cpp | 10 +- tests/components/atm90e32/common.yaml | 7 + tests/components/atm90e32/threshold_test.cpp | 320 ++++++++++++++++++ .../components/atm90e32/test_sensor.py | 283 ++++++++++++++++ 8 files changed, 944 insertions(+), 67 deletions(-) create mode 100644 tests/components/atm90e32/threshold_test.cpp create mode 100644 tests/unit_tests/components/atm90e32/test_sensor.py diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index f9cfb068fd0..5afc332ec33 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -1,8 +1,10 @@ #include "atm90e32.h" +#include #include #include #include #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::atm90e32 { @@ -107,7 +109,6 @@ void ATM90E32Component::update() { #ifdef USE_TEXT_SENSOR this->check_phase_status(); - this->check_over_current(); this->check_freq_status(); #endif } @@ -130,17 +131,9 @@ void ATM90E32Component::configure_chip_() { const bool has_distinct_legacy_namespace = strcmp(cs, legacy_cs) != 0; uint16_t mmode0 = 0x87; // 3P4W 50Hz - uint16_t high_thresh = 0; - uint16_t low_thresh = 0; if (line_freq_ == 60) { mmode0 |= 1 << 12; // sets 12th bit to 1, 60Hz - // for freq threshold registers - high_thresh = 6300; // 63.00 Hz - low_thresh = 5700; // 57.00 Hz - } else { - high_thresh = 5300; // 53.00 Hz - low_thresh = 4700; // 47.00 Hz } if (current_phases_ == 2) { @@ -164,8 +157,6 @@ void ATM90E32Component::configure_chip_() { this->write16_(ATM90E32_REGISTER_ZXCONFIG, 0xD654); // Zero crossing (ZX2, ZX1, ZX0) pin config this->write16_(ATM90E32_REGISTER_MMODE0, mmode0); // Mode Config (frequency set in main program) this->write16_(ATM90E32_REGISTER_MMODE1, pga_gain_); // PGA Gain Configuration for Current Channels - this->write16_(ATM90E32_REGISTER_FREQHITH, high_thresh); // Frequency high threshold - this->write16_(ATM90E32_REGISTER_FREQLOTH, low_thresh); // Frequency low threshold this->write16_(ATM90E32_REGISTER_PSTARTTH, 0x1D4C); // All Active Startup Power Threshold - 0.02A/0.00032 = 7500 this->write16_(ATM90E32_REGISTER_QSTARTTH, 0x1D4C); // All Reactive Startup Power Threshold - 50% this->write16_(ATM90E32_REGISTER_SSTARTTH, 0x1D4C); // All Reactive Startup Power Threshold - 50% @@ -262,17 +253,10 @@ void ATM90E32Component::configure_chip_() { this->write_gains_to_registers_(); } - // gain_phase_ holds the gains written to the chip above, restored from flash or from the config. - // Sag threshold (78%) - uint16_t sagth = calculate_voltage_threshold(line_freq_, this->gain_phase_[0].voltage_gain, 0.78f); - // Overvoltage threshold (122%) - uint16_t ovth = calculate_voltage_threshold(line_freq_, this->gain_phase_[0].voltage_gain, 1.22f); - - // Calibration helpers close config access before returning. this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x55AA); - this->write16_(ATM90E32_REGISTER_SAGTH, sagth); - this->write16_(ATM90E32_REGISTER_OVTH, ovth); - + this->write16_(ATM90E32_REGISTER_FREQHITH, this->frequency_high_threshold_); + this->write16_(ATM90E32_REGISTER_FREQLOTH, this->frequency_low_threshold_); + // SAGTH, OVTH and OITH were written with the gains, by write_gains_to_registers_(). this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); // end configuration } @@ -899,9 +883,56 @@ void ATM90E32Component::write_gains_to_registers_() { this->write16_(current_gain_registers[phase], this->gain_phase_[phase].current_gain); } + this->write_thresholds_(); this->write16_(ATM90E32_REGISTER_CFGREGACCEN, 0x0000); } +void ATM90E32Component::write_thresholds_() { + static constexpr uint16_t LEGACY_OITH = 0xC000; + struct GainRange { + uint16_t min{UINT16_MAX}; + uint16_t max{0}; + }; + // Reads back the gain registers of the configured phases; {0, 0} when none are configured. + auto read_gain_range = [this](const bool (&configured)[3], const uint16_t (®isters)[3]) { + GainRange range; + for (uint8_t phase = 0; phase < 3; phase++) { + if (configured[phase]) { + const uint16_t gain = this->read16_(registers[phase]); + range.min = std::min(range.min, gain); + range.max = std::max(range.max, gain); + } + } + if (range.max == 0) + range.min = 0; + return range; + }; + const GainRange voltage = read_gain_range(this->has_config_voltage_gain_, this->voltage_gain_registers); + const GainRange current = read_gain_range(this->has_config_current_gain_, this->current_gain_registers); + const bool current_configured = !std::isnan(this->threshold_current_peak_a_); +#ifdef USE_TEXT_SENSOR + this->use_voltage_threshold_flags_ = voltage.min == voltage.max; + if (!this->use_voltage_threshold_flags_) { + ESP_LOGW(TAG, "Phase voltage gains differ; using per-phase RMS voltage alarms"); + } + // The shared OITH is encoded at the highest gain, so a lower-gain phase would flag below current_peak. + this->use_current_threshold_flags_ = current.min == current.max; + if (!this->use_current_threshold_flags_ && current_configured) { + ESP_LOGW(TAG, "Phase current gains differ; using per-phase RMS over-current alarms"); + } +#endif + + const uint16_t sagth = calculate_voltage_threshold_from_volts(this->threshold_voltage_sag_v_, voltage.min); + const uint16_t ovth = calculate_voltage_threshold_from_volts(this->threshold_voltage_peak_v_, voltage.max); + const uint16_t oith = current_configured + ? calculate_current_threshold_from_amps(this->threshold_current_peak_a_, current.max) + : LEGACY_OITH; + ESP_LOGD(TAG, "Thresholds: SAGTH=0x%04" PRIX16 ", OVTH=0x%04" PRIX16 ", OITH=0x%04" PRIX16, sagth, ovth, oith); + this->write16_(ATM90E32_REGISTER_SAGTH, sagth); + this->write16_(ATM90E32_REGISTER_OVTH, ovth); + this->write16_(ATM90E32_REGISTER_OITH, oith); +} + void ATM90E32Component::write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, OffsetCalibrationType type) { const bool power_offsets = type == OffsetCalibrationType::OFFSET_CALIBRATION_TYPE_POWER; @@ -1266,28 +1297,45 @@ bool ATM90E32Component::verify_offset_writes_(OffsetCalibrationType type) { #ifdef USE_TEXT_SENSOR void ATM90E32Component::check_phase_status() { + static constexpr uint16_t OVER_CURRENT_FLAGS[3] = {ATM90E32_STATUS_S0_OIPHASEAST, ATM90E32_STATUS_S0_OIPHASEBST, + ATM90E32_STATUS_S0_OIPHASECST}; + static constexpr float LEGACY_OVER_CURRENT_A = 65.53f; uint16_t state0 = this->read16_(ATM90E32_REGISTER_EMMSTATE0); uint16_t state1 = this->read16_(ATM90E32_REGISTER_EMMSTATE1); + const bool current_configured = !std::isnan(this->threshold_current_peak_a_); + const float current_limit = current_configured ? this->threshold_current_peak_a_ : LEGACY_OVER_CURRENT_A; for (int phase = 0; phase < 3; phase++) { - std::string status; - - if (state0 & over_voltage_flags[phase]) - status += "Over Voltage; "; - if (state1 & voltage_sag_flags[phase]) - status += "Voltage Sag; "; - if (state1 & phase_loss_flags[phase]) - status += "Phase Loss; "; - auto *sensor = this->phase_status_text_sensor_[phase]; if (sensor == nullptr) continue; - if (!status.empty()) { - status.pop_back(); // remove space - status.pop_back(); // remove semicolon - ESP_LOGW(TAG, "%s: %s", LOG_STR_ARG(sensor->get_log_name()), status.c_str()); - sensor->publish_state(status); + char status[sizeof("Over Current; Over Voltage; Voltage Sag; Phase Loss; ")]; + size_t length = 0; + bool over_voltage = state0 & this->over_voltage_flags[phase]; + bool voltage_sag = state1 & this->voltage_sag_flags[phase]; + if (!this->use_voltage_threshold_flags_) { + const float voltage = this->get_phase_voltage_(phase); + over_voltage = voltage > this->threshold_voltage_peak_v_; + voltage_sag = voltage < this->threshold_voltage_sag_v_; + } + + const float current = this->has_config_current_gain_[phase] ? this->get_phase_current_(phase) : 0.0f; + if ((current_configured && this->use_current_threshold_flags_ && (state0 & OVER_CURRENT_FLAGS[phase])) || + current > current_limit) + length = buf_append_str(status, sizeof(status), length, "Over Current; "); + if (over_voltage) + length = buf_append_str(status, sizeof(status), length, "Over Voltage; "); + if (voltage_sag) + length = buf_append_str(status, sizeof(status), length, "Voltage Sag; "); + if (state1 & this->phase_loss_flags[phase]) + length = buf_append_str(status, sizeof(status), length, "Phase Loss; "); + + if (length != 0) { + length -= 2; // Remove the trailing separator. + status[length] = '\0'; + ESP_LOGW(TAG, "%s: %s", LOG_STR_ARG(sensor->get_log_name()), status); + sensor->publish_state(status, length); } else { sensor->publish_state("Okay"); } @@ -1316,34 +1364,30 @@ void ATM90E32Component::check_freq_status() { } } -void ATM90E32Component::check_over_current() { - constexpr float max_current_threshold = 65.53f; - - for (uint8_t phase = 0; phase < 3; phase++) { - float current_val = - this->phase_[phase].current_sensor_ != nullptr ? this->phase_[phase].current_sensor_->state : 0.0f; - - if (current_val > max_current_threshold) { - ESP_LOGW(TAG, "Over current detected on Phase %c: %.2f A", 'A' + phase, current_val); - ESP_LOGW(TAG, "You may need to half your gain_ct: value & multiply the current and power values by 2"); - PUBLISH_STATE(this->phase_status_text_sensor_[phase], "Over Current; "); - } - } -} #endif -uint16_t ATM90E32Component::calculate_voltage_threshold(int line_freq, uint16_t ugain, float multiplier) { - // this assumes that 60Hz electrical systems use 120V mains, - // which is usually, but not always the case - float nominal_voltage = (line_freq == 60) ? 120.0f : 220.0f; - float target_voltage = nominal_voltage * multiplier; +static uint16_t encode_threshold(float value) { + if (!std::isfinite(value) || value > UINT16_MAX) { + ESP_LOGW(TAG, "Threshold exceeds the 16-bit register range; clamping to 65535"); + return UINT16_MAX; + } + return static_cast(std::round(std::max(value, 0.0f))); +} - float peak_01v = target_voltage * 100.0f * std::numbers::sqrt2_v; // convert RMS → peak, scale to 0.01V - float divider = (2.0f * ugain) / 32768.0f; +// Datasheet equation (section "Power Quality Monitoring"): xxThRegValue = +// RmsRegValue * sqrt(2) / (VIgain / 2^14). RmsRegValue is in 0.01V for voltage and 0.001A for current. +static uint16_t calculate_threshold(float rms, float scale, uint16_t gain) { + if (gain == 0) + return 0; + return encode_threshold(rms * scale * std::numbers::sqrt2_v / (gain / 16384.0f)); +} - float threshold = peak_01v / divider; +uint16_t ATM90E32Component::calculate_voltage_threshold_from_volts(float voltage_rms, uint16_t ugain) { + return calculate_threshold(voltage_rms, 100.0f, ugain); +} - return static_cast(threshold); +uint16_t ATM90E32Component::calculate_current_threshold_from_amps(float current_rms, uint16_t igain) { + return calculate_threshold(current_rms, 1000.0f, igain); } bool ATM90E32Component::validate_spi_read_(uint16_t expected, const char *context) { diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h index ea6d5a93edb..880bb068658 100644 --- a/esphome/components/atm90e32/atm90e32.h +++ b/esphome/components/atm90e32/atm90e32.h @@ -1,10 +1,14 @@ #pragma once -#include -#include #include "atm90e32_reg.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/spi/spi.h" +#include +#include +#include +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include "esphome/core/application.h" #include "esphome/core/component.h" #include "esphome/core/gpio.h" @@ -114,6 +118,13 @@ class ATM90E32Component final : public PollingComponent, this->has_config_reactive_power_offset_[phase] = true; } void set_peak_current_signed(bool flag) { peak_current_signed_ = flag; } + void set_threshold_voltage_sag_v(float voltage) { this->threshold_voltage_sag_v_ = voltage; } + void set_threshold_voltage_peak_v(float voltage) { this->threshold_voltage_peak_v_ = voltage; } + void set_threshold_current_peak_a(float current) { this->threshold_current_peak_a_ = current; } + void set_frequency_thresholds(uint16_t low, uint16_t high) { + this->frequency_low_threshold_ = low; + this->frequency_high_threshold_ = high; + } void set_line_freq(int freq) { line_freq_ = freq; } void set_current_phases(int phases) { current_phases_ = phases; } void set_pga_gain(uint16_t gain) { pga_gain_ = gain; } @@ -152,14 +163,15 @@ class ATM90E32Component final : public PollingComponent, #ifdef USE_TEXT_SENSOR void check_phase_status(); void check_freq_status(); - void check_over_current(); void set_phase_status_text_sensor(uint8_t phase, text_sensor::TextSensor *sensor) { this->phase_status_text_sensor_[phase] = sensor; } #endif - uint16_t calculate_voltage_threshold(int line_freq, uint16_t ugain, float multiplier); + static uint16_t calculate_voltage_threshold_from_volts(float voltage_rms, uint16_t ugain); + static uint16_t calculate_current_threshold_from_amps(float current_rms, uint16_t igain); protected: + friend class ATM90E32ThresholdRegisters; #ifdef USE_NUMBER number::Number *ref_voltages_[3]{nullptr, nullptr, nullptr}; number::Number *ref_currents_[3]{nullptr, nullptr, nullptr}; @@ -211,6 +223,7 @@ class ATM90E32Component final : public PollingComponent, void write_offsets_to_registers_(uint8_t phase, int16_t first_offset, int16_t second_offset, OffsetCalibrationType type); void write_gains_to_registers_(); + void write_thresholds_(); bool verify_gain_writes_(); bool verify_offset_writes_(OffsetCalibrationType type); bool validate_spi_read_(uint16_t expected, const char *context = nullptr); @@ -282,6 +295,11 @@ class ATM90E32Component final : public PollingComponent, bool peak_current_signed_{false}; bool enable_offset_calibration_{false}; bool enable_gain_calibration_{false}; + float threshold_voltage_sag_v_{0.0f}; + float threshold_voltage_peak_v_{0.0f}; + float threshold_current_peak_a_{NAN}; // NAN keeps the legacy OITH register and 65.53 A software limit + uint16_t frequency_low_threshold_{0}; + uint16_t frequency_high_threshold_{0}; const char *instance_id_{nullptr}; bool has_stored_offset_calibration_{false}; bool has_stored_power_offset_calibration_{false}; @@ -290,6 +308,10 @@ class ATM90E32Component final : public PollingComponent, bool restored_power_offset_calibration_{false}; bool restored_gain_calibration_{false}; bool calibration_message_printed_{false}; +#ifdef USE_TEXT_SENSOR + bool use_voltage_threshold_flags_{true}; + bool use_current_threshold_flags_{true}; +#endif bool offset_calibration_mismatch_[3]{false, false, false}; bool power_offset_calibration_mismatch_[3]{false, false, false}; bool gain_calibration_mismatch_[3]{false, false, false}; diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py index e3d5fa2de2b..991877a42f6 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -1,3 +1,6 @@ +import logging +import math + import esphome.codegen as cg from esphome.components import sensor, spi import esphome.config_validation as cv @@ -45,6 +48,8 @@ from esphome.types import ConfigType from . import atm90e32_ns +_LOGGER = logging.getLogger(__name__) + CONF_CHIP_TEMPERATURE = "chip_temperature" CONF_GAIN_PGA = "gain_pga" CONF_CURRENT_PHASES = "current_phases" @@ -61,6 +66,16 @@ CONF_ENABLE_OFFSET_CALIBRATION = "enable_offset_calibration" CONF_ENABLE_GAIN_CALIBRATION = "enable_gain_calibration" CONF_PHASE_STATUS = "phase_status" CONF_FREQUENCY_STATUS = "frequency_status" +CONF_THRESHOLDS = "thresholds" +CONF_ALLOW_UNSAFE_THRESHOLDS = "allow_unsafe_thresholds" +CONF_VOLTAGE_NOMINAL = "voltage_nominal" +CONF_VOLTAGE_SAG_PCT = "voltage_sag_pct" +CONF_VOLTAGE_PEAK_PCT = "voltage_peak_pct" +CONF_VOLTAGE_SAG_V = "voltage_sag_v" +CONF_VOLTAGE_PEAK_V = "voltage_peak_v" +CONF_CURRENT_PEAK = "current_peak" +CONF_FREQUENCY_LOW_HZ = "frequency_low_hz" +CONF_FREQUENCY_HIGH_HZ = "frequency_high_hz" UNIT_DEG = "degrees" LINE_FREQS = { "50HZ": 50, @@ -76,6 +91,13 @@ PGA_GAINS = { "4X": 0x2A, } +DEFAULT_VOLTAGE_SAG_PCT = 0.78 +DEFAULT_VOLTAGE_PEAK_PCT = 1.22 +# IRMS saturates at 65.535 A, so a higher limit could never be exceeded. +MAX_CURRENT_PEAK_A = 65.53 +MAX_FREQUENCY_HZ = 655.35 +DEFAULT_FREQUENCY_THRESHOLD_BAND_HZ = 3.0 + ATM90E32Component = atm90e32_ns.class_( "ATM90E32Component", cg.PollingComponent, spi.SPIDevice ) @@ -156,7 +178,158 @@ ATM90E32_PHASE_SCHEMA = cv.Schema( } ) -CONFIG_SCHEMA = ( + +POSITIVE_VOLTAGE = cv.All(cv.voltage, cv.positive_not_null_float) +THRESHOLD_PERCENT = cv.All( + cv.unbounded_percentage, cv.float_range(min=0, min_included=False, max=2.0) +) +REGISTER_FREQUENCY = cv.All( + cv.frequency, cv.float_range(min=0, min_included=False, max=MAX_FREQUENCY_HZ) +) + +THRESHOLDS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ALLOW_UNSAFE_THRESHOLDS, default=False): cv.boolean, + cv.Optional(CONF_VOLTAGE_NOMINAL): POSITIVE_VOLTAGE, + cv.Exclusive(CONF_VOLTAGE_SAG_PCT, "voltage_sag_threshold"): THRESHOLD_PERCENT, + cv.Exclusive(CONF_VOLTAGE_SAG_V, "voltage_sag_threshold"): POSITIVE_VOLTAGE, + cv.Exclusive( + CONF_VOLTAGE_PEAK_PCT, "voltage_peak_threshold" + ): THRESHOLD_PERCENT, + cv.Exclusive(CONF_VOLTAGE_PEAK_V, "voltage_peak_threshold"): POSITIVE_VOLTAGE, + cv.Optional(CONF_CURRENT_PEAK): cv.All( + cv.current, + cv.float_range(min=0, min_included=False, max=MAX_CURRENT_PEAK_A), + ), + cv.Optional(CONF_FREQUENCY_LOW_HZ): REGISTER_FREQUENCY, + cv.Optional(CONF_FREQUENCY_HIGH_HZ): REGISTER_FREQUENCY, + } +) + + +def _default_voltage_nominal(line_frequency: int) -> float: + return 120.0 if line_frequency == 60 else 220.0 + + +def _resolve_thresholds(config: ConfigType) -> ConfigType: + line_frequency = config[CONF_LINE_FREQUENCY].enum_value + resolved = dict(config[CONF_THRESHOLDS]) + nominal_voltage = resolved.setdefault( + CONF_VOLTAGE_NOMINAL, _default_voltage_nominal(line_frequency) + ) + for volts_key, pct_key, default_pct in ( + (CONF_VOLTAGE_SAG_V, CONF_VOLTAGE_SAG_PCT, DEFAULT_VOLTAGE_SAG_PCT), + (CONF_VOLTAGE_PEAK_V, CONF_VOLTAGE_PEAK_PCT, DEFAULT_VOLTAGE_PEAK_PCT), + ): + resolved.setdefault( + volts_key, nominal_voltage * resolved.get(pct_key, default_pct) + ) + resolved.setdefault( + CONF_FREQUENCY_LOW_HZ, line_frequency - DEFAULT_FREQUENCY_THRESHOLD_BAND_HZ + ) + resolved.setdefault( + CONF_FREQUENCY_HIGH_HZ, line_frequency + DEFAULT_FREQUENCY_THRESHOLD_BAND_HZ + ) + return resolved + + +def _validate_thresholds(config: ConfigType) -> ConfigType: + configured = config[CONF_THRESHOLDS] + thresholds = _resolve_thresholds(config) + nominal_voltage = thresholds[CONF_VOLTAGE_NOMINAL] + sag_voltage = thresholds[CONF_VOLTAGE_SAG_V] + peak_voltage = thresholds[CONF_VOLTAGE_PEAK_V] + + if not thresholds[CONF_ALLOW_UNSAFE_THRESHOLDS]: + for key, ratio, low, high in ( + (CONF_VOLTAGE_SAG_V, sag_voltage / nominal_voltage, 0.50, 0.99), + (CONF_VOLTAGE_PEAK_V, peak_voltage / nominal_voltage, 1.01, 1.50), + ): + if not low <= ratio <= high: + raise cv.Invalid( + f"thresholds.{key} must be between {low:.0%} and {high:.0%} of voltage_nominal; " + "set thresholds.allow_unsafe_thresholds: true to override." + ) + + if not sag_voltage < nominal_voltage < peak_voltage: + raise cv.Invalid( + "Invalid voltage thresholds: expected sag < nominal < peak " + f"(got {sag_voltage:.3f}V < {nominal_voltage:.3f}V < {peak_voltage:.3f}V)." + ) + + frequency_low = thresholds[CONF_FREQUENCY_LOW_HZ] + frequency_high = thresholds[CONF_FREQUENCY_HIGH_HZ] + + if round(frequency_low * 100) >= round(frequency_high * 100): + raise cv.Invalid( + "thresholds.frequency_low_hz must be lower than thresholds.frequency_high_hz " + "at the register resolution of 0.01Hz." + ) + + # Each limit counts as set by the user when any key it is derived from was given. + for gain_key, scale, registers in ( + ( + CONF_GAIN_VOLTAGE, + 100, + ( + ( + CONF_VOLTAGE_SAG_V, + min, + (CONF_VOLTAGE_NOMINAL, CONF_VOLTAGE_SAG_PCT, CONF_VOLTAGE_SAG_V), + ), + ( + CONF_VOLTAGE_PEAK_V, + max, + (CONF_VOLTAGE_NOMINAL, CONF_VOLTAGE_PEAK_PCT, CONF_VOLTAGE_PEAK_V), + ), + ), + ), + (CONF_GAIN_CT, 1000, ((CONF_CURRENT_PEAK, max, (CONF_CURRENT_PEAK,)),)), + ): + explicit = { + key: any(source in configured for source in sources) + for key, _, sources in registers + } + # A legacy config without current_peak keeps the fixed OITH register value. + if gain_key == CONF_GAIN_CT and not explicit[CONF_CURRENT_PEAK]: + continue + gains = { + config[phase][gain_key] + for phase in (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) + if phase in config + } + if not gains or 0 in gains: + if not any(explicit.values()): + continue + raise cv.Invalid( + f"Thresholds require at least one phase and a nonzero {gain_key} on every configured phase." + ) + for key, select_gain, _ in registers: + gain = select_gain(gains) + limit = 65535 * gain / (scale * math.sqrt(2) * 16384) + if thresholds[key] <= limit: + continue + message = ( + f"thresholds.{key} ({thresholds[key]:.1f}) cannot fit the 16-bit threshold register with " + f"{gain_key}: {gain}; the highest value that fits is {limit:.1f}" + ) + if explicit[key]: + raise cv.Invalid( + f"{message}. Increase {gain_key} or lower the threshold." + ) + # Defaults are only warned about so existing configs keep building; the register is clamped. + _LOGGER.warning( + "%s. The default is clamped to the register maximum, so this alarm cannot trigger; " + "set thresholds.%s to at most %.1f to use it.", + message, + key, + limit, + ) + + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ATM90E32Component), @@ -185,10 +358,12 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_PEAK_CURRENT_SIGNED, default=False): cv.boolean, cv.Optional(CONF_ENABLE_OFFSET_CALIBRATION, default=False): cv.boolean, cv.Optional(CONF_ENABLE_GAIN_CALIBRATION, default=False): cv.boolean, + cv.Optional(CONF_THRESHOLDS, default={}): THRESHOLDS_SCHEMA, } ) .extend(cv.polling_component_schema("60s")) - .extend(spi.spi_device_schema()) + .extend(spi.spi_device_schema()), + _validate_thresholds, ) @@ -250,3 +425,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_peak_current_signed(config[CONF_PEAK_CURRENT_SIGNED])) cg.add(var.set_enable_offset_calibration(config[CONF_ENABLE_OFFSET_CALIBRATION])) cg.add(var.set_enable_gain_calibration(config[CONF_ENABLE_GAIN_CALIBRATION])) + + # The user's keys stay as written so `esphome config` output can be loaded again. + thresholds = _resolve_thresholds(config) + cg.add(var.set_threshold_voltage_sag_v(thresholds[CONF_VOLTAGE_SAG_V])) + cg.add(var.set_threshold_voltage_peak_v(thresholds[CONF_VOLTAGE_PEAK_V])) + cg.add_optional(var.set_threshold_current_peak_a, thresholds.get(CONF_CURRENT_PEAK)) + cg.add( + var.set_frequency_thresholds( + round(thresholds[CONF_FREQUENCY_LOW_HZ] * 100), + round(thresholds[CONF_FREQUENCY_HIGH_HZ] * 100), + ) + ) diff --git a/tests/components/atm90e32/__init__.py b/tests/components/atm90e32/__init__.py index 37d6797e2dd..8745f8e6df1 100644 --- a/tests/components/atm90e32/__init__.py +++ b/tests/components/atm90e32/__init__.py @@ -1,5 +1,13 @@ +import esphome.codegen as cg +from esphome.core import CORE +from esphome.types import ConfigType from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - manifest.dependencies = manifest.dependencies + ["sensor", "spi"] + async def to_code_testing(config: ConfigType) -> None: + CORE.testing_ensure_platform_registered("text_sensor") + cg.add_define("USE_SENSOR_FILTER") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["sensor", "spi", "text_sensor"] diff --git a/tests/components/atm90e32/calibration_register_verification_test.cpp b/tests/components/atm90e32/calibration_register_verification_test.cpp index 933df5ebbbc..01550f35bb5 100644 --- a/tests/components/atm90e32/calibration_register_verification_test.cpp +++ b/tests/components/atm90e32/calibration_register_verification_test.cpp @@ -439,6 +439,8 @@ TEST(ATM90E32Setup, ConfiguresGainsAndThresholdsWithCalibrationEnabledOrDisabled component.set_cs_pin(&cs); component.set_enable_offset_calibration(offsets); component.set_enable_gain_calibration(gains); + component.set_threshold_voltage_sag_v(93.6f); + component.set_threshold_voltage_peak_v(146.4f); for (uint8_t phase = 0; phase < 3; ++phase) { component.set_volt_gain(phase, 7305); component.set_ct_gain(phase, 27518); @@ -464,6 +466,8 @@ TEST(ATM90E32Setup, ThresholdsUseRestoredGains) { spi::NullPin cs; component.set_cs_pin(&cs); component.set_enable_gain_calibration(true); + component.set_threshold_voltage_sag_v(93.6f); + component.set_threshold_voltage_peak_v(146.4f); GainCalibration stored[3]; for (uint8_t phase = 0; phase < 3; ++phase) { component.set_volt_gain(phase, 7305); @@ -477,8 +481,10 @@ TEST(ATM90E32Setup, ThresholdsUseRestoredGains) { access.configure_chip(); EXPECT_EQ(access.read16(component.voltage_gain_registers[0]), 9000); - EXPECT_EQ(access.read16(ATM90E32_REGISTER_SAGTH), component.calculate_voltage_threshold(60, 9000, 0.78f)); - EXPECT_EQ(access.read16(ATM90E32_REGISTER_OVTH), component.calculate_voltage_threshold(60, 9000, 1.22f)); + EXPECT_EQ(access.read16(ATM90E32_REGISTER_SAGTH), + ATM90E32Component::calculate_voltage_threshold_from_volts(93.6f, 9000)); + EXPECT_EQ(access.read16(ATM90E32_REGISTER_OVTH), + ATM90E32Component::calculate_voltage_threshold_from_volts(146.4f, 9000)); } class ATM90E32CalibrationPersistence : public ::testing::TestWithParam {}; diff --git a/tests/components/atm90e32/common.yaml b/tests/components/atm90e32/common.yaml index b8b480ab620..d116db3daf8 100644 --- a/tests/components/atm90e32/common.yaml +++ b/tests/components/atm90e32/common.yaml @@ -78,6 +78,13 @@ sensor: chip_temperature: name: EMON Chip Temp line_frequency: 60Hz + thresholds: + voltage_nominal: 120V + voltage_sag_pct: 80% + voltage_peak_pct: 120% + current_peak: 65.53A + frequency_low_hz: 58Hz + frequency_high_hz: 62Hz current_phases: 3 gain_pga: 1X enable_offset_calibration: True diff --git a/tests/components/atm90e32/threshold_test.cpp b/tests/components/atm90e32/threshold_test.cpp new file mode 100644 index 00000000000..b3dfbf194be --- /dev/null +++ b/tests/components/atm90e32/threshold_test.cpp @@ -0,0 +1,320 @@ +#include + +#include +#include +#include + +#include "esphome/components/atm90e32/atm90e32.h" +#include "esphome/components/sensor/filter.h" +#include "esphome/components/host/preferences.h" +#include "esphome/components/text_sensor/text_sensor.h" + +#ifndef __has_feature +#define __has_feature(x) 0 +#endif +#if defined(__SANITIZE_ADDRESS__) || __has_feature(address_sanitizer) +#define ATM90E32_TEST_ASAN +extern "C" int __sanitizer_install_malloc_and_free_hooks(void (*malloc_hook)(const volatile void *, size_t), + void (*free_hook)(const volatile void *)); +#endif + +namespace esphome::atm90e32 { + +static_assert(std::is_final_v); + +struct ExpectedThresholds { + uint16_t sag; + uint16_t over_voltage; + uint16_t over_current; +}; + +// Phase gains 7305 (voltage) and 27961 (current) with 96 V / 144 V / 10 A thresholds. +static constexpr ExpectedThresholds DEFAULT_GAIN_THRESHOLDS{30450, 45675, 8287}; +// Calibrated gains 14610 (voltage) and 13980 (current). +static constexpr ExpectedThresholds CALIBRATED_GAIN_THRESHOLDS{15225, 22837, 16574}; +static constexpr ExpectedThresholds CLAMPED_THRESHOLDS{65535, 65535, 65535}; + +class ThresholdRegisterDelegate : public spi::SPIDelegate { + public: + std::array registers{}; + + void begin_transaction() override {} + void end_transaction() override {} + uint8_t transfer(uint8_t data) override { return 0; } + void transfer(uint8_t *data, size_t length) override { + ASSERT_EQ(length, 4); + const auto address = ((data[0] & 0x03) << 8) | data[1]; + const uint16_t value = this->registers[address]; + data[2] = value >> 8; + data[3] = value & 0xFF; + if (address != ATM90E32_REGISTER_LASTSPIDATA) + this->registers[ATM90E32_REGISTER_LASTSPIDATA] = value; + } + void write_array(const uint8_t *data, size_t length) override { + ASSERT_EQ(length, 4); + const auto address = (data[0] << 8) | data[1]; + const uint16_t value = (data[2] << 8) | data[3]; + this->registers[address] = value; + this->registers[ATM90E32_REGISTER_LASTSPIDATA] = value; + } +}; + +class ATM90E32ThresholdRegisters : public ::testing::Test { + protected: + void SetUp() override { + this->component_.delegate_ = &this->delegate_; + this->component_.set_cs_pin(&this->cs_); + this->component_.set_instance_id("threshold_test"); + this->component_.set_threshold_voltage_sag_v(96.0f); + this->component_.set_threshold_voltage_peak_v(144.0f); + this->component_.set_frequency_thresholds(5995, 65535); + } + + ThresholdRegisterDelegate delegate_; + spi::NullPin cs_; + ATM90E32Component component_; + GainCalibration (&gain_phase_)[3]{this->component_.gain_phase_}; + ESPPreferenceObject &gain_calibration_pref_{this->component_.gain_calibration_pref_}; + void setup_component_() { this->component_.configure_chip_(); } + void setup_phase_(uint8_t phase = 1) { + this->component_.set_volt_gain(phase, 7305); + this->component_.set_ct_gain(phase, 27961); + this->component_.set_threshold_current_peak_a(10.0f); + this->setup_component_(); + } + void expect_thresholds_(const ExpectedThresholds &expected, uint16_t tolerance = 1) { + EXPECT_NEAR(this->delegate_.registers[ATM90E32_REGISTER_SAGTH], expected.sag, tolerance); + EXPECT_NEAR(this->delegate_.registers[ATM90E32_REGISTER_OVTH], expected.over_voltage, tolerance); + EXPECT_NEAR(this->delegate_.registers[ATM90E32_REGISTER_OITH], expected.over_current, tolerance); + } + void write_gains_() { this->component_.write_gains_to_registers_(); } + void restore_gains_() { this->component_.restore_gain_calibrations_(); } + bool restored_gains_() const { return this->component_.restored_gain_calibration_; } +}; + +TEST(ATM90E32Threshold, SaturatesVoltageBeforeNarrowing) { + EXPECT_EQ(ATM90E32Component::calculate_voltage_threshold_from_volts(292.8f, 7305), 65535); +} + +TEST(ATM90E32Threshold, SaturatesCurrentBeforeNarrowing) { + EXPECT_EQ(ATM90E32Component::calculate_current_threshold_from_amps(50.0f, 10000), 65535); +} + +TEST(ATM90E32Threshold, HandlesZeroGain) { + EXPECT_EQ(ATM90E32Component::calculate_voltage_threshold_from_volts(120.0f, 0), 0); + EXPECT_EQ(ATM90E32Component::calculate_current_threshold_from_amps(10.0f, 0), 0); +} + +class ATM90E32SinglePhaseThresholds : public ATM90E32ThresholdRegisters, + public ::testing::WithParamInterface {}; + +TEST_P(ATM90E32SinglePhaseThresholds, UsesConfiguredPhaseWithoutPhaseA) { + this->setup_phase_(GetParam()); + + this->expect_thresholds_(DEFAULT_GAIN_THRESHOLDS); +} + +INSTANTIATE_TEST_SUITE_P(PhaseBAndC, ATM90E32SinglePhaseThresholds, ::testing::Values(1, 2)); + +TEST_F(ATM90E32ThresholdRegisters, RefreshesThresholdsWhenCalibratedGainsAreWritten) { + this->setup_phase_(); + this->gain_phase_[1] = {14610, 13980}; + + this->write_gains_(); + + this->expect_thresholds_(CALIBRATED_GAIN_THRESHOLDS); +} + +TEST_F(ATM90E32ThresholdRegisters, UsesNativeVoltageLimitsForDifferentCalibratedGains) { + text_sensor::TextSensor status_a, status_b; + this->component_.set_phase_status_text_sensor(0, &status_a); + this->component_.set_phase_status_text_sensor(1, &status_b); + this->component_.set_volt_gain(0, 7305); + this->component_.set_volt_gain(1, 14610); + this->component_.set_ct_gain(0, 13980); + this->component_.set_ct_gain(1, 27961); + this->component_.set_threshold_current_peak_a(10.0f); + this->setup_component_(); + + this->expect_thresholds_({30450, 22837, 8287}); + + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE0] = + ATM90E32_STATUS_S0_OVPHASEAST | ATM90E32_STATUS_S0_OVPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE1] = + ATM90E32_STATUS_S1_SAGPHASEAST | ATM90E32_STATUS_S1_SAGPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_URMS] = 12000; + this->delegate_.registers[ATM90E32_REGISTER_URMS + 1] = 12000; + this->component_.update(); + EXPECT_EQ(status_a.state, "Okay"); + EXPECT_EQ(status_b.state, "Okay"); + + this->delegate_.registers[ATM90E32_REGISTER_URMS] = 9500; + this->delegate_.registers[ATM90E32_REGISTER_URMS + 1] = 14500; + this->component_.update(); + EXPECT_EQ(status_a.state, "Voltage Sag"); + EXPECT_EQ(status_b.state, "Over Voltage"); + + this->delegate_.registers[ATM90E32_REGISTER_URMS] = 9600; + this->delegate_.registers[ATM90E32_REGISTER_URMS + 1] = 14400; + this->component_.update(); + EXPECT_EQ(status_a.state, "Okay"); + EXPECT_EQ(status_b.state, "Okay"); + + this->gain_phase_[0] = {7305, 13980}; + this->gain_phase_[1] = {7305, 27961}; + this->write_gains_(); + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE1] = 0; + this->component_.update(); + EXPECT_EQ(status_a.state, "Over Voltage"); + EXPECT_EQ(status_b.state, "Over Voltage"); +} + +TEST_F(ATM90E32ThresholdRegisters, PreservesLegacyCurrentAndReadyMadeFrequencyRegisters) { + this->component_.set_ct_gain(1, 10000); + this->setup_component_(); + EXPECT_EQ(this->delegate_.registers[ATM90E32_REGISTER_OITH], 0xC000); + EXPECT_EQ(this->delegate_.registers[ATM90E32_REGISTER_FREQLOTH], 5995); + EXPECT_EQ(this->delegate_.registers[ATM90E32_REGISTER_FREQHITH], 65535); + + this->gain_phase_[1].current_gain = 5000; + this->write_gains_(); + EXPECT_EQ(this->delegate_.registers[ATM90E32_REGISTER_OITH], 0xC000); +} + +TEST_F(ATM90E32ThresholdRegisters, ClampsWhenCalibrationExceedsTheRegisterRange) { + this->setup_phase_(); + this->gain_phase_[1] = {1000, 1000}; + + this->write_gains_(); + + this->expect_thresholds_(CLAMPED_THRESHOLDS, 0); +} + +#ifdef USE_HOST +TEST_F(ATM90E32ThresholdRegisters, RefreshesThresholdsAfterRestoringAndClearingStoredGains) { + if (global_preferences == nullptr) + host::setup_preferences(); + this->setup_phase_(); + auto preference = global_preferences->make_preference(0x15244000); + const GainCalibration saved[3]{{0, 0}, {14610, 13980}, {0, 0}}; + ASSERT_TRUE(preference.save(&saved)); + this->gain_calibration_pref_ = preference; + + this->restore_gains_(); + + EXPECT_TRUE(this->restored_gains_()); + this->expect_thresholds_(CALIBRATED_GAIN_THRESHOLDS); + + this->component_.clear_gain_calibrations(); + + EXPECT_FALSE(this->restored_gains_()); + this->expect_thresholds_(DEFAULT_GAIN_THRESHOLDS); +} +#endif // USE_HOST + +TEST_F(ATM90E32ThresholdRegisters, CombinesNativeCurrentWithOtherPhaseAlarms) { + sensor::Sensor current; + sensor::MultiplyFilter multiply([]() { return 2.0f; }); + current.add_filter(&multiply); + current.publish_state(6.0f); + ASSERT_FLOAT_EQ(current.state, 12.0f); + text_sensor::TextSensor status; + this->component_.set_ct_gain(1, 27961); + this->component_.set_current_sensor(1, ¤t); + this->component_.set_phase_status_text_sensor(1, &status); + this->component_.set_threshold_current_peak_a(10.0f); + this->setup_component_(); + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE0] = ATM90E32_STATUS_S0_OVPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_IRMS + 1] = 6000; + + this->component_.update(); + EXPECT_EQ(status.state, "Over Voltage"); + + this->delegate_.registers[ATM90E32_REGISTER_IRMS + 1] = 12000; + this->component_.update(); + EXPECT_EQ(status.state, "Over Current; Over Voltage"); +} + +TEST_F(ATM90E32ThresholdRegisters, IgnoresLegacyCurrentFlagAndPreservesRmsLimit) { + text_sensor::TextSensor status; + this->component_.set_ct_gain(1, 10000); + this->component_.set_phase_status_text_sensor(1, &status); + this->setup_component_(); + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE0] = ATM90E32_STATUS_S0_OIPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_IRMS + 1] = 30000; + + this->component_.update(); + EXPECT_EQ(status.state, "Okay"); + + this->delegate_.registers[ATM90E32_REGISTER_IRMS + 1] = 65535; + this->component_.update(); + EXPECT_EQ(status.state, "Over Current"); + + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE0] |= ATM90E32_STATUS_S0_OVPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_IRMS + 1] = 30000; + this->component_.update(); + EXPECT_EQ(status.state, "Over Voltage"); +} + +TEST_F(ATM90E32ThresholdRegisters, UsesRmsCurrentLimitForDifferentCurrentGains) { + text_sensor::TextSensor status_a, status_b; + this->component_.set_phase_status_text_sensor(0, &status_a); + this->component_.set_phase_status_text_sensor(1, &status_b); + this->component_.set_ct_gain(0, 13980); + this->component_.set_ct_gain(1, 27961); + this->component_.set_threshold_current_peak_a(10.0f); + this->setup_component_(); + // OITH is encoded at the highest gain, so the chip flags phase A below 10 A; only the RMS current counts. + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE0] = + ATM90E32_STATUS_S0_OIPHASEAST | ATM90E32_STATUS_S0_OIPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_IRMS] = 9000; + this->delegate_.registers[ATM90E32_REGISTER_IRMS + 1] = 11000; + + this->component_.update(); + EXPECT_EQ(status_a.state, "Okay"); + EXPECT_EQ(status_b.state, "Over Current"); +} + +TEST_F(ATM90E32ThresholdRegisters, ReportsConfiguredCurrentFlagBelowRmsLimit) { + text_sensor::TextSensor status; + this->component_.set_ct_gain(1, 27961); + this->component_.set_phase_status_text_sensor(1, &status); + this->component_.set_threshold_current_peak_a(10.0f); + this->setup_component_(); + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE0] = + ATM90E32_STATUS_S0_OIPHASEBST | ATM90E32_STATUS_S0_OVPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_IRMS + 1] = 6000; + + this->component_.update(); + EXPECT_EQ(status.state, "Over Current; Over Voltage"); +} + +TEST_F(ATM90E32ThresholdRegisters, RepeatedCombinedAlarmsDoNotAllocate) { +#ifdef ATM90E32_TEST_ASAN + static std::atomic allocations{0}; + static const int hooks_installed = __sanitizer_install_malloc_and_free_hooks( + [](const volatile void *, size_t) { allocations.fetch_add(1, std::memory_order_relaxed); }, + [](const volatile void *) {}); + ASSERT_NE(hooks_installed, 0); + text_sensor::TextSensor status; + this->component_.set_ct_gain(1, 27961); + this->component_.set_phase_status_text_sensor(1, &status); + this->component_.set_threshold_current_peak_a(10.0f); + this->setup_component_(); + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE0] = + ATM90E32_STATUS_S0_OIPHASEBST | ATM90E32_STATUS_S0_OVPHASEBST; + this->delegate_.registers[ATM90E32_REGISTER_EMMSTATE1] = + ATM90E32_STATUS_S1_SAGPHASEBST | ATM90E32_STATUS_S1_PHASELOSSBST; + this->component_.check_phase_status(); + ASSERT_EQ(status.state, "Over Current; Over Voltage; Voltage Sag; Phase Loss"); + const size_t before = allocations.load(std::memory_order_relaxed); + for (unsigned i = 0; i < 10; i++) + this->component_.check_phase_status(); + const size_t after = allocations.load(std::memory_order_relaxed); + EXPECT_EQ(after - before, 0u); +#else + GTEST_SKIP() << "Allocation counting requires AddressSanitizer"; +#endif +} + +} // namespace esphome::atm90e32 diff --git a/tests/unit_tests/components/atm90e32/test_sensor.py b/tests/unit_tests/components/atm90e32/test_sensor.py new file mode 100644 index 00000000000..3993eec332e --- /dev/null +++ b/tests/unit_tests/components/atm90e32/test_sensor.py @@ -0,0 +1,283 @@ +"""Validate ATM90E32 thresholds before narrowing them to hardware registers.""" + +import logging +from typing import Any + +import pytest + +from esphome import config as esphome_config +import esphome.codegen as cg +from esphome.components.atm90e32 import sensor as atm90e32 +import esphome.config_validation as cv +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE +from esphome.types import ConfigType + + +def _validate( + thresholds: ConfigType | None = None, + phases: ConfigType | None = None, + line_frequency: int = 60, +) -> ConfigType: + config = { + "line_frequency": cv.enum(atm90e32.LINE_FREQS, upper=True)( + f"{line_frequency}Hz" + ), + "thresholds": atm90e32.THRESHOLDS_SCHEMA(thresholds or {}), + } + for phase, values in ({"phase_a": {}} if phases is None else phases).items(): + config[phase] = atm90e32.ATM90E32_PHASE_SCHEMA(values) + return atm90e32._validate_thresholds(config) + + +@pytest.mark.parametrize("line_frequency, nominal", [(50, 220), (60, 120)]) +def test_resolves_defaults_without_threshold_block( + line_frequency: int, nominal: int +) -> None: + thresholds = atm90e32._resolve_thresholds(_validate(line_frequency=line_frequency)) + assert thresholds["voltage_nominal"] == nominal + assert thresholds["voltage_sag_v"] == pytest.approx(nominal * 0.78) + assert thresholds["voltage_peak_v"] == pytest.approx(nominal * 1.22) + assert "current_peak" not in thresholds + assert thresholds["frequency_low_hz"] == line_frequency - 3 + assert thresholds["frequency_high_hz"] == line_frequency + 3 + + +@pytest.mark.parametrize("key", ["frequency_low_hz", "frequency_high_hz"]) +def test_rejects_frequency_register_overflow(key: str) -> None: + with pytest.raises(cv.Invalid): + _validate({key: "700Hz"}) + + +def test_accepts_frequency_register_limit() -> None: + thresholds = atm90e32._resolve_thresholds( + _validate({"frequency_high_hz": "655.35Hz"}) + ) + assert thresholds["frequency_high_hz"] == 655.35 + + +@pytest.mark.parametrize( + "low, high", + [ + ("59.996Hz", "60.004Hz"), + ("62Hz", "58Hz"), + ], +) +def test_rejects_inverted_or_collapsed_frequency_band(low: str, high: str) -> None: + with pytest.raises(cv.Invalid, match="frequency_low_hz must be lower"): + _validate({"frequency_low_hz": low, "frequency_high_hz": high}) + + +@pytest.mark.parametrize( + "thresholds", + [ + {"voltage_sag_v": "1V"}, + {"voltage_peak_v": "200V"}, + {"voltage_sag_pct": "1%"}, + {"voltage_peak_pct": "180%"}, + ], +) +def test_absolute_and_percent_voltages_require_safety_override( + thresholds: ConfigType, +) -> None: + with pytest.raises(cv.Invalid, match="allow_unsafe_thresholds"): + _validate(thresholds) + thresholds["allow_unsafe_thresholds"] = True + _validate(thresholds, {"phase_a": {"gain_voltage": 20000}}) + + +@pytest.mark.parametrize( + "thresholds, phases", + [ + ({"voltage_nominal": "240V"}, {"phase_a": {}}), + ({"current_peak": "50A"}, {"phase_a": {"gain_ct": 10000}}), + ({"voltage_peak_v": "150V"}, {"phase_b": {"gain_voltage": 0}}), + ({"current_peak": "10A"}, {"phase_c": {"gain_ct": 0}}), + ( + {"current_peak": "10A"}, + {"phase_a": {"gain_ct": 0}, "phase_b": {}}, + ), + ( + {"voltage_nominal": "120V"}, + {"phase_a": {"gain_voltage": 0}, "phase_b": {}}, + ), + ({"current_peak": "10A"}, {}), + ({"voltage_nominal": "120V"}, {}), + ], +) +def test_rejects_thresholds_unrepresentable_at_configured_gain( + thresholds: ConfigType, phases: ConfigType +) -> None: + with pytest.raises(cv.Invalid, match="gain"): + _validate(thresholds, phases) + + +@pytest.mark.parametrize("phase", ["phase_b", "phase_c"]) +def test_single_phase_threshold_does_not_require_phase_a(phase: str) -> None: + thresholds = atm90e32._resolve_thresholds( + _validate({"current_peak": "10A"}, {phase: {}}) + ) + assert thresholds["current_peak"] == 10 + + +@pytest.mark.parametrize( + "thresholds, phases", + [ + ( + {"current_peak": "10A"}, + {"phase_a": {"gain_ct": 15000}, "phase_b": {"gain_ct": 20000}}, + ), + ( + {"voltage_nominal": "120V"}, + {"phase_a": {"gain_voltage": 15000}, "phase_b": {"gain_voltage": 20000}}, + ), + ( + {"current_peak": "50A"}, + {"phase_a": {"gain_ct": 10000}, "phase_b": {"gain_ct": 27961}}, + ), + ( + {"voltage_peak_v": "150V"}, + {"phase_a": {"gain_voltage": 4000}, "phase_b": {}}, + ), + ], +) +def test_accepts_different_phase_gains_for_a_shared_threshold( + thresholds: ConfigType, phases: ConfigType +) -> None: + _validate(thresholds, phases) + + +@pytest.mark.parametrize( + "thresholds, phases, key", + [ + ( + {"voltage_sag_v": "96V"}, + {"phase_a": {"gain_voltage": 3000}, "phase_b": {}}, + "voltage_sag_v", + ), + ( + {"voltage_nominal": "200V", "voltage_sag_v": "100V"}, + {"phase_a": {"gain_voltage": 4000}, "phase_b": {}}, + "voltage_peak_v", + ), + ( + {"current_peak": "50A"}, + {"phase_a": {"gain_ct": 5000}, "phase_b": {"gain_ct": 10000}}, + "current_peak", + ), + ], +) +def test_rejects_overflow_at_the_gain_used_by_shared_register( + thresholds: ConfigType, phases: ConfigType, key: str +) -> None: + with pytest.raises(cv.Invalid, match=key): + _validate(thresholds, phases) + + +@pytest.mark.parametrize( + "key, value", + [ + ("current_peak", float("nan")), + ("voltage_nominal", float("inf")), + ("frequency_high_hz", float("nan")), + ], +) +def test_rejects_nonfinite_thresholds(key: str, value: Any) -> None: + with pytest.raises(cv.Invalid): + _validate({key: value}) + + +def test_safety_override_does_not_allow_register_overflow() -> None: + with pytest.raises(cv.Invalid, match="gain"): + _validate({"voltage_peak_v": "1000V", "allow_unsafe_thresholds": True}) + + +def test_default_thresholds_do_not_reject_legacy_phase_gains() -> None: + _validate(phases={"phase_a": {"gain_ct": 10000}, "phase_b": {"gain_ct": 20000}}) + + +def test_default_peak_that_cannot_fit_warns(caplog: pytest.LogCaptureFixture) -> None: + # The Gelidus PM1 calibration: 122% of 120V does not fit the overvoltage register. + with caplog.at_level(logging.WARNING): + _validate(phases={"phase_a": {"gain_voltage": 4470}}) + assert "thresholds.voltage_peak_v (146.4)" in caplog.text + assert "at most 126.4" in caplog.text + + +def test_sag_only_keeps_default_peak_a_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + _validate({"voltage_sag_v": "96V"}, {"phase_a": {"gain_voltage": 4470}}) + assert "thresholds.voltage_peak_v (146.4)" in caplog.text + + +def test_validated_thresholds_keep_the_user_keys() -> None: + thresholds = _validate({"voltage_sag_pct": "80%"})["thresholds"] + # Adding the derived _v keys would break the Exclusive groups when the output is loaded again. + assert "voltage_sag_v" not in thresholds + assert "voltage_peak_v" not in thresholds + atm90e32.THRESHOLDS_SCHEMA(thresholds) + + +def test_current_peak_stays_below_rms_saturation() -> None: + with pytest.raises(cv.Invalid): + atm90e32.THRESHOLDS_SCHEMA({"current_peak": "65.535A"}) + atm90e32.THRESHOLDS_SCHEMA({"current_peak": "65.53A"}) + + +def test_explicit_peak_that_cannot_fit_names_the_limit() -> None: + with pytest.raises(cv.Invalid, match="highest value that fits is 126.4"): + _validate({"voltage_peak_v": "130V"}, {"phase_a": {"gain_voltage": 4470}}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "thresholds, current_peak, low, high", + [ + (None, None, 5700, 6300), + ( + {"frequency_low_hz": "59.95Hz", "frequency_high_hz": "60.01Hz"}, + None, + 5995, + 6001, + ), + ({"current_peak": "10A"}, 10.0, 5700, 6300), + ({"current_peak": "65.53A"}, 65.53, 5700, 6300), + ], +) +async def test_codegen_rounds_frequency_and_emits_configured_current( + thresholds: ConfigType | None, + current_peak: float | None, + low: int, + high: int, +) -> None: + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp32", + KEY_TARGET_FRAMEWORK: "esp-idf", + } + CORE.data["esp32"] = {"variant": "ESP32", "board": "esp32dev"} + esphome_config.path_context.set([]) + config = atm90e32.CONFIG_SCHEMA( + { + "id": "meter", + "spi_id": "spi_bus", + "cs_pin": 4, + "line_frequency": "60Hz", + "phase_b": {}, + **({"thresholds": thresholds} if thresholds is not None else {}), + } + ) + cg.new_Pvariable(config["spi_id"]) + CORE.component_ids.add("meter") + + await atm90e32.to_code(config) + CORE.flush_tasks() + + source = CORE.cpp_main_section + assert f"set_frequency_thresholds({low}, {high})" in source + assert "set_line_freq(60)" in source + if current_peak is None: + assert "set_threshold_current_peak_a" not in source + else: + assert f"set_threshold_current_peak_a({current_peak}f)" in source From 05949b7e3abe20c75263a333b4984d6317c0a291 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 02:45:49 -1000 Subject: [PATCH 012/170] [remote_base] Add tests for transmit_raw and rc_switch transmit code generation (#22000) --- .../config/raw_rc_switch_actions.yaml | 117 +++++++++++++++ .../test_raw_rc_switch_actions.py | 135 ++++++++++++++++++ .../remote_transmitter/common-buttons.yaml | 35 +++++ 3 files changed, 287 insertions(+) create mode 100644 tests/component_tests/remote_transmitter/config/raw_rc_switch_actions.yaml create mode 100644 tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py diff --git a/tests/component_tests/remote_transmitter/config/raw_rc_switch_actions.yaml b/tests/component_tests/remote_transmitter/config/raw_rc_switch_actions.yaml new file mode 100644 index 00000000000..e1851ae8438 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/raw_rc_switch_actions.yaml @@ -0,0 +1,117 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + id: tx + pin: GPIO4 + carrier_duty_percent: 50% + +script: + - id: send_scaled + parameters: + x: int + then: + - remote_transmitter.transmit_raw: + code: !lambda |- + x *= 2; + return {x, -1000}; + carrier_frequency: !lambda return x *= 1000; + repeat: + times: !lambda return x *= 1; + wait_time: !lambda x *= 3; return x; + - remote_transmitter.transmit_rc_switch_type_b: + address: !lambda x *= 2; return x; + channel: !lambda return x *= 1; + state: !lambda return x > 0; + protocol: !lambda x *= 1; return remote_base::rc_switch_protocol(x); + +button: + - platform: template + name: Raw static + on_press: + - remote_transmitter.transmit_raw: + code: [1000, -1000, 500, -500] + - platform: template + name: Raw static repeat + on_press: + - remote_transmitter.transmit_raw: + code: [1000, -1000, 500, -500] + carrier_frequency: 38kHz + repeat: + times: 2 + wait_time: 10ms + - platform: template + name: Raw lambda + on_press: + - remote_transmitter.transmit_raw: + code: !lambda return {2000, -1000}; + - platform: template + name: RC switch raw + on_press: + - remote_transmitter.transmit_rc_switch_raw: + code: "00101001100111110101xxxx" + protocol: 2 + - platform: template + name: RC switch raw lambda + on_press: + - remote_transmitter.transmit_rc_switch_raw: + code: !lambda return std::string("0010100110"); + repeat: + times: 2 + wait_time: 10ms + - platform: template + name: RC switch type A + on_press: + - remote_transmitter.transmit_rc_switch_type_a: + group: "11001" + device: "01000" + state: true + protocol: + pulse_length: 175 + - platform: template + name: RC switch type A lambda + on_press: + - remote_transmitter.transmit_rc_switch_type_a: + group: !lambda return std::string("11001"); + device: !lambda return std::string("01000"); + state: !lambda return false; + - platform: template + name: RC switch type B + on_press: + - remote_transmitter.transmit_rc_switch_type_b: + address: 4 + channel: 2 + state: true + - platform: template + name: RC switch type C + on_press: + - remote_transmitter.transmit_rc_switch_type_c: + family: "c" + group: 1 + device: 2 + state: true + - platform: template + name: RC switch type C lambda + on_press: + - remote_transmitter.transmit_rc_switch_type_c: + family: !lambda return std::string("C"); + group: !lambda return 1; + device: !lambda return 2; + state: !lambda return true; + - platform: template + name: RC switch type D + on_press: + - remote_transmitter.transmit_rc_switch_type_d: + group: "b" + device: 3 + state: false + - platform: template + name: RC switch type D lambda + on_press: + - remote_transmitter.transmit_rc_switch_type_d: + group: !lambda return std::string("b"); + device: !lambda return 3; + state: !lambda return false; diff --git a/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py b/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py new file mode 100644 index 00000000000..1703ddf6ede --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_raw_rc_switch_actions.py @@ -0,0 +1,135 @@ +"""transmit_raw and the rc_switch transmit actions as generated code.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +CONFIG = "raw_rc_switch_actions.yaml" + + +@pytest.fixture +def main_cpp( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> str: + # Without the #line directives a lambda body is the user's text alone + main = generate_main(component_config_path(CONFIG)) + return re.sub(r"\n *(#line .*|// ESPHOME_LINE_DIRECTIVE_RESET)(?=\n)", "", main) + + +def _action(main_cpp: str, action_id: str) -> str: + """The setup statements of one action object.""" + marker = f"new({action_id}) " + start = main_cpp.index(marker) + # Ends at the next object constructed, or at the end of the file + end = main_cpp.find("new(", start + len(marker)) + return main_cpp[start : end if end != -1 else len(main_cpp)] + + +def test_raw_static_code_in_flash(main_cpp: str) -> None: + raw = _action(main_cpp, "remote_base_rawaction_id_2") + assert ( + "static constexpr int32_t int32_t_id_2[] PROGMEM = {1000, -1000, 500, -500};" + in raw + ) + assert "->set_code_static(int32_t_id_2, 4);" in raw + assert "->set_carrier_frequency([]() -> uint32_t {\n return 0;\n});" in raw + # Without repeat the call sends once + assert "set_send_times" not in raw + assert "set_send_wait" not in raw + + +def test_raw_repeat_and_carrier(main_cpp: str) -> None: + raw = _action(main_cpp, "remote_base_rawaction_id_3") + assert "->set_send_times([]() -> uint32_t {\n return 2;\n});" in raw + assert "->set_send_wait([]() -> uint32_t {\n return 10000;\n});" in raw + assert "->set_carrier_frequency([]() -> uint32_t {\n return 38000;\n});" in raw + + +def test_raw_lambda_code(main_cpp: str) -> None: + raw = _action(main_cpp, "remote_base_rawaction_id_4") + assert ( + "->set_code_template([]() -> std::vector {\n" + " return {2000, -1000};\n" + "});" + ) in raw + + +def test_rc_switch_raw(main_cpp: str) -> None: + constant = _action(main_cpp, "remote_base_rcswitchrawaction_id") + assert "return remote_base::rc_switch_protocol(2);" in constant + assert '->set_code(ESPHOME_F("00101001100111110101xxxx"));' in constant + # The rc_switch actions also send once without repeat + assert "set_send_times" not in constant + lambda_ = _action(main_cpp, "remote_base_rcswitchrawaction_id_2") + assert "return remote_base::rc_switch_protocol(1);" in lambda_ + assert ' return std::string("0010100110");' in lambda_ + assert "->set_send_times([]() -> uint32_t {\n return 2;\n});" in lambda_ + assert "->set_send_wait([]() -> uint32_t {\n return 10000;\n});" in lambda_ + + +def test_rc_switch_type_a(main_cpp: str) -> None: + constant = _action(main_cpp, "remote_base_rcswitchtypeaaction_id") + assert ( + "return remote_base::rc_switch_protocol_copy(rc_switch_custom_protocol);" + in constant + ) + assert '->set_group(ESPHOME_F("11001"));' in constant + assert '->set_device(ESPHOME_F("01000"));' in constant + assert "->set_state([]() -> bool {\n return true;\n});" in constant + lambda_ = _action(main_cpp, "remote_base_rcswitchtypeaaction_id_2") + assert '->set_group([]() -> std::string {\n return std::string("11001");' in ( + lambda_ + ) + assert '->set_device([]() -> std::string {\n return std::string("01000");' in ( + lambda_ + ) + assert "->set_state([]() -> bool {\n return false;" in lambda_ + + +def test_rc_switch_type_b(main_cpp: str) -> None: + constant = _action(main_cpp, "remote_base_rcswitchtypebaction_id_2") + assert "->set_address([]() -> uint8_t {\n return 4;\n});" in constant + assert "->set_channel([]() -> uint8_t {\n return 2;\n});" in constant + assert "->set_state([]() -> bool {\n return true;\n});" in constant + + +def test_rc_switch_type_c(main_cpp: str) -> None: + constant = _action(main_cpp, "remote_base_rcswitchtypecaction_id") + assert '->set_family(ESPHOME_F("c"));' in constant + assert "->set_group([]() -> uint8_t {\n return 1;\n});" in constant + assert "->set_device([]() -> uint8_t {\n return 2;\n});" in constant + lambda_ = _action(main_cpp, "remote_base_rcswitchtypecaction_id_2") + assert '->set_family([]() -> std::string {\n return std::string("C");' in ( + lambda_ + ) + + +def test_rc_switch_type_d(main_cpp: str) -> None: + constant = _action(main_cpp, "remote_base_rcswitchtypedaction_id") + assert '->set_group(ESPHOME_F("b"));' in constant + assert "->set_device([]() -> uint8_t {\n return 3;\n});" in constant + assert "->set_state([]() -> bool {\n return false;\n});" in constant + lambda_ = _action(main_cpp, "remote_base_rcswitchtypedaction_id_2") + assert '->set_group([]() -> std::string {\n return std::string("b");' in lambda_ + + +def test_lambdas_take_args_by_value(main_cpp: str) -> None: + """User lambdas get the script argument by value, so they may modify it.""" + raw = _action(main_cpp, "remote_base_rawaction_id") + assert ( + "[](int x) -> std::vector {\n x *= 2;\n return {x, -1000};\n}" + in raw + ) + assert "[](int x) -> uint32_t {\n return x *= 1000;\n}" in raw + assert "[](int x) -> uint32_t {\n return x *= 1;\n}" in raw + assert "[](int x) -> uint32_t {\n x *= 3; return x;\n}" in raw + type_b = _action(main_cpp, "remote_base_rcswitchtypebaction_id") + assert "[](int x) -> uint8_t {\n x *= 2; return x;\n}" in type_b + assert "[](int x) -> uint8_t {\n return x *= 1;\n}" in type_b + assert ( + "[](int x) -> remote_base::RCSwitchBase {\n" + " x *= 1; return remote_base::rc_switch_protocol(x);\n}" + ) in type_b diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index e09ada83018..350d5e3d7c5 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1059,3 +1059,38 @@ button: - remote_transmitter.transmit_raw: code: [1000, -1000] carrier_frequency: !lambda return 38000; + +script: + # The lambdas modify their by-value argument + - id: remote_transmitter_send_scaled + parameters: + x: int + then: + - remote_transmitter.transmit_raw: + code: !lambda |- + x *= 2; + return {x, -1000}; + carrier_frequency: !lambda return x *= 1000; + repeat: + times: !lambda return x *= 1; + wait_time: !lambda x *= 3; return x; + - remote_transmitter.transmit_rc_switch_raw: + code: !lambda x *= 1; return std::string(x, '1'); + - remote_transmitter.transmit_rc_switch_type_a: + group: !lambda x *= 1; return std::string("11001"); + device: !lambda return std::string(x *= 1, '0'); + state: !lambda return (x *= 1) > 0; + - remote_transmitter.transmit_rc_switch_type_b: + address: !lambda x *= 2; return x; + channel: !lambda return x *= 1; + state: !lambda return x > 0; + protocol: !lambda x *= 1; return remote_base::rc_switch_protocol(x); + - remote_transmitter.transmit_rc_switch_type_c: + family: !lambda x *= 1; return std::string("a"); + group: !lambda return x *= 1; + device: !lambda x += 1; return x; + state: !lambda return x > 0; + - remote_transmitter.transmit_rc_switch_type_d: + group: !lambda x *= 1; return std::string("a"); + device: !lambda return x *= 1; + state: !lambda x *= 1; return true; From b6460a19049e9c71246c62f425142b11992b0e4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 02:46:12 -1000 Subject: [PATCH 013/170] [light] Add tests for dim_relative and effect next/previous code generation (#21999) --- .../light/config/dim_relative_actions.yaml | 49 +++++++++++ .../light/test_dim_relative_codegen.py | 84 +++++++++++++++++++ tests/components/light/common.yaml | 11 +++ .../fixtures/light_dim_relative_action.yaml | 24 ++++++ .../fixtures/light_effect_cycle_action.yaml | 60 +++++++++++++ .../test_light_dim_relative_action.py | 9 ++ .../test_light_effect_cycle_action.py | 49 +++++++++++ 7 files changed, 286 insertions(+) create mode 100644 tests/component_tests/light/config/dim_relative_actions.yaml create mode 100644 tests/component_tests/light/test_dim_relative_codegen.py create mode 100644 tests/integration/fixtures/light_effect_cycle_action.yaml create mode 100644 tests/integration/test_light_effect_cycle_action.py diff --git a/tests/component_tests/light/config/dim_relative_actions.yaml b/tests/component_tests/light/config/dim_relative_actions.yaml new file mode 100644 index 00000000000..343a28b6947 --- /dev/null +++ b/tests/component_tests/light/config/dim_relative_actions.yaml @@ -0,0 +1,49 @@ +esphome: + name: test-dim-relative-actions + +esp32: + board: esp32dev + +output: + - platform: ledc + id: out_a + pin: GPIO4 + +light: + - platform: monochromatic + id: dim_light + output: out_a + effects: + - strobe: + - pulse: + +script: + - id: dim_plain + then: + - light.dim_relative: + id: dim_light + relative_brightness: 10% + - id: dim_limits + then: + - light.dim_relative: + id: dim_light + relative_brightness: -5% + transition_length: 250ms + brightness_limits: + min_brightness: 20% + max_brightness: 90% + limit_mode: DO_NOTHING + - id: dim_lambdas + parameters: + step: float + then: + - light.dim_relative: + id: dim_light + relative_brightness: !lambda "step *= 0.5f; return step;" + transition_length: !lambda "return step *= 100.0f;" + - id: effect_cycle + then: + - light.effect.next: dim_light + - light.effect.previous: + id: dim_light + include_none: true diff --git a/tests/component_tests/light/test_dim_relative_codegen.py b/tests/component_tests/light/test_dim_relative_codegen.py new file mode 100644 index 00000000000..e4dd620663b --- /dev/null +++ b/tests/component_tests/light/test_dim_relative_codegen.py @@ -0,0 +1,84 @@ +"""Tests for the code generated for light.dim_relative and light.effect.next/previous.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +@pytest.fixture +def main_cpp( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> str: + return generate_main(component_config_path("dim_relative_actions.yaml")) + + +def test_dim_relative_without_transition_or_limits(main_cpp: str) -> None: + """No transition length and no limits: the defaults apply.""" + assert ( + "new(light_dimrelativeaction_id) light::DimRelativeAction(dim_light);\n" + "light_dimrelativeaction_id->set_relative_brightness([]() -> float {\n" + " return 0.1f;\n" + "});\n" + "new(automation_id) Automation<>(dim_plain);\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.""" + assert ( + "new(light_dimrelativeaction_id_2) light::DimRelativeAction(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" + ) in main_cpp + + +@pytest.mark.parametrize( + ("return_type", "body"), + [("float", "step *= 0.5f; return step;"), ("uint32_t", "return step *= 100.0f;")], +) +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.""" + head = f"[](float step) -> {return_type} {{\n" + start = main_cpp.index(head) + assert f" {body}\n" in main_cpp[start : main_cpp.index("});", start)] + + +def test_effect_cycle(main_cpp: str) -> None: + """The direction is a template argument and include_none is always set.""" + assert ( + "new(light_lighteffectcycleaction_id) light::LightEffectCycleAction(dim_light);\n" + "light_lighteffectcycleaction_id->set_include_none(false);\n" + "new(light_lighteffectcycleaction_id_2) light::LightEffectCycleAction(dim_light);\n" + "light_lighteffectcycleaction_id_2->set_include_none(true);\n" + ) in main_cpp + + +@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] +) -> None: + """Cycling a light that has no effects is refused at validation, so it never runs.""" + config = tmp_path / "no_effects.yaml" + config.write_text( + "esphome:\n name: test-no-effects\n" + "esp32:\n board: esp32dev\n" + "output:\n - platform: ledc\n id: out_a\n pin: GPIO4\n" + "light:\n - platform: monochromatic\n id: bare_light\n output: out_a\n" + f"script:\n - id: cycle\n then:\n - {action}: bare_light\n" + ) + CORE.config_path = config + assert read_config({}) is None + assert "has no effects configured" in capsys.readouterr().out diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 4e53b0ba830..93e4b7b76e4 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -281,6 +281,17 @@ number: id: test_monochromatic_light brightness: !lambda "return x / 100.0f;" +# The lambdas get the script parameter by value, so they may modify it. +script: + - id: test_dim_relative_script + parameters: + step: float + then: + - light.dim_relative: + id: test_monochromatic_light + relative_brightness: !lambda "step *= 0.5f; return step;" + transition_length: !lambda "return step *= 100.0f;" + output: - platform: template id: light_test_restore_state_keep diff --git a/tests/integration/fixtures/light_dim_relative_action.yaml b/tests/integration/fixtures/light_dim_relative_action.yaml index b52cf65b89c..0d3f111e9dc 100644 --- a/tests/integration/fixtures/light_dim_relative_action.yaml +++ b/tests/integration/fixtures/light_dim_relative_action.yaml @@ -58,3 +58,27 @@ button: relative_brightness: 50% brightness_limits: max_brightness: 80% + + # Test 4: DO_NOTHING outside the limits skips only this action, not the one after it + - platform: template + id: btn_dim_skip + name: "Dim Skip" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: 10% + brightness_limits: + min_brightness: 90% + limit_mode: DO_NOTHING + - light.dim_relative: + id: test_light + relative_brightness: -5% + + # Test 5: a relative dim that clamps to 0 turns the light off + - platform: template + id: btn_dim_off + name: "Dim Off" + on_press: + - light.dim_relative: + id: test_light + relative_brightness: -100% diff --git a/tests/integration/fixtures/light_effect_cycle_action.yaml b/tests/integration/fixtures/light_effect_cycle_action.yaml new file mode 100644 index 00000000000..b9a1d3b8dcd --- /dev/null +++ b/tests/integration/fixtures/light_effect_cycle_action.yaml @@ -0,0 +1,60 @@ +esphome: + name: light-effect-cycle-action-test +host: +api: +logger: + level: DEBUG + +output: + - platform: template + id: test_out + type: float + write_action: + - lambda: "" + +light: + - platform: monochromatic + name: "Test Light" + id: test_light + output: test_out + default_transition_length: 0s + effects: + - lambda: + name: "A" + update_interval: 1h + lambda: "" + - lambda: + name: "B" + update_interval: 1h + lambda: "" + +button: + - platform: template + id: btn_setup + name: "Setup" + on_press: + - light.turn_on: test_light + - platform: template + id: btn_next_skip + name: "Next Skip" + on_press: + - light.effect.next: test_light + - platform: template + id: btn_prev_skip + name: "Prev Skip" + on_press: + - light.effect.previous: test_light + - platform: template + id: btn_next_none + name: "Next None" + on_press: + - light.effect.next: + id: test_light + include_none: true + - platform: template + id: btn_prev_none + name: "Prev None" + on_press: + - light.effect.previous: + id: test_light + include_none: true diff --git a/tests/integration/test_light_dim_relative_action.py b/tests/integration/test_light_dim_relative_action.py index 15194d8293b..ed523c7177f 100644 --- a/tests/integration/test_light_dim_relative_action.py +++ b/tests/integration/test_light_dim_relative_action.py @@ -48,3 +48,12 @@ async def test_light_dim_relative_action( # Test 3: dim_relative with max_brightness limit: 65% + 50% clamped to 80% state = await press("dim_clamp") assert state.brightness == pytest.approx(0.80, abs=0.05) + + # Test 4: 80% is below the 90% minimum, so DO_NOTHING skips the +10% + # and the -5% after it still runs: 75% + state = await press("dim_skip") + assert state.brightness == pytest.approx(0.75, abs=0.05) + + # Test 5: 75% - 100% clamps to 0, which turns the light off + state = await press("dim_off") + assert state.state is False diff --git a/tests/integration/test_light_effect_cycle_action.py b/tests/integration/test_light_effect_cycle_action.py new file mode 100644 index 00000000000..9d50750ea9e --- /dev/null +++ b/tests/integration/test_light_effect_cycle_action.py @@ -0,0 +1,49 @@ +"""Integration test for light.effect.next and light.effect.previous.""" + +from __future__ import annotations + +from functools import partial + +from aioesphomeapi import LightInfo, LightState +import pytest + +from .state_utils import press_and_wait, require_entity, subscribe_after_initial_states +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_effect_cycle_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Cycle both ways through two effects, with and without "None".""" + async with run_compiled(yaml_config), api_client_connected() as client: + entities, _ = await client.list_entities_services() + sub = await subscribe_after_initial_states(client, entities) + + require_entity(entities, "test_light", LightInfo) + + press = partial( + press_and_wait, client, entities, sub.waiter, state_type=LightState + ) + + state = await press("setup") + assert state.state is True + assert state.effect == "None" + + # Without "None": wraps from the last effect to the first and back + for button, effect in ( + ("next_skip", "A"), + ("next_skip", "B"), + ("next_skip", "A"), + ("prev_skip", "B"), + ("prev_skip", "A"), + # With "None": it sits between the last and the first effect + ("prev_none", "None"), + ("prev_none", "B"), + ("next_none", "None"), + ("next_none", "A"), + ): + state = await press(button) + assert state.effect == effect, button From 176ab8433ef9dfc483f6e627baa43d12db21934a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 02:46:31 -1000 Subject: [PATCH 014/170] [deep_sleep] Add tests for deep_sleep.enter code generation (#21997) --- .../deep_sleep/test_deep_sleep.py | 80 +++++++++++++++++++ .../deep_sleep/test_deep_sleep_enter.yaml | 48 +++++++++++ tests/components/deep_sleep/common.yaml | 11 +++ 3 files changed, 139 insertions(+) create mode 100644 tests/component_tests/deep_sleep/test_deep_sleep_enter.yaml diff --git a/tests/component_tests/deep_sleep/test_deep_sleep.py b/tests/component_tests/deep_sleep/test_deep_sleep.py index 1059a81a02c..ac02a3550d2 100644 --- a/tests/component_tests/deep_sleep/test_deep_sleep.py +++ b/tests/component_tests/deep_sleep/test_deep_sleep.py @@ -2,6 +2,7 @@ from collections.abc import Callable from pathlib import Path +import re import pytest @@ -218,3 +219,82 @@ def test_deep_sleep_esp32_wakeup_pin_mode_at_both_levels_rejected( with pytest.raises(cv.Invalid, match="not both") as exc: deep_sleep.validate_config(config) assert exc.value.path == [CONF_WAKEUP_PIN, 0, deep_sleep.CONF_WAKEUP_PIN_MODE] + + +ENTER_YAML = "tests/component_tests/deep_sleep/test_deep_sleep_enter.yaml" + + +def test_deep_sleep_enter_without_duration( + generate_main: Callable[[str | Path], str], +) -> None: + """A bare deep_sleep.enter only begins a manual sleep with the configured duration.""" + main_cpp = generate_main(ENTER_YAML) + + assert ( + "new(deep_sleep_enterdeepsleepaction_id) " + "deep_sleep::EnterDeepSleepAction<>(deepsleep);\n" + "new(automation_id) Automation<>(sleep_now);" + ) in main_cpp + + +def test_deep_sleep_enter_constant_duration( + generate_main: Callable[[str | Path], str], +) -> None: + """A constant sleep_duration is set in milliseconds.""" + main_cpp = generate_main(ENTER_YAML) + + assert ( + "deep_sleep_enterdeepsleepaction_id_2->set_sleep_duration([]() -> uint32_t {\n" + " return 3600000;\n" + "});" + ) in main_cpp + + +def test_deep_sleep_enter_lambda_duration( + generate_main: Callable[[str | Path], str], +) -> None: + """A lambda sleep_duration is computed from the script parameters.""" + main_cpp = generate_main(ENTER_YAML) + + assert re.search( + r"new\(deep_sleep_enterdeepsleepaction_id_3\) " + r"deep_sleep::EnterDeepSleepAction\(deepsleep\);\n" + r"deep_sleep_enterdeepsleepaction_id_3->set_sleep_duration\(" + r"\[\]\(uint32_t seconds\) -> uint32_t \{\n(?: #line [^\n]*\n)?" + r" return seconds \* 1000;\n", + main_cpp, + ) + + +@pytest.mark.parametrize( + ("action_id", "body"), + [ + ("deep_sleep_enterdeepsleepaction_id_4", "seconds *= 1000; return seconds;"), + ("deep_sleep_enterdeepsleepaction_id_5", "return seconds *= 1000;"), + ], +) +def test_deep_sleep_enter_lambda_takes_args_by_value( + generate_main: Callable[[str | Path], str], action_id: str, body: str +) -> None: + """The sleep_duration lambda gets the script parameters by value, so it may modify them.""" + main_cpp = generate_main(ENTER_YAML) + + assert re.search( + action_id + r"->set_sleep_duration\(" + r"\[\]\(uint32_t seconds\) -> uint32_t \{\n(?: #line [^\n]*\n)? " + + re.escape(body) + + "\n", + main_cpp, + ) + + +def test_deep_sleep_enter_until( + generate_main: Callable[[str | Path], str], +) -> None: + """The until and time_id keys set the wake time of day and the clock to read it from.""" + main_cpp = generate_main(ENTER_YAML) + + assert ( + "deep_sleep_enterdeepsleepaction_id_6->set_until(6, 30, 0);\n" + "deep_sleep_enterdeepsleepaction_id_6->set_time(sntp_time);" + ) in main_cpp diff --git a/tests/component_tests/deep_sleep/test_deep_sleep_enter.yaml b/tests/component_tests/deep_sleep/test_deep_sleep_enter.yaml new file mode 100644 index 00000000000..7dcc2b017d5 --- /dev/null +++ b/tests/component_tests/deep_sleep/test_deep_sleep_enter.yaml @@ -0,0 +1,48 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + +wifi: + ssid: MySSID + password: password1 + +time: + - platform: sntp + id: sntp_time + +deep_sleep: + id: deepsleep + +script: + - id: sleep_now + then: + - deep_sleep.enter: deepsleep + - id: sleep_constant + then: + - deep_sleep.enter: + sleep_duration: 60min + - id: sleep_seconds + parameters: + seconds: uint32_t + then: + - deep_sleep.enter: + sleep_duration: !lambda "return seconds * 1000;" + - id: sleep_scaled + parameters: + seconds: uint32_t + then: + - deep_sleep.enter: + sleep_duration: !lambda "seconds *= 1000; return seconds;" + - id: sleep_scaled_return + parameters: + seconds: uint32_t + then: + - deep_sleep.enter: + sleep_duration: !lambda "return seconds *= 1000;" + - id: sleep_until + then: + - deep_sleep.enter: + until: "06:30:00" + time_id: sntp_time diff --git a/tests/components/deep_sleep/common.yaml b/tests/components/deep_sleep/common.yaml index df5b504bb38..02ea37812cb 100644 --- a/tests/components/deep_sleep/common.yaml +++ b/tests/components/deep_sleep/common.yaml @@ -21,3 +21,14 @@ esphome: lambda: 'return deep_sleep::woke_from_deep_sleep();' then: - logger.log: Woke from deep sleep + +script: + - id: deep_sleep_for_seconds + parameters: + seconds: uint32_t + then: + # The lambdas modify their by-value parameter + - deep_sleep.enter: + sleep_duration: !lambda "seconds *= 1000; return seconds;" + - deep_sleep.enter: + sleep_duration: !lambda "return seconds *= 1000;" From 9aceee001e6cfc5977582a4df70b4baa1c0b27f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 02:47:13 -1000 Subject: [PATCH 015/170] [display] Add tests for display.page.show code generation (#21995) --- .../display/config/page_show.yaml | 45 ++++++++++++++++ .../display/test_display_page_show.py | 51 +++++++++++++++++++ .../display/common-page-change.yaml | 11 ++++ .../display/test-page-change.esp8266-ard.yaml | 6 +++ 4 files changed, 113 insertions(+) create mode 100644 tests/component_tests/display/config/page_show.yaml create mode 100644 tests/component_tests/display/test_display_page_show.py create mode 100644 tests/components/display/test-page-change.esp8266-ard.yaml diff --git a/tests/component_tests/display/config/page_show.yaml b/tests/component_tests/display/config/page_show.yaml new file mode 100644 index 00000000000..2c8f079e92f --- /dev/null +++ b/tests/component_tests/display/config/page_show.yaml @@ -0,0 +1,45 @@ +esphome: + name: test + +esp32: + board: esp32dev + +spi: + clk_pin: GPIO14 + mosi_pin: GPIO12 + +display: + - platform: epaper_spi + id: epaper_display + model: ssd1677 + dimensions: + width: 200 + height: 200 + cs_pin: GPIO5 + dc_pin: GPIO21 + busy_pin: GPIO22 + reset_pin: GPIO25 + pages: + - id: page1 + lambda: it.fill(Color::BLACK); + - id: page2 + lambda: it.fill(Color::WHITE); + +button: + - platform: template + id: show_page_button + on_press: + - display.page.show: + id: !lambda return id(page2); + - display.page.show: page1 + +script: + - id: show_page_by_index + parameters: + index: int + then: + # The page lambda may modify its by-value argument + - display.page.show: + id: !lambda "index *= 2; return index == 0 ? id(page1) : id(page2);" + - display.page.show: + id: !lambda "return (index *= 2) == 0 ? id(page1) : id(page2);" diff --git a/tests/component_tests/display/test_display_page_show.py b/tests/component_tests/display/test_display_page_show.py new file mode 100644 index 00000000000..e83515cfe66 --- /dev/null +++ b/tests/component_tests/display/test_display_page_show.py @@ -0,0 +1,51 @@ +"""Tests for the code generated for the display.page.show action.""" + +from collections.abc import Callable + +import pytest + + +@pytest.fixture +def main_cpp(generate_main: Callable[[str], str]) -> str: + return generate_main("tests/component_tests/display/config/page_show.yaml") + + +def test_page_show_constant_page(main_cpp: str) -> None: + """A page id is wrapped in a lambda that returns the page.""" + assert ( + "new(display_displaypageshowaction_id_2) display::DisplayPageShowAction<>();\n" + "display_displaypageshowaction_id_2->set_page([]() -> display::DisplayPage * {\n" + " return page1;\n" + "});" + ) in main_cpp + + +def test_page_show_lambda_page(main_cpp: str) -> None: + """A page lambda is stored on the action.""" + # The user lambda carries #line directives; match the block without them + code = "\n".join( + line + for line in main_cpp.splitlines() + if not line.lstrip().startswith(("#line", "// ESPHOME_LINE_DIRECTIVE_RESET")) + ) + assert ( + "display_displaypageshowaction_id->set_page([]() -> display::DisplayPage * {\n" + " return page2;\n" + "});" + ) in code + + +@pytest.mark.parametrize( + "body", + [ + "index *= 2; return index == 0 ? page1 : page2;", + "return (index *= 2) == 0 ? page1 : page2;", + ], +) +def test_page_show_lambda_takes_args_by_value(main_cpp: str, body: str) -> None: + """The page lambda gets the trigger args by value, so it may modify them.""" + lambda_start = main_cpp.index(f" {body}\n") + head = main_cpp.rindex("[", 0, lambda_start) + assert main_cpp[head:lambda_start].startswith( + "[](int index) -> display::DisplayPage * {\n" + ) diff --git a/tests/components/display/common-page-change.yaml b/tests/components/display/common-page-change.yaml index 1d18213a447..4b0c45ff97c 100644 --- a/tests/components/display/common-page-change.yaml +++ b/tests/components/display/common-page-change.yaml @@ -32,3 +32,14 @@ button: - display.page.show: display_page_change_page1 - display.page.show: id: !lambda return id(display_page_change_page2); + +script: + - id: display_page_change_show_by_index + parameters: + index: int + then: + # The page lambda may modify its by-value argument + - display.page.show: + id: !lambda "index *= 2; return index == 0 ? id(display_page_change_page1) : id(display_page_change_page2);" + - display.page.show: + id: !lambda "return (index *= 2) == 0 ? id(display_page_change_page1) : id(display_page_change_page2);" diff --git a/tests/components/display/test-page-change.esp8266-ard.yaml b/tests/components/display/test-page-change.esp8266-ard.yaml new file mode 100644 index 00000000000..7c8b03f5b7a --- /dev/null +++ b/tests/components/display/test-page-change.esp8266-ard.yaml @@ -0,0 +1,6 @@ +substitutions: + reset_pin: GPIO2 + +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + display: !include common-page-change.yaml From c1a068220f0d06e323e614fe1d7ae8295cb9f9a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 02:47:46 -1000 Subject: [PATCH 016/170] [core] Add tests for on_boot code generation and ordering (#21988) --- .../automation/config/core_automations.yaml | 36 ++++++ .../config/on_boot_waits_for_ids.yaml | 28 +++++ .../automation/test_core_automations.py | 106 ++++++++++++++++++ .../fixtures/core_automation_order.yaml | 32 ++++++ .../integration/test_core_automation_order.py | 46 ++++++++ 5 files changed, 248 insertions(+) create mode 100644 tests/component_tests/automation/config/core_automations.yaml create mode 100644 tests/component_tests/automation/config/on_boot_waits_for_ids.yaml create mode 100644 tests/component_tests/automation/test_core_automations.py create mode 100644 tests/integration/fixtures/core_automation_order.yaml create mode 100644 tests/integration/test_core_automation_order.py diff --git a/tests/component_tests/automation/config/core_automations.yaml b/tests/component_tests/automation/config/core_automations.yaml new file mode 100644 index 00000000000..22c178225fa --- /dev/null +++ b/tests/component_tests/automation/config/core_automations.yaml @@ -0,0 +1,36 @@ +--- +esphome: + name: test + on_boot: + - priority: 800 + then: + - logger.log: boot high + - lambda: ESP_LOGI("test", "boot high lambda"); + - then: + - logger.log: boot default + - priority: -100 + then: + - logger.log: boot before delay + - delay: 1ms + - logger.log: boot after delay + - priority: 250.5 + trigger_id: manual_boot + then: + - logger.log: boot manual + on_shutdown: + - priority: 700 + then: + - logger.log: shutdown inline + - then: + - logger.log: shutdown before delay + - delay: 1ms + on_loop: + - then: + - lambda: ESP_LOGV("test", "loop inline"); + - then: + - delay: 1ms + +esp32: + board: nodemcu-32s + +logger: diff --git a/tests/component_tests/automation/config/on_boot_waits_for_ids.yaml b/tests/component_tests/automation/config/on_boot_waits_for_ids.yaml new file mode 100644 index 00000000000..40758b4cabb --- /dev/null +++ b/tests/component_tests/automation/config/on_boot_waits_for_ids.yaml @@ -0,0 +1,28 @@ +--- +esphome: + name: test + on_boot: + # The script is declared after this block starts building, so building it waits + - priority: 800 + then: + - script.execute: boot_script + +esp32: + board: nodemcu-32s + +logger: + +script: + - id: boot_script + then: + - logger.log: script ran + +interval: + - interval: 1s + then: + - logger.log: tick + +sensor: + - platform: template + id: some_sensor + lambda: return 1.0; diff --git a/tests/component_tests/automation/test_core_automations.py b/tests/component_tests/automation/test_core_automations.py new file mode 100644 index 00000000000..cbcccd7db0c --- /dev/null +++ b/tests/component_tests/automation/test_core_automations.py @@ -0,0 +1,106 @@ +"""Tests for on_boot, on_shutdown and on_loop codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +CONFIG = "core_automations.yaml" + + +@pytest.fixture +def main_cpp( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> str: + return generate_main(component_config_path(CONFIG)) + + +def _constructed(main_cpp: str, var: str) -> str: + match = re.search(rf"new\({var}\) ([^;]+);", main_cpp) + assert match is not None, var + return match.group(1) + + +def _automation_of(main_cpp: str, trigger: str) -> str: + """The Automation built on ``trigger``, found by its trigger rather than an id counter.""" + match = re.search(rf"new\((\w+)\) Automation<>\({trigger}\);", main_cpp) + assert match is not None, trigger + return match.group(1) + + +def _has_delay(main_cpp: str, trigger: str) -> bool: + automation = _automation_of(main_cpp, trigger) + return ( + re.search(rf"{automation}->add_action\(delayaction_id\w*\);", main_cpp) + is not None + ) + + +def _registered(main_cpp: str) -> list[str]: + return re.findall(r"App\.register_component_\((\w+), \d+\);", main_cpp) + + +def test_core_automations_registration_order(main_cpp: str) -> None: + """Each block is one component, registered in config order after the core ones.""" + assert _registered(main_cpp)[-8:] == [ + "startuptrigger_id", + "startuptrigger_id_2", + "startuptrigger_id_3", + "manual_boot", + "shutdowntrigger_id", + "shutdowntrigger_id_2", + "looptrigger_id", + "looptrigger_id_2", + ] + + +def test_on_boot_inline_priorities(main_cpp: str) -> None: + """All-inline on_boot blocks keep their configured priority, default 600.""" + assert _constructed(main_cpp, "startuptrigger_id") == "StartupTrigger(800.0f)" + assert _constructed(main_cpp, "startuptrigger_id_2") == "StartupTrigger(600.0f)" + _automation_of(main_cpp, "startuptrigger_id") + _automation_of(main_cpp, "startuptrigger_id_2") + + +def test_on_boot_delay_keeps_trigger(main_cpp: str) -> None: + """A delay needs the Trigger and its Automation of separate actions.""" + assert _constructed(main_cpp, "startuptrigger_id_3") == "StartupTrigger(-100.0f)" + assert _has_delay(main_cpp, "startuptrigger_id_3") + + +def test_on_boot_manual_trigger_id_keeps_trigger(main_cpp: str) -> None: + """A trigger_id set by hand can be used from lambdas, so the Trigger stays.""" + assert _constructed(main_cpp, "manual_boot") == "StartupTrigger(250.5f)" + _automation_of(main_cpp, "manual_boot") + + +def test_on_shutdown_priorities(main_cpp: str) -> None: + """on_shutdown blocks keep their priority; a delay keeps the Trigger.""" + assert _constructed(main_cpp, "shutdowntrigger_id") == "ShutdownTrigger(700.0f)" + _automation_of(main_cpp, "shutdowntrigger_id") + assert _constructed(main_cpp, "shutdowntrigger_id_2") == "ShutdownTrigger(600.0f)" + assert _has_delay(main_cpp, "shutdowntrigger_id_2") + + +def test_on_loop(main_cpp: str) -> None: + """on_loop blocks are looping components; a delay keeps the Trigger.""" + assert _constructed(main_cpp, "looptrigger_id") == "LoopTrigger()" + _automation_of(main_cpp, "looptrigger_id") + assert _constructed(main_cpp, "looptrigger_id_2") == "LoopTrigger()" + assert _has_delay(main_cpp, "looptrigger_id_2") + + +def test_on_boot_registered_before_its_actions_are_built( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """The block keeps its place among components with the same priority while it waits.""" + main_cpp = generate_main(component_config_path("on_boot_waits_for_ids.yaml")) + + assert _registered(main_cpp)[-3:] == [ + "startuptrigger_id", + "interval_intervaltrigger_id", + "some_sensor", + ] diff --git a/tests/integration/fixtures/core_automation_order.yaml b/tests/integration/fixtures/core_automation_order.yaml new file mode 100644 index 00000000000..dffe7169665 --- /dev/null +++ b/tests/integration/fixtures/core_automation_order.yaml @@ -0,0 +1,32 @@ +esphome: + name: core-automation-order + # Listed out of priority order; they must run highest priority first + on_boot: + - priority: -100 + then: + - logger.log: "BOOT step -100" + - priority: 250.5 + then: + - logger.log: "BOOT step 250.5 before delay" + - delay: 10ms + - logger.log: "BOOT step after delay" + - then: + - logger.log: "BOOT step 600" + - priority: 700 + then: + - logger.log: "BOOT step 700" + on_loop: + - then: + - lambda: |- + if (++id(loop_runs) == 3) { + ESP_LOGI("test", "LOOP ran 3 times"); + } + +host: +api: +logger: + +globals: + - id: loop_runs + type: int + initial_value: "0" diff --git a/tests/integration/test_core_automation_order.py b/tests/integration/test_core_automation_order.py new file mode 100644 index 00000000000..f1fd0cb155c --- /dev/null +++ b/tests/integration/test_core_automation_order.py @@ -0,0 +1,46 @@ +"""Integration test that on_boot automations run in priority order and on_loop runs each loop.""" + +from __future__ import annotations + +import re + +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +STEP = re.compile(r"BOOT step (-?[\d.]+(?: before delay)?|after delay)") + + +@pytest.mark.asyncio +async def test_core_automation_order( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """on_boot blocks run once, highest priority first; a delay finishes after setup.""" + waiter = LineWaiter() + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected(), + ): + await waiter.wait_for_each("BOOT step after delay", "LOOP ran 3 times") + steps = [ + match.group(1) for line in waiter.lines if (match := STEP.search(line)) + ] + assert steps == [ + "700", + "600", + "250.5 before delay", + "-100", + "after delay", + ] + lines = waiter.lines + setup_done = next( + i for i, line in enumerate(lines) if "setup() finished" in line + ) + after_delay = next( + i for i, line in enumerate(lines) if "BOOT step after delay" in line + ) + assert setup_done < after_delay From b576063d154edc888b53c70739a242e4f07fc538 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:25:11 -0400 Subject: [PATCH 017/170] [ethernet] Bump ESP-IDF ethernet driver components (#21982) --- esphome/components/ethernet/__init__.py | 22 +++++++++---------- .../ethernet/ethernet_component_esp32.cpp | 7 ++++++ .../components/ethernet/w5500_custom_spi.cpp | 16 +++++++++++++- esphome/idf_component.yml | 18 +++++++-------- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 8ba6d75248c..c3f74ff0c79 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -175,17 +175,17 @@ class IDFRegistryComponent: # IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry. _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = { - "LAN8720": IDFRegistryComponent("espressif/lan87xx", "1.0.0"), - "RTL8201": IDFRegistryComponent("espressif/rtl8201", "1.0.1"), - "DP83848": IDFRegistryComponent("espressif/dp83848", "1.0.0"), - "IP101": IDFRegistryComponent("espressif/ip101", "1.0.0"), - "KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), - "KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.0.0"), - "W5500": IDFRegistryComponent("espressif/w5500", "1.0.1"), - "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.0"), - "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"), - "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"), - "CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"), + "LAN8720": IDFRegistryComponent("espressif/lan87xx", "1.1.0"), + "RTL8201": IDFRegistryComponent("espressif/rtl8201", "1.1.0"), + "DP83848": IDFRegistryComponent("espressif/dp83848", "1.1.0"), + "IP101": IDFRegistryComponent("espressif/ip101", "1.1.0"), + "KSZ8081": IDFRegistryComponent("espressif/ksz80xx", "1.1.0"), + "KSZ8081RNA": IDFRegistryComponent("espressif/ksz80xx", "1.1.0"), + "W5500": IDFRegistryComponent("espressif/w5500", "2.0.1"), + "DM9051": IDFRegistryComponent("espressif/dm9051", "1.1.1"), + "ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.1.0"), + "LAN8670": IDFRegistryComponent("espressif/lan867x", "2.1.0"), + "CH390": IDFRegistryComponent("espressif/ch390", "0.4.1"), "KSZ8851SNL": IDFRegistryComponent("espressif/ksz8851snl", "1.2.0"), } diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index fe3da7739a2..79f5cb52f8a 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -278,9 +278,16 @@ void EthernetComponent::ethernet_lazy_init_() { #endif #if defined(USE_ETHERNET_W5500) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + w5500_config.base.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + w5500_config.base.poll_period_ms = this->polling_interval_; +#endif +#else w5500_config.int_gpio_num = this->interrupt_pin_; #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT w5500_config.poll_period_ms = this->polling_interval_; +#endif #endif // Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait // path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which diff --git a/esphome/components/ethernet/w5500_custom_spi.cpp b/esphome/components/ethernet/w5500_custom_spi.cpp index 9c6b59582a3..5bd1469e4e5 100644 --- a/esphome/components/ethernet/w5500_custom_spi.cpp +++ b/esphome/components/ethernet/w5500_custom_spi.cpp @@ -32,10 +32,16 @@ void *w5500_custom_spi_init(const void *spi_config) { auto *ctx = &w5500_context; // The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control // byte in the address phase; mirror what the stock driver configures. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + spi_device_interface_config_t devcfg = *config->base.spi_devcfg; + const spi_host_device_t spi_host_id = config->base.spi_host_id; +#else spi_device_interface_config_t devcfg = *config->spi_devcfg; + const spi_host_device_t spi_host_id = config->spi_host_id; +#endif devcfg.command_bits = 16; devcfg.address_bits = 8; - if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { + if (spi_bus_add_device(spi_host_id, &devcfg, &ctx->handle) != ESP_OK) { ctx->handle = nullptr; return nullptr; } @@ -107,11 +113,19 @@ void install_w5500_async_spi(eth_w5500_config_t &config) { // Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and // spi_devcfg back out of it. The self-reference is valid because both the config and the // spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init(). +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) + config.base.custom_spi_driver.config = &config; + config.base.custom_spi_driver.init = w5500_custom_spi_init; + config.base.custom_spi_driver.deinit = w5500_custom_spi_deinit; + config.base.custom_spi_driver.read = w5500_custom_spi_read; + config.base.custom_spi_driver.write = w5500_custom_spi_write; +#else config.custom_spi_driver.config = &config; config.custom_spi_driver.init = w5500_custom_spi_init; config.custom_spi_driver.deinit = w5500_custom_spi_deinit; config.custom_spi_driver.read = w5500_custom_spi_read; config.custom_spi_driver.write = w5500_custom_spi_write; +#endif } } // namespace esphome::ethernet diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1b45b739a42..62da7c7cf71 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -52,7 +52,7 @@ dependencies: zorxx/multipart-parser: version: 1.0.1 espressif/lan867x: - version: "2.0.0" + version: "2.1.0" rules: - if: "target in [esp32, esp32p4]" espressif/esp-zigbee-lib: @@ -60,31 +60,31 @@ dependencies: rules: - if: "target in [esp32h2, esp32c5, esp32c6]" espressif/lan87xx: - version: "1.0.0" + version: "1.1.0" rules: - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" espressif/rtl8201: - version: "1.0.1" + version: "1.1.0" rules: - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" espressif/dp83848: - version: "1.0.0" + version: "1.1.0" rules: - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" espressif/ip101: - version: "1.0.0" + version: "1.1.0" rules: - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" espressif/ksz80xx: - version: "1.0.0" + version: "1.1.0" rules: - if: "idf_version >=6.0.0 && target in [esp32, esp32p4]" espressif/w5500: - version: "1.0.1" + version: "2.0.1" rules: - if: "idf_version >=6.0.0" espressif/dm9051: - version: "1.1.0" + version: "1.1.1" rules: - if: "idf_version >=6.0.0" espressif/ksz8851snl: @@ -93,7 +93,7 @@ dependencies: - if: "idf_version >=6.0.0" # Keep in sync with esphome/components/ethernet/__init__.py espressif/ch390: - version: "0.3.0" + version: "0.4.1" espressif/esp_tinyusb: version: "2.2.1" rules: From 5f5a81434df4d47c3b9fb8d3927f11791a5bcbf6 Mon Sep 17 00:00:00 2001 From: Mikkel Jeppesen <2756925+Duckle29@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:02:55 +0100 Subject: [PATCH 018/170] [panasonic_ir] Add Panasonic AC IR climate component (#21631) --- CODEOWNERS | 1 + esphome/components/panasonic_ir/__init__.py | 1 + esphome/components/panasonic_ir/climate.py | 74 ++ .../panasonic_ir/panasonic_ac_frame.h | 225 +++++ .../components/panasonic_ir/panasonic_ir.cpp | 289 +++++++ .../components/panasonic_ir/panasonic_ir.h | 68 ++ esphome/components/panasonic_ir/select.py | 35 + tests/components/panasonic_ir/__init__.py | 6 + tests/components/panasonic_ir/benchmark.yaml | 4 + tests/components/panasonic_ir/common.yaml | 13 + .../panasonic_ir/test.esp32-idf.yaml | 4 + .../panasonic_ir/test.esp8266-ard.yaml | 4 + .../panasonic_ir/test_panasonic_ir.cpp | 804 ++++++++++++++++++ 13 files changed, 1528 insertions(+) create mode 100644 esphome/components/panasonic_ir/__init__.py create mode 100644 esphome/components/panasonic_ir/climate.py create mode 100644 esphome/components/panasonic_ir/panasonic_ac_frame.h create mode 100644 esphome/components/panasonic_ir/panasonic_ir.cpp create mode 100644 esphome/components/panasonic_ir/panasonic_ir.h create mode 100644 esphome/components/panasonic_ir/select.py create mode 100644 tests/components/panasonic_ir/__init__.py create mode 100644 tests/components/panasonic_ir/benchmark.yaml create mode 100644 tests/components/panasonic_ir/common.yaml create mode 100644 tests/components/panasonic_ir/test.esp32-idf.yaml create mode 100644 tests/components/panasonic_ir/test.esp8266-ard.yaml create mode 100644 tests/components/panasonic_ir/test_panasonic_ir.cpp diff --git a/CODEOWNERS b/CODEOWNERS index 29d35a679f4..eea5352c74b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -481,6 +481,7 @@ esphome/components/output/* @esphome/core esphome/components/packet_interface/* @clydebarrow esphome/components/packet_interface/packet_transport/* @clydebarrow esphome/components/packet_transport/* @clydebarrow +esphome/components/panasonic_ir/* @duckle29 esphome/components/pca6416a/* @Mat931 esphome/components/pca9505/* @ajfriesen @ssieb esphome/components/pca9554/* @bdraco @clydebarrow @hwstar diff --git a/esphome/components/panasonic_ir/__init__.py b/esphome/components/panasonic_ir/__init__.py new file mode 100644 index 00000000000..651bb8c031b --- /dev/null +++ b/esphome/components/panasonic_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "panasonic_ir" diff --git a/esphome/components/panasonic_ir/climate.py b/esphome/components/panasonic_ir/climate.py new file mode 100644 index 00000000000..aae972fb8f1 --- /dev/null +++ b/esphome/components/panasonic_ir/climate.py @@ -0,0 +1,74 @@ +import logging + +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.components.remote_receiver import CONF_RECEIVE_SYMBOLS +import esphome.config_validation as cv +from esphome.const import CONF_MODEL, CONF_RMT_SYMBOLS, CONF_USE_DMA +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@duckle29"] + + +panasonic_ir_ns = cg.esphome_ns.namespace("panasonic_ir") +PanasonicIrClimate = panasonic_ir_ns.class_("PanasonicIrClimate", climate_ir.ClimateIR) +Model = panasonic_ir_ns.enum("Model", is_class=True) + +MODELS = Model.options("DKE", "JKE", "LKE", "NKE", "CKP", "RKR", "PKE") + +# A frame is 440 pulses, so the receiver needs room for 220 symbols (a mark and a space each) +FRAME_SYMBOLS = 220 +RECOMMENDED_RMT_SYMBOLS = 256 + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(PanasonicIrClimate).extend( + { + cv.Optional(CONF_MODEL, default="JKE"): cv.enum(MODELS, upper=True), + } +) + + +def _final_validate(config: ConfigType) -> None: + """Warn if the receiver is too small to capture a whole frame. + + The ESP32 receiver stops capturing when its RMT memory is full, and a frame cut short is silently ignored. + This is a warning and not an error, as not every chip can allocate that much RMT memory. + """ + receiver_id = config.get(remote_base.CONF_RECEIVER_ID) + if receiver_id is None: + return + + receiver_config = fv.get_declaring_config(receiver_id) + + # Only the RMT receivers on the ESP32 limit the capture: the receive buffer always, and the hardware memory + # unless DMA streams the symbols out of it + limits = [CONF_RECEIVE_SYMBOLS] + if not receiver_config.get(CONF_USE_DMA): + limits.append(CONF_RMT_SYMBOLS) + if too_small := [ + f"{key}={symbols}" + for key in limits + if (symbols := receiver_config.get(key)) is not None and symbols < FRAME_SYMBOLS + ]: + _LOGGER.warning( + "The receiver '%s' has %s, which is too small to capture a whole Panasonic frame (%d symbols). " + "Frames from the remote will be cut short and ignored. " + "Set %s and %s to %d on the receiver, if the chip allows it", + receiver_id.id, + " and ".join(too_small), + FRAME_SYMBOLS, + CONF_RMT_SYMBOLS, + CONF_RECEIVE_SYMBOLS, + RECOMMENDED_RMT_SYMBOLS, + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/panasonic_ir/panasonic_ac_frame.h b/esphome/components/panasonic_ir/panasonic_ac_frame.h new file mode 100644 index 00000000000..299e536c871 --- /dev/null +++ b/esphome/components/panasonic_ir/panasonic_ac_frame.h @@ -0,0 +1,225 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::panasonic_ir { + +/* +Panasonic A/C state: 27 bytes, sent LSB first as two sections (8 bytes + 19 bytes). + +Based on IRremoteESP8266 by Kristian Lauszus and David Conran (LGPL-2.1): +https://github.com/crankyoldgit/IRremoteESP8266/blob/master/src/ir_Panasonic.cpp +Cross-checked against the reverse engineering notes by Mathieu: +https://github.com/r45635/HVAC-IR-Control/blob/master/Protocol/Panasonic%20HVAC%20IR%20Protocol%20specification.pdf +https://www.instructables.com/Reverse-engineering-of-an-Air-Conditioning-control/ +and captures from the remote of a CS-NE9PKE. + +B0-B12: static 0x0220E004000000060220E00400 +B13: b0 power (CKP: toggle), b4-6 mode, other bits per model +B14: b1-5 temperature +B16: b0-3 vertical vane (1 highest to 5 lowest, 15 auto), b4-7 fan speed + 3 +B17, B21, B23, B25: per model +B26: checksum, sum of B0-B25 plus 0xF4 (the first section sums to 0x10C, so it is the sum of B8-B25) +Stateless buttons (quiet/powerful, nanoe-G) send an 8 byte second section, which is ignored. +*/ + +static constexpr size_t STATE_LENGTH = 27; +static constexpr size_t SECTION1_LENGTH = 8; +static constexpr size_t SECTION2_LENGTH = STATE_LENGTH - SECTION1_LENGTH; +static constexpr uint8_t CHECKSUM_INIT = 0xF4; + +// PKE is not an IRremoteESP8266 model: it is JKE with B13 b3 set, as sent by the remote of a CS-NE9PKE (CWA75C4410). +enum class Model : uint8_t { DKE, JKE, LKE, NKE, CKP, RKR, PKE }; + +enum class Mode : uint8_t { AUTO = 0, DRY = 2, COOL = 3, HEAT = 4, FAN = 6 }; + +// Raw fan speeds, as used by IRremoteESP8266. 0 = minimum, 4 = maximum. +static constexpr uint8_t FAN_SPEED_MIN = 0; +static constexpr uint8_t FAN_SPEED_LOW = 1; +static constexpr uint8_t FAN_SPEED_MEDIUM = 2; +static constexpr uint8_t FAN_SPEED_HIGH = 3; +static constexpr uint8_t FAN_SPEED_MAX = 4; +static constexpr uint8_t FAN_SPEED_AUTO = 7; +static constexpr uint8_t FAN_SPEED_OFFSET = 3; + +static constexpr uint8_t VANE_V_HIGHEST = 0x1; +static constexpr uint8_t VANE_V_MIDDLE = 0x3; +static constexpr uint8_t VANE_V_LOWEST = 0x5; +static constexpr uint8_t VANE_V_AUTO = 0xF; + +// The remote's frost protection ("+8/10 C Heat") is a mode of its own that only offers 8 C and 10 C. Both are +// understood when receiving, 10 C is what we send. +static constexpr uint8_t TEMP_MAINTENANCE = 10; +static constexpr uint8_t TEMP_MIN_RECEIVE = 8; +static constexpr uint8_t TEMP_MIN = 16; // Celsius +static constexpr uint8_t TEMP_MAX = 30; // Celsius +static constexpr uint8_t TEMP_FAN_MODE = 27; // Celsius. The remote always sends this in fan mode + +/// Per model bytes. B17 is the horizontal vane on DKE and RKR, always sent in the middle (0x06). +struct ModelBytes { + uint8_t b13_or; + uint8_t b17; + uint8_t b21_or; + uint8_t b23; + uint8_t b25; +}; + +// In the order of the Model enum +static constexpr ModelBytes MODEL_BYTES[] PROGMEM = { + {0x00, 0x06, 0x00, 0x01, 0x06}, // DKE + {0x00, 0x00, 0x00, 0x81, 0x00}, // JKE + {0x02, 0x06, 0x00, 0x81, 0x00}, // LKE + {0x00, 0x06, 0x00, 0x81, 0x00}, // NKE + {0x00, 0x00, 0x10, 0x01, 0x00}, // CKP + {0x08, 0x06, 0x00, 0x89, 0x00}, // RKR + {0x08, 0x00, 0x00, 0x81, 0x00}, // PKE +}; + +struct FrameParams { + Model model{Model::JKE}; + bool power{false}; // For CKP this means "toggle power" + Mode mode{Mode::AUTO}; + uint8_t temperature{25}; + uint8_t fan_speed{FAN_SPEED_AUTO}; + uint8_t vane_v{VANE_V_AUTO}; +}; + +/// The part of the state that doesn't depend on the settings. Built in code, so it doesn't take up RAM on an ESP8266 +inline void fill_template(uint8_t *state) { + memset(state, 0, STATE_LENGTH); + state[0] = 0x02; + state[1] = 0x20; + state[2] = 0xE0; + state[3] = 0x04; + state[7] = 0x06; + state[8] = 0x02; + state[9] = 0x20; + state[10] = 0xE0; + state[11] = 0x04; + state[15] = 0x80; + state[19] = 0x0E; + state[20] = 0xE0; +} + +inline uint8_t calc_checksum(const uint8_t *state, size_t len) { return sum_bytes(state, len - 1, CHECKSUM_INIT); } + +/// Encodes `p` as is: the temperature is not clamped, except that fan only mode always sends 27 C. +inline void encode_frame(const FrameParams &p, uint8_t *state) { + fill_template(state); + ModelBytes model; + progmem_memcpy(&model, &MODEL_BYTES[static_cast(p.model)], sizeof(model)); + state[13] = model.b13_or | (p.power ? 0x01 : 0x00) | ((static_cast(p.mode) & 0x07) << 4); + const uint8_t temp = p.mode == Mode::FAN ? TEMP_FAN_MODE : p.temperature; + state[14] = (temp & 0x1F) << 1; + state[16] = (p.vane_v & 0x0F) | (((p.fan_speed + FAN_SPEED_OFFSET) & 0x0F) << 4); + state[17] = model.b17; + state[21] = model.b21_or; + state[23] = model.b23; + state[25] = model.b25; + state[STATE_LENGTH - 1] = calc_checksum(state, STATE_LENGTH); +} + +/// Decode a state. Returns false if the state doesn't look like a valid Panasonic A/C state. +inline bool decode_frame(const uint8_t *state, FrameParams &p) { + // The first 13 bytes are static + uint8_t expected[STATE_LENGTH]; + fill_template(expected); + if (memcmp(state, expected, 13) != 0) + return false; + if (state[STATE_LENGTH - 1] != calc_checksum(state, STATE_LENGTH)) + return false; + + const uint8_t mode = (state[13] >> 4) & 0x07; + switch (static_cast(mode)) { + case Mode::AUTO: + case Mode::DRY: + case Mode::COOL: + case Mode::HEAT: + case Mode::FAN: + break; + default: + return false; + } + + const uint8_t temp = (state[14] >> 1) & 0x1F; + if (temp < TEMP_MIN_RECEIVE || temp > TEMP_MAX) + return false; + + const uint8_t fan = static_cast((state[16] >> 4) - FAN_SPEED_OFFSET); // Wraps below the offset + if (fan > FAN_SPEED_MAX && fan != FAN_SPEED_AUTO) + return false; + + p.power = state[13] & 0x01; + p.mode = static_cast(mode); + p.temperature = temp; + p.fan_speed = fan; + p.vane_v = state[16] & 0x0F; + return true; +} + +// IR timings [us] +static constexpr uint32_t CARRIER_FREQUENCY = 36700; +static constexpr uint32_t HEADER_MARK = 3456; +static constexpr uint32_t HEADER_SPACE = 1728; +static constexpr uint32_t BIT_MARK = 432; +static constexpr uint32_t BIT_ONE_SPACE = 1296; +static constexpr uint32_t BIT_ZERO_SPACE = 432; +static constexpr uint32_t SECTION_GAP = 10000; +static constexpr remote_base::BitTimings BIT_TIMINGS{ + .bit_mark = BIT_MARK, .one_space = BIT_ONE_SPACE, .zero_space = BIT_ZERO_SPACE}; + +/// A footer mark, followed by the end of the capture or a long space (idle time or section gap), never a bit. +inline bool peek_footer(const remote_base::RemoteReceiveData &data) { + return data.peek_mark(BIT_MARK) && (!data.is_valid(1) || data.peek_space_at_least(3 * BIT_ONE_SPACE, 1)); +} + +/// Reads header + bytes + footer mark of a section of either length. Returns the number of bytes read, or 0. +inline size_t receive_section(remote_base::RemoteReceiveData &data, uint8_t *bytes) { + if (!data.expect_item(HEADER_MARK, HEADER_SPACE) || !data.expect_bytes(bytes, SECTION1_LENGTH)) + return 0; + size_t len = SECTION1_LENGTH; + if (!peek_footer(data)) { + if (!data.expect_bytes(bytes + SECTION1_LENGTH, SECTION2_LENGTH - SECTION1_LENGTH) || + !peek_footer(data)) + return 0; + len = SECTION2_LENGTH; + } + data.advance(); + return len; +} + +/// Reads a state from a received capture. Returns true if `state` holds a complete state (not yet validated). +/// +/// The 10 ms gap between the sections is as long as the default idle time, so a capture holds the whole frame or +/// one section. The first section is constant and outside the checksum, so a lone second section is enough and a +/// lone first section is ignored. +inline bool read_state(remote_base::RemoteReceiveData &data, uint8_t *state) { + uint8_t section[SECTION2_LENGTH]; + size_t len = receive_section(data, section); + + uint8_t expected[STATE_LENGTH]; + fill_template(expected); + + if (len == SECTION1_LENGTH) { + if (memcmp(section, expected, SECTION1_LENGTH) != 0) + return false; + // The gap and the second section follow in a full capture. Anything else is just the receiver's idle space + if (!data.is_valid(1) || !data.expect_space(SECTION_GAP)) + return false; + len = receive_section(data, section); + } + if (len != SECTION2_LENGTH) + return false; + + memcpy(state, expected, SECTION1_LENGTH); + memcpy(state + SECTION1_LENGTH, section, SECTION2_LENGTH); + return true; +} + +} // namespace esphome::panasonic_ir diff --git a/esphome/components/panasonic_ir/panasonic_ir.cpp b/esphome/components/panasonic_ir/panasonic_ir.cpp new file mode 100644 index 00000000000..cd0b58a710a --- /dev/null +++ b/esphome/components/panasonic_ir/panasonic_ir.cpp @@ -0,0 +1,289 @@ +#include "panasonic_ir.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +namespace esphome::panasonic_ir { + +ESPHOME_LOG_TAG(TAG, "panasonic_ir.climate"); + +// How long after sending a frame an identical one is taken for its echo +static constexpr uint32_t ECHO_WINDOW_MS = 1000; + +// In the order of the Model enum +PROGMEM_STRING_TABLE(ModelStrings, "DKE", "JKE", "LKE", "NKE", "CKP", "RKR", "PKE"); + +void PanasonicIrClimate::setup() { + climate_ir::ClimateIR::setup(); + // The restored mode is the best guess of whether the unit is on + this->ckp_is_on_ = this->mode != climate::CLIMATE_MODE_OFF; + this->sync_vane_select_(); +} + +void PanasonicIrClimate::dump_config() { + climate_ir::ClimateIR::dump_config(); + ESP_LOGCONFIG(TAG, " Model: %s", + LOG_STR_ARG(ModelStrings::get_log_str(static_cast(this->model_), ModelStrings::LAST_INDEX))); +} + +void PanasonicIrClimate::set_vane_position(size_t index) { + if (index > VANE_V_LOWEST) { + ESP_LOGW(TAG, "Vane position %u out of range", static_cast(index)); + return; + } + // A vane position takes the unit out of frost protection, as a swing change does + this->preset = climate::CLIMATE_PRESET_NONE; + // The select only offers 0 to 5 + if (index == 0) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + this->vane_position_ = index; + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + this->transmit_state(); + this->publish_state(); +} + +uint8_t PanasonicIrClimate::vane_v_() const { + if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL) + return VANE_V_AUTO; + if (this->vane_position_ != 0) + return this->vane_position_; + // Blow down to heat from the bottom of the room, and up to cool from the top + return this->mode == climate::CLIMATE_MODE_HEAT ? VANE_V_LOWEST : VANE_V_HIGHEST; +} + +void PanasonicIrClimate::sync_vane_select_() { +#ifdef USE_SELECT + if (this->vane_select_ == nullptr) + return; + const uint8_t vane = this->vane_v_(); + const size_t index = vane == VANE_V_AUTO ? 0 : vane; + if (this->vane_select_->active_index().value_or(SIZE_MAX) != index) + this->vane_select_->publish_state(index); +#endif +} + +void PanasonicIrClimate::control(const climate::ClimateCall &call) { + // The base class only knows about the regular fan modes + if (call.has_custom_fan_mode()) { + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + } else if (call.get_fan_mode().has_value()) { + this->clear_custom_fan_mode_(); + } + + // Frost protection is a mode of its own on the remote. Changing anything else takes the unit out of it + const bool in_maintenance = this->preset.value_or(climate::CLIMATE_PRESET_NONE) == climate::CLIMATE_PRESET_AWAY; + if (call.get_preset().has_value()) { + // Choosing frost protection turns the unit on. transmit_state() sets the rest + if (*call.get_preset() == climate::CLIMATE_PRESET_AWAY) + this->mode = climate::CLIMATE_MODE_HEAT; + } else if (in_maintenance && + (call.get_mode().has_value() || call.get_target_temperature().has_value() || + call.get_fan_mode().has_value() || call.has_custom_fan_mode() || call.get_swing_mode().has_value())) { + this->preset = climate::CLIMATE_PRESET_NONE; + } + climate_ir::ClimateIR::control(call); +} + +uint8_t PanasonicIrClimate::fan_speed_() const { + if (this->has_custom_fan_mode()) { + const auto custom_fan_mode = this->get_custom_fan_mode(); + if (custom_fan_mode == FAN_MODE_MINIMUM) + return FAN_SPEED_MIN; + if (custom_fan_mode == FAN_MODE_MAXIMUM) + return FAN_SPEED_MAX; + } + switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { + case climate::CLIMATE_FAN_LOW: + return FAN_SPEED_LOW; + case climate::CLIMATE_FAN_MEDIUM: + return FAN_SPEED_MEDIUM; + case climate::CLIMATE_FAN_HIGH: + return FAN_SPEED_HIGH; + default: + return FAN_SPEED_AUTO; + } +} + +void PanasonicIrClimate::transmit_state() { + // Frost protection is sent as the remote does, whatever else a call combined with the preset set, and the entity + // shows what is sent + const bool maintenance = this->mode != climate::CLIMATE_MODE_OFF && + this->preset.value_or(climate::CLIMATE_PRESET_NONE) == climate::CLIMATE_PRESET_AWAY; + if (maintenance) { + this->mode = climate::CLIMATE_MODE_HEAT; + this->set_custom_fan_mode_(FAN_MODE_MAXIMUM); + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } + + FrameParams params; + params.model = this->model_; + + bool power = true; + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + params.mode = Mode::AUTO; + break; + case climate::CLIMATE_MODE_DRY: + params.mode = Mode::DRY; + break; + case climate::CLIMATE_MODE_COOL: + params.mode = Mode::COOL; + break; + case climate::CLIMATE_MODE_HEAT: + params.mode = Mode::HEAT; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + params.mode = Mode::FAN; + break; + case climate::CLIMATE_MODE_OFF: + default: + power = false; + params.mode = this->last_mode_; + break; + } + if (power) + this->last_mode_ = params.mode; + + if (this->model_ == Model::CKP) { + // No declarative power on CKP. The power bit toggles the unit, so only set it when the state changes + params.power = (power != this->ckp_is_on_); + this->ckp_is_on_ = power; + } else { + params.power = power; + } + + params.temperature = maintenance + ? TEMP_MAINTENANCE + : static_cast(lroundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX))); + params.fan_speed = this->fan_speed_(); + params.vane_v = this->vane_v_(); + + uint8_t state[STATE_LENGTH]; + encode_frame(params, state); + memcpy(this->echo_, state + SECTION1_LENGTH, SECTION2_LENGTH); + this->echo_sent_ms_ = millis(); + + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(CARRIER_FREQUENCY); + // Two headers (mark + space) and footer marks, 16 timings per byte, and the gap + data->reserve(2 * 3 + STATE_LENGTH * 16 + 1); + + data->item(HEADER_MARK, HEADER_SPACE); + data->item_bytes(state, SECTION1_LENGTH); + data->item(BIT_MARK, SECTION_GAP); + data->item(HEADER_MARK, HEADER_SPACE); + data->item_bytes(state + SECTION1_LENGTH, SECTION2_LENGTH); + data->mark(BIT_MARK); + + transmit.perform(); + this->sync_vane_select_(); +} + +bool PanasonicIrClimate::on_receive(remote_base::RemoteReceiveData data) { + uint8_t state[STATE_LENGTH]; + if (!read_state(data, state)) + return false; + + FrameParams params; + params.model = this->model_; + if (!decode_frame(state, params)) { + ESP_LOGD(TAG, "Received frame is not a valid Panasonic state"); + return false; + } + + // A receiver near the transmitter hears our own frame. Applying it would flip the CKP power toggle back and store + // the vane position derived from the mode as one the user chose. + if (millis() - this->echo_sent_ms_ < ECHO_WINDOW_MS && + memcmp(state + SECTION1_LENGTH, this->echo_, SECTION2_LENGTH) == 0) { + ESP_LOGD(TAG, "Ignoring the echo of our own frame"); + this->echo_sent_ms_ -= ECHO_WINDOW_MS; // only the first echo + return true; + } + + this->apply_received_(params); + return true; +} + +void PanasonicIrClimate::apply_received_(const FrameParams ¶ms) { + bool power = params.power; + if (this->model_ == Model::CKP) { + // The power bit is only a toggle + if (params.power) + this->ckp_is_on_ = !this->ckp_is_on_; + power = this->ckp_is_on_; + } + + this->last_mode_ = params.mode; + if (!power) { + this->mode = climate::CLIMATE_MODE_OFF; + } else { + switch (params.mode) { + case Mode::DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case Mode::COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case Mode::HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case Mode::FAN: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + case Mode::AUTO: + default: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + } + } + + // The frost protection frame holds 8 C or 10 C, which is not a set-point of the normal range. Keep the one we had + const bool maintenance = params.temperature < TEMP_MIN; + this->preset = maintenance ? climate::CLIMATE_PRESET_AWAY : climate::CLIMATE_PRESET_NONE; + // Fan only mode always sends a fixed temperature, so it says nothing about the set-point either + if (params.mode != Mode::FAN && !maintenance) + this->target_temperature = params.temperature; + + switch (params.fan_speed) { + case FAN_SPEED_MIN: + this->set_custom_fan_mode_(FAN_MODE_MINIMUM); + break; + case FAN_SPEED_LOW: + this->set_fan_mode_(climate::CLIMATE_FAN_LOW); + break; + case FAN_SPEED_MEDIUM: + this->set_fan_mode_(climate::CLIMATE_FAN_MEDIUM); + break; + case FAN_SPEED_HIGH: + this->set_fan_mode_(climate::CLIMATE_FAN_HIGH); + break; + case FAN_SPEED_MAX: + this->set_custom_fan_mode_(FAN_MODE_MAXIMUM); + break; + case FAN_SPEED_AUTO: + default: + this->set_fan_mode_(climate::CLIMATE_FAN_AUTO); + break; + } + + // Any other vane value is not one the remote sends, so it leaves the vane as it was + if (params.vane_v == VANE_V_AUTO) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else if (params.vane_v >= VANE_V_HIGHEST && params.vane_v <= VANE_V_LOWEST) { + this->swing_mode = climate::CLIMATE_SWING_OFF; + this->vane_position_ = params.vane_v; + } else { + ESP_LOGV(TAG, "Ignoring vane value %u", params.vane_v); + } + this->sync_vane_select_(); + + this->publish_state(); +} + +} // namespace esphome::panasonic_ir diff --git a/esphome/components/panasonic_ir/panasonic_ir.h b/esphome/components/panasonic_ir/panasonic_ir.h new file mode 100644 index 00000000000..4f67502ad19 --- /dev/null +++ b/esphome/components/panasonic_ir/panasonic_ir.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +#include "esphome/components/climate_ir/climate_ir.h" +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#include "panasonic_ac_frame.h" + +namespace esphome::panasonic_ir { + +static constexpr const char *FAN_MODE_MINIMUM = "Minimum"; +static constexpr const char *FAN_MODE_MAXIMUM = "Maximum"; +static constexpr const char *const CUSTOM_FAN_MODES[] PROGMEM = {FAN_MODE_MINIMUM, FAN_MODE_MAXIMUM}; + +class PanasonicIrClimate final : public climate_ir::ClimateIR { + public: + PanasonicIrClimate() + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, + 1.0f, // Temp step + true, // Supports dry + true, // Supports fan only + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}, + {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_AWAY}) { + this->set_supported_custom_fan_modes_static(CUSTOM_FAN_MODES, std::size(CUSTOM_FAN_MODES)); + } + + void set_model(Model model) { this->model_ = model; } + void setup() override; + void dump_config() override; + + /// Set the vertical vane from the select: 0 is auto (swing vertical), 1 (highest) to 5 (lowest) are fixed positions. + void set_vane_position(size_t index); +#ifdef USE_SELECT + SUB_SELECT(vane) +#endif + + protected: + void control(const climate::ClimateCall &call) override; + + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Handle received IR buffer + bool on_receive(remote_base::RemoteReceiveData data) override; + + uint8_t fan_speed_() const; + uint8_t vane_v_() const; + void sync_vane_select_(); + void apply_received_(const FrameParams ¶ms); + + Model model_{Model::JKE}; + // The remote has no concept of "off" mode, so remember what to send along with the power bit + Mode last_mode_{Mode::AUTO}; + // CKP models only have a power toggle, so we need to keep track of the state + bool ckp_is_on_{false}; + // The time and second section of the last frame sent, so its echo is not taken for the remote. The time starts + // outside the echo window. + uint32_t echo_sent_ms_{UINT32_MAX / 2}; + uint8_t echo_[SECTION2_LENGTH]{}; + // The fixed vane position (1 to 5) used while swing is off. 0 until one is chosen, then heat blows down and the + // other modes up + uint8_t vane_position_{0}; +}; + +} // namespace esphome::panasonic_ir diff --git a/esphome/components/panasonic_ir/select.py b/esphome/components/panasonic_ir/select.py new file mode 100644 index 00000000000..1fecfef42df --- /dev/null +++ b/esphome/components/panasonic_ir/select.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ICON_ARROW_EXPAND_VERTICAL +from esphome.types import ConfigType + +from .climate import PanasonicIrClimate + +DEPENDENCIES = ["climate.panasonic_ir"] + +CONF_PANASONIC_IR_ID = "panasonic_ir_id" +CONF_VANE_POSITION = "vane_position" + +# Option 0 is auto, the others are the fixed positions of the vertical vane from the highest to the lowest +VANE_POSITIONS = ["Auto", "1", "2", "3", "4", "5"] + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_PANASONIC_IR_ID): cv.use_id(PanasonicIrClimate), + cv.Required(CONF_VANE_POSITION): select.select_schema( + select.ApplySelect, icon=ICON_ARROW_EXPAND_VERTICAL + ), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await cg.get_variable(config[CONF_PANASONIC_IR_ID]) + selects = select.sub_selects(config, parent=var) + await selects( + CONF_VANE_POSITION, + var.set_vane_select, + options=VANE_POSITIONS, + apply="set_vane_position(index)", + ) diff --git a/tests/components/panasonic_ir/__init__.py b/tests/components/panasonic_ir/__init__.py new file mode 100644 index 00000000000..a806846bb62 --- /dev/null +++ b/tests/components/panasonic_ir/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks the component manifest, not the climate platform, so declare its auto-load here + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/panasonic_ir/benchmark.yaml b/tests/components/panasonic_ir/benchmark.yaml new file mode 100644 index 00000000000..bb9d878b5b4 --- /dev/null +++ b/tests/components/panasonic_ir/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The panasonic_ir, climate_ir and remote_transmitter sources come in +# through the dependency walk in __init__.py; their code generation is suppressed. +climate: [] diff --git a/tests/components/panasonic_ir/common.yaml b/tests/components/panasonic_ir/common.yaml new file mode 100644 index 00000000000..f49825ed498 --- /dev/null +++ b/tests/components/panasonic_ir/common.yaml @@ -0,0 +1,13 @@ +climate: + - platform: panasonic_ir + id: panasonic_climate + name: Panasonic Climate + transmitter_id: xmitr + receiver_id: rcvr + model: PKE + +select: + - platform: panasonic_ir + panasonic_ir_id: panasonic_climate + vane_position: + name: Panasonic Vane Position diff --git a/tests/components/panasonic_ir/test.esp32-idf.yaml b/tests/components/panasonic_ir/test.esp32-idf.yaml new file mode 100644 index 00000000000..eff6d30dafb --- /dev/null +++ b/tests/components/panasonic_ir/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml + panasonic_ir: !include common.yaml diff --git a/tests/components/panasonic_ir/test.esp8266-ard.yaml b/tests/components/panasonic_ir/test.esp8266-ard.yaml new file mode 100644 index 00000000000..f52cc46422a --- /dev/null +++ b/tests/components/panasonic_ir/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + panasonic_ir: !include common.yaml diff --git a/tests/components/panasonic_ir/test_panasonic_ir.cpp b/tests/components/panasonic_ir/test_panasonic_ir.cpp new file mode 100644 index 00000000000..b55b96ea5e0 --- /dev/null +++ b/tests/components/panasonic_ir/test_panasonic_ir.cpp @@ -0,0 +1,804 @@ +#include +#include +#include +#include +#include +#include + +#include "esphome/components/panasonic_ir/panasonic_ir.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +namespace esphome::panasonic_ir::testing { + +using Frame = std::vector; +using remote_base::RawTimings; + +static constexpr uint32_t TOLERANCE_PERCENT = 25; + +// ---- Real frames -------------------------------------------------------------------------------------------------- + +// From the IRremoteESP8266 unit tests +static const uint8_t JKE_COOL_25_OFF[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x30, 0x32, 0x80, 0xAF, 0x00, + 0x00, 0x06, 0x60, 0x00, 0x00, 0x80, 0x00, 0x06, 0x83}; +static const uint8_t CKP_POWERFUL[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x4E, 0x2E, 0x80, 0xAF, 0x00, + 0x00, 0x0E, 0xE0, 0x11, 0x00, 0x01, 0x00, 0x06, 0xB7}; +static const uint8_t CKP_QUIET[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, 0x20, 0xE0, 0x04, 0x00, 0x4E, + 0x2E, 0x80, 0xAF, 0x00, 0x00, 0x0E, 0xE0, 0x30, 0x00, 0x01, 0x00, 0x06, 0xD6}; +static const uint8_t DKE_HEAT_25[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x4F, 0x32, 0x80, 0xAF, 0x0D, + 0x00, 0x06, 0x60, 0x00, 0x00, 0x01, 0x00, 0x06, 0x30}; +// From the "Panasonic HVAC IR Protocol" notes by Mathieu (a CKP style remote) +static const uint8_t NOTES_CKP_HEAT_30[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x48, 0x3C, 0x80, 0xAF, 0x00, + 0x00, 0x0E, 0xE0, 0x10, 0x00, 0x01, 0x00, 0x06, 0xBE}; +// From the Instructables article by the same author (a JKE style remote) +static const uint8_t ARTICLE_AUTO_25[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x01, 0x32, 0x80, 0xAF, 0x00, + 0x00, 0x06, 0x60, 0x00, 0x00, 0x80, 0x00, 0x06, 0x54}; +// From the remote of a CS-NE9PKE. All heat, powered on, swing auto +static const uint8_t PKE_8C_FAN_MAX[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x49, 0x10, 0x80, 0x7F, 0x00, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x00, 0xCD}; +// Frost protection at 10 C. The remote only offers 8 C and 10 C in this mode +static const uint8_t PKE_10C_FAN_MAX[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x49, 0x14, 0x80, 0x7F, 0x00, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x00, 0xD1}; +// Cool at 24 C, fan auto, vane on fixed position 3 of 5 (1 is the highest) +static const uint8_t PKE_COOL_24C_VANE_3[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x39, 0x30, 0x80, 0xA3, 0x00, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x00, 0x01}; +static const uint8_t PKE_16C_FAN_MAX[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x49, 0x20, 0x80, 0x7F, 0x00, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x00, 0xDD}; +static const uint8_t PKE_30C_FAN_MAX[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x49, 0x3C, 0x80, 0x7F, 0x00, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x00, 0xF9}; +static const uint8_t PKE_19C_AUTO[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x49, 0x26, 0x80, 0xAF, 0x00, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x00, 0x13}; +static const uint8_t PKE_20C_AUTO[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, 0x02, + 0x20, 0xE0, 0x04, 0x00, 0x49, 0x28, 0x80, 0xAF, 0x00, + 0x00, 0x0E, 0xE0, 0x00, 0x00, 0x81, 0x00, 0x00, 0x15}; +// The powerful/quiet toggle button of the remote of a CS-NE9PKE: the second section is only 8 bytes +static const uint8_t PKE_TOGGLE[] = {0x02, 0x20, 0xE0, 0x04, 0x00, 0x00, 0x00, 0x06, + 0x02, 0x20, 0xE0, 0x04, 0x80, 0x9C, 0x32, 0x54}; + +static Frame frame(const uint8_t (&bytes)[STATE_LENGTH]) { return Frame(bytes, bytes + STATE_LENGTH); } + +// ---- Building captures -------------------------------------------------------------------------------------------- + +static void add_section(RawTimings &t, const uint8_t *bytes, size_t len) { + t.push_back(HEADER_MARK); + t.push_back(-static_cast(HEADER_SPACE)); + for (size_t i = 0; i < len; i++) { + for (int bit = 0; bit < 8; bit++) { + t.push_back(BIT_MARK); + t.push_back(-static_cast((bytes[i] >> bit) & 1 ? BIT_ONE_SPACE : BIT_ZERO_SPACE)); + } + } + t.push_back(BIT_MARK); +} + +enum class Sections : uint8_t { FIRST, SECOND, BOTH }; + +/// What a receiver captures. `idle` is the trailing space the receiver appends, as long as its idle time +static RawTimings capture(const uint8_t *state, Sections sections = Sections::BOTH, int idle = 0) { + RawTimings t; + if (sections != Sections::SECOND) + add_section(t, state, SECTION1_LENGTH); + if (sections == Sections::BOTH) + t.push_back(-static_cast(SECTION_GAP)); + if (sections != Sections::FIRST) + add_section(t, state + SECTION1_LENGTH, SECTION2_LENGTH); + if (idle != 0) + t.push_back(-idle); + return t; +} + +static bool read(const RawTimings &raw, uint8_t *state) { + remote_base::RemoteReceiveData data(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); + return read_state(data, state); +} + +// ---- Frame encoding and decoding ---------------------------------------------------------------------------------- + +TEST(PanasonicFrame, ChecksumMatchesEveryRealFrame) { + for (const auto *f : {JKE_COOL_25_OFF, CKP_POWERFUL, CKP_QUIET, DKE_HEAT_25, NOTES_CKP_HEAT_30, ARTICLE_AUTO_25, + PKE_8C_FAN_MAX, PKE_16C_FAN_MAX, PKE_30C_FAN_MAX, PKE_19C_AUTO, PKE_20C_AUTO}) { + EXPECT_EQ(calc_checksum(f, STATE_LENGTH), f[STATE_LENGTH - 1]); + } +} + +TEST(PanasonicFrame, ChecksumSeedCancelsTheFirstSection) { + // The first section is constant, and sums to 0x10C. So the 0xF4 seed makes the checksum a plain sum of the second one + uint8_t tmpl[STATE_LENGTH]; + fill_template(tmpl); + EXPECT_EQ(sum_bytes(tmpl, SECTION1_LENGTH, CHECKSUM_INIT), 0); + EXPECT_EQ(sum_bytes(NOTES_CKP_HEAT_30 + SECTION1_LENGTH, SECTION2_LENGTH - 1), NOTES_CKP_HEAT_30[STATE_LENGTH - 1]); +} + +TEST(PanasonicFrame, EncodesTheAuthorsOwnFramesExactly) { + struct Case { + const uint8_t (&expected)[STATE_LENGTH]; + uint8_t temperature; + uint8_t fan; + }; + const Case cases[] = {{PKE_16C_FAN_MAX, 16, FAN_SPEED_MAX}, + {PKE_30C_FAN_MAX, 30, FAN_SPEED_MAX}, + {PKE_19C_AUTO, 19, FAN_SPEED_AUTO}, + {PKE_20C_AUTO, 20, FAN_SPEED_AUTO}}; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.temperature)); + FrameParams params; + params.model = Model::PKE; + params.power = true; + params.mode = Mode::HEAT; + params.temperature = c.temperature; + params.fan_speed = c.fan; + uint8_t state[STATE_LENGTH]; + encode_frame(params, state); + EXPECT_EQ(frame(state), frame(c.expected)); + } +} + +TEST(PanasonicFrame, EncodesFrostProtectionAsTheRemoteDoes) { + FrameParams params; + params.model = Model::PKE; + params.power = true; + params.mode = Mode::HEAT; + params.temperature = TEMP_MAINTENANCE; + params.fan_speed = FAN_SPEED_MAX; + uint8_t state[STATE_LENGTH]; + encode_frame(params, state); + EXPECT_EQ(frame(state), frame(PKE_10C_FAN_MAX)); + + // Switching the unit off keeps the power bit clear + params.power = false; + encode_frame(params, state); + EXPECT_EQ(state[13] & 0x01, 0); +} + +TEST(PanasonicFrame, PkeIsJkeWithBit3SetInByte13) { + FrameParams params; + params.power = true; + params.mode = Mode::HEAT; + uint8_t jke[STATE_LENGTH], pke[STATE_LENGTH]; + params.model = Model::JKE; + encode_frame(params, jke); + params.model = Model::PKE; + encode_frame(params, pke); + EXPECT_EQ(jke[13], 0x41); + EXPECT_EQ(pke[13], 0x49); +} + +TEST(PanasonicFrame, EncodesTheCkpModelBytes) { + FrameParams params; + params.model = Model::CKP; + params.mode = Mode::HEAT; + params.temperature = 30; + uint8_t state[STATE_LENGTH]; + encode_frame(params, state); + EXPECT_EQ(state[21], NOTES_CKP_HEAT_30[21]); // b4 is always set + EXPECT_EQ(state[23], NOTES_CKP_HEAT_30[23]); +} + +TEST(PanasonicFrame, FanOnlyAlwaysSends27C) { + FrameParams params; + uint8_t state[STATE_LENGTH]; + params.mode = Mode::FAN; + params.temperature = 18; + encode_frame(params, state); + EXPECT_EQ((state[14] >> 1) & 0x1F, 27); +} + +TEST(PanasonicFrame, DecodesRealFrames) { + FrameParams p; + ASSERT_TRUE(decode_frame(JKE_COOL_25_OFF, p)); + EXPECT_FALSE(p.power); + EXPECT_EQ(p.mode, Mode::COOL); + EXPECT_EQ(p.temperature, 25); + EXPECT_EQ(p.fan_speed, FAN_SPEED_AUTO); + EXPECT_EQ(p.vane_v, VANE_V_AUTO); + + p = {}; + ASSERT_TRUE(decode_frame(ARTICLE_AUTO_25, p)); + EXPECT_TRUE(p.power); + EXPECT_EQ(p.mode, Mode::AUTO); + EXPECT_EQ(p.temperature, 25); + + p = {}; + ASSERT_TRUE(decode_frame(NOTES_CKP_HEAT_30, p)); + EXPECT_EQ(p.mode, Mode::HEAT); + EXPECT_EQ(p.temperature, 30); + + // Quiet and powerful are not used, but the frames are still valid states + for (const auto *f : {CKP_POWERFUL, CKP_QUIET}) { + p = {}; + ASSERT_TRUE(decode_frame(f, p)); + EXPECT_EQ(p.mode, Mode::HEAT); + EXPECT_EQ(p.temperature, 23); + } + + p = {}; + ASSERT_TRUE(decode_frame(DKE_HEAT_25, p)); + EXPECT_TRUE(p.power); + EXPECT_EQ(p.mode, Mode::HEAT); + EXPECT_EQ(p.temperature, 25); +} + +TEST(PanasonicFrame, DecodesTheAuthorsFrostProtectionFrames) { + for (const auto &c : {std::make_pair(PKE_10C_FAN_MAX, 10), std::make_pair(PKE_8C_FAN_MAX, 8)}) { + FrameParams p; + ASSERT_TRUE(decode_frame(c.first, p)); + EXPECT_TRUE(p.power); + EXPECT_EQ(p.mode, Mode::HEAT); + EXPECT_EQ(p.temperature, c.second); + EXPECT_EQ(p.fan_speed, FAN_SPEED_MAX); + } + + // Ordinary frames are never mistaken for it + for (const auto *f : {PKE_16C_FAN_MAX, PKE_30C_FAN_MAX, PKE_19C_AUTO, PKE_20C_AUTO}) { + FrameParams n; + ASSERT_TRUE(decode_frame(f, n)); + EXPECT_GE(n.temperature, TEMP_MIN); + } +} + +TEST(PanasonicFrame, RejectsInvalidFrames) { + // Sets one byte of a real frame and fixes up the checksum, so only that byte is wrong + auto corrupt = [](const uint8_t *real, size_t index, uint8_t value) { + FrameParams p; + uint8_t bad[STATE_LENGTH]; + memcpy(bad, real, STATE_LENGTH); + bad[index] = value; + bad[STATE_LENGTH - 1] = calc_checksum(bad, STATE_LENGTH); + return decode_frame(bad, p); + }; + + FrameParams p; + uint8_t bad[STATE_LENGTH]; + memcpy(bad, JKE_COOL_25_OFF, STATE_LENGTH); + bad[26] ^= 1; // Checksum + EXPECT_FALSE(decode_frame(bad, p)); + + EXPECT_FALSE(corrupt(JKE_COOL_25_OFF, 3, 0)); // Static prefix + EXPECT_FALSE(corrupt(JKE_COOL_25_OFF, 13, 0x10)); // Mode 1 doesn't exist + EXPECT_FALSE(corrupt(PKE_8C_FAN_MAX, 14, 0x0E)); // 7 C, below even the frost protection frame + EXPECT_FALSE(corrupt(JKE_COOL_25_OFF, 16, 0x0F)); // Fan nibble 0 +} + +TEST(PanasonicFrame, RoundTripsEverythingTheEncoderCanProduce) { + auto round_trip = [](const FrameParams &in) { + uint8_t state[STATE_LENGTH]; + encode_frame(in, state); + FrameParams out; + ASSERT_TRUE(decode_frame(state, out)); + EXPECT_EQ(out.power, in.power); + EXPECT_EQ(out.mode, in.mode); + EXPECT_EQ(out.temperature, in.mode == Mode::FAN ? TEMP_FAN_MODE : in.temperature); + EXPECT_EQ(out.fan_speed, in.fan_speed); + EXPECT_EQ(out.vane_v, in.vane_v); + }; + // Each setting is varied on its own against a baseline, for every model + for (auto model : {Model::DKE, Model::JKE, Model::LKE, Model::NKE, Model::CKP, Model::RKR, Model::PKE}) { + SCOPED_TRACE(static_cast(model)); + FrameParams base; + base.model = model; + base.mode = Mode::COOL; + for (bool power : {false, true}) { + FrameParams p = base; + p.power = power; + round_trip(p); + } + for (auto mode : {Mode::AUTO, Mode::DRY, Mode::COOL, Mode::HEAT, Mode::FAN}) { + FrameParams p = base; + p.mode = mode; + round_trip(p); + } + for (uint8_t fan : + {FAN_SPEED_MIN, FAN_SPEED_LOW, FAN_SPEED_MEDIUM, FAN_SPEED_HIGH, FAN_SPEED_MAX, FAN_SPEED_AUTO}) { + FrameParams p = base; + p.fan_speed = fan; + round_trip(p); + } + for (uint8_t temp = TEMP_MIN_RECEIVE; temp <= TEMP_MAX; temp++) { + FrameParams p = base; + p.temperature = temp; + round_trip(p); + } + for (uint8_t vane : {VANE_V_HIGHEST, uint8_t{2}, VANE_V_MIDDLE, uint8_t{4}, VANE_V_LOWEST, VANE_V_AUTO}) { + FrameParams p = base; + p.vane_v = vane; + round_trip(p); + } + } +} + +// ---- Reading a capture -------------------------------------------------------------------------------------------- + +TEST(PanasonicReceive, WholeFrameForAnyIdleTime) { + for (int idle : {0, 5000, 10000, 20000, 30000}) { + SCOPED_TRACE(idle); + uint8_t state[STATE_LENGTH] = {}; + ASSERT_TRUE(read(capture(PKE_20C_AUTO, Sections::BOTH, idle), state)); + EXPECT_EQ(frame(state), frame(PKE_20C_AUTO)); + } +} + +TEST(PanasonicReceive, SplitCapturesNeedOnlyTheSecondSection) { + // The 10 ms gap is as long as the default idle time, and a shorter idle time always splits the frame + for (int idle : {0, 5000, 10000}) { + SCOPED_TRACE(idle); + uint8_t state[STATE_LENGTH] = {}; + EXPECT_FALSE(read(capture(PKE_20C_AUTO, Sections::FIRST, idle), state)); + ASSERT_TRUE(read(capture(PKE_20C_AUTO, Sections::SECOND, idle), state)); + EXPECT_EQ(frame(state), frame(PKE_20C_AUTO)); + } +} + +TEST(PanasonicReceive, IgnoresShortButtonFrames) { + // Powerful/quiet and nanoe-G have an 8 byte second section of their own + uint8_t state[STATE_LENGTH] = {}; + RawTimings whole; + add_section(whole, PKE_TOGGLE, 8); + whole.push_back(-static_cast(SECTION_GAP)); + add_section(whole, PKE_TOGGLE + 8, 8); + EXPECT_FALSE(read(whole, state)); + + RawTimings lone; + add_section(lone, PKE_TOGGLE + 8, 8); + lone.push_back(-5000); + EXPECT_FALSE(read(lone, state)); +} + +TEST(PanasonicReceive, RejectsBrokenCaptures) { + uint8_t state[STATE_LENGTH] = {}; + EXPECT_FALSE(read({}, state)); + + RawTimings truncated = capture(PKE_20C_AUTO); + truncated.resize(truncated.size() - 40); + EXPECT_FALSE(read(truncated, state)); + + RawTimings bad_bit = capture(PKE_20C_AUTO); + bad_bit[5] = 9000; + EXPECT_FALSE(read(bad_bit, state)); + + // Too many bytes must be rejected without writing past the buffer + RawTimings too_long; + const uint8_t zeros[40] = {}; + add_section(too_long, zeros, sizeof(zeros)); + EXPECT_FALSE(read(too_long, state)); + + RawTimings junk = capture(PKE_20C_AUTO, Sections::FIRST); + junk.push_back(-3000); + junk.push_back(HEADER_MARK); + EXPECT_FALSE(read(junk, state)); + + uint8_t other[STATE_LENGTH]; + memcpy(other, PKE_20C_AUTO, STATE_LENGTH); + other[0] = 0x12; // Not our first section + EXPECT_FALSE(read(capture(other), state)); +} + +TEST(PanasonicReceive, ToleratesTimingDrift) { + for (int percent : {90, 110}) { + SCOPED_TRACE(percent); + RawTimings raw = capture(PKE_20C_AUTO); + for (auto &v : raw) + v = v * percent / 100; + uint8_t state[STATE_LENGTH] = {}; + ASSERT_TRUE(read(raw, state)); + EXPECT_EQ(frame(state), frame(PKE_20C_AUTO)); + } +} + +// ---- The climate entity ------------------------------------------------------------------------------------------- + +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector frames; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->frames.push_back(this->temp_.get_data()); + } +}; + +struct Fixture { + CapturingTransmitter transmitter; + PanasonicIrClimate climate; + + /// With `restore`, setup() restores the state an earlier fixture in the same test saved + explicit Fixture(Model model = Model::PKE, bool restore = false) { + if (!restore && global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_panasonic_ir"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_model(model); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + /// Builds a call, lets `fill` set it up, and performs it + template void command(F &&fill) { + auto call = this->climate.make_call(); + fill(call); + call.perform(); + } + + /// The state of the last transmission, read back with the same code that reads a remote + Frame last_state() const { + EXPECT_FALSE(this->transmitter.frames.empty()); + if (this->transmitter.frames.empty()) + return {}; + uint8_t state[STATE_LENGTH] = {}; + EXPECT_TRUE(read(this->transmitter.frames.back(), state)); + return frame(state); + } + + bool receive(const RawTimings &raw) { + remote_base::RemoteReceiveData data(raw, TOLERANCE_PERCENT, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(this->climate).on_receive(data); + } + bool receive(const uint8_t (&state)[STATE_LENGTH]) { return this->receive(capture(state)); } +}; + +TEST(PanasonicClimate, TransmitsTheAuthorsFramesExactly) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_HEAT); + call.set_target_temperature(20.0f); + call.set_fan_mode(climate::CLIMATE_FAN_AUTO); + call.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); // The remote sends the vane on auto + }); + EXPECT_EQ(f.last_state(), frame(PKE_20C_AUTO)); + + f.command([](auto &call) { call.set_target_temperature(19.0f); }); + EXPECT_EQ(f.last_state(), frame(PKE_19C_AUTO)); + + f.command([](auto &call) { + call.set_target_temperature(30.0f); + call.set_fan_mode("Maximum"); + }); + EXPECT_EQ(f.last_state(), frame(PKE_30C_FAN_MAX)); +} + +TEST(PanasonicClimate, TransmitsTwoSectionsWithTheRightTimings) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(24.0f); + }); + ASSERT_EQ(f.transmitter.frames.size(), 1u); + const RawTimings &t = f.transmitter.frames.back(); + // header + 8 bytes + footer, gap, header + 19 bytes + footer + ASSERT_EQ(t.size(), static_cast(2 + 8 * 16 + 1 + 1 + 2 + 19 * 16 + 1)); + EXPECT_EQ(t[0], static_cast(HEADER_MARK)); + EXPECT_EQ(t[1], -static_cast(HEADER_SPACE)); + EXPECT_EQ(t[2 + 8 * 16], static_cast(BIT_MARK)); + EXPECT_EQ(t[2 + 8 * 16 + 1], -static_cast(SECTION_GAP)); + EXPECT_EQ(t.back(), static_cast(BIT_MARK)); +} + +TEST(PanasonicClimate, OffKeepsTheLastModeAndClearsThePowerBit) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(23.0f); + }); + f.command([](auto &call) { call.set_mode(climate::CLIMATE_MODE_OFF); }); + + FrameParams p; + const Frame last = f.last_state(); + ASSERT_TRUE(decode_frame(last.data(), p)); + EXPECT_FALSE(p.power); + EXPECT_EQ(p.mode, Mode::COOL); + EXPECT_EQ(p.temperature, 23); +} + +TEST(PanasonicClimate, CkpOnlyHasAPowerToggle) { + Fixture f(Model::CKP); + auto power_bit = [&]() { return f.last_state()[13] & 0x01; }; + + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_HEAT); + call.set_target_temperature(22.0f); + }); + EXPECT_EQ(power_bit(), 1); // Off -> on toggles + + f.command([](auto &call) { call.set_target_temperature(23.0f); }); + EXPECT_EQ(power_bit(), 0); // Already on, so changing a setting must not toggle it off + + f.command([](auto &call) { call.set_mode(climate::CLIMATE_MODE_OFF); }); + EXPECT_EQ(power_bit(), 1); // On -> off toggles + + f.command([](auto &call) { call.set_mode(climate::CLIMATE_MODE_OFF); }); + EXPECT_EQ(power_bit(), 0); // Already off +} + +TEST(PanasonicClimate, CkpRestoredOnDoesNotToggleOff) { + { + Fixture f(Model::CKP); + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_HEAT); + call.set_target_temperature(22.0f); + }); + } + Fixture f(Model::CKP, true); + ASSERT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + f.command([](auto &call) { call.set_target_temperature(23.0f); }); + EXPECT_EQ(f.last_state()[13] & 0x01, 0); // The unit is taken to be on still +} + +TEST(PanasonicClimate, FrostProtectionIsAPreset) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(24.0f); + }); + + f.command([](auto &call) { call.set_preset(climate::CLIMATE_PRESET_AWAY); }); + EXPECT_EQ(f.last_state(), frame(PKE_10C_FAN_MAX)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_AWAY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 24.0f); // The set-point is not touched + + // Whatever else the call sets, the frame is the remote's + f.command([](auto &call) { + call.set_preset(climate::CLIMATE_PRESET_AWAY); + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_fan_mode(climate::CLIMATE_FAN_LOW); + }); + EXPECT_EQ(f.last_state(), frame(PKE_10C_FAN_MAX)); + // and the entity shows what was sent + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Maximum"); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + // Changing anything else takes the unit out of it + f.command([](auto &call) { call.set_target_temperature(21.0f); }); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + FrameParams p; + const Frame last = f.last_state(); + ASSERT_TRUE(decode_frame(last.data(), p)); + EXPECT_EQ(p.temperature, 21); +} + +TEST(PanasonicClimate, PresetNoneLeavesFrostProtection) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(24.0f); + }); + f.command([](auto &call) { call.set_preset(climate::CLIMATE_PRESET_AWAY); }); + ASSERT_EQ(f.last_state(), frame(PKE_10C_FAN_MAX)); + + f.command([](auto &call) { call.set_preset(climate::CLIMATE_PRESET_NONE); }); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + FrameParams p; + const Frame last = f.last_state(); + ASSERT_TRUE(decode_frame(last.data(), p)); + EXPECT_EQ(p.temperature, 24); +} + +TEST(PanasonicClimate, CkpIgnoresTheEchoOfItsOwnToggle) { + Fixture f(Model::CKP); + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_HEAT); + call.set_target_temperature(22.0f); + }); + const RawTimings sent = f.transmitter.frames.back(); + + ASSERT_TRUE(f.receive(sent)); // Our own toggle, heard back + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + + ASSERT_TRUE(f.receive(sent)); // The same frame again is a press of the remote + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); +} + +TEST(PanasonicClimate, EchoKeepsTheVaneFollowingTheMode) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(24.0f); + call.set_swing_mode(climate::CLIMATE_SWING_OFF); + }); + ASSERT_EQ(f.last_state()[16] & 0x0F, VANE_V_HIGHEST); + ASSERT_TRUE(f.receive(f.transmitter.frames.back())); // Our own frame, heard back + + // No position was chosen, so heating still blows down + f.command([](auto &call) { call.set_mode(climate::CLIMATE_MODE_HEAT); }); + EXPECT_EQ(f.last_state()[16] & 0x0F, VANE_V_LOWEST); +} + +TEST(PanasonicClimate, RemoteFrameWithTheEchoChecksumIsApplied) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_HEAT); + call.set_target_temperature(22.0f); + call.set_swing_mode(climate::CLIMATE_SWING_OFF); + }); + const Frame sent = f.last_state(); + ASSERT_EQ(sent[16] & 0x0F, VANE_V_LOWEST); + + // One degree warmer (B14 + 2) and the vane two steps up (B16 - 2) keeps the checksum + uint8_t remote[STATE_LENGTH]; + memcpy(remote, sent.data(), STATE_LENGTH); + remote[14] += 2; + remote[16] -= 2; + ASSERT_EQ(remote[STATE_LENGTH - 1], calc_checksum(remote, STATE_LENGTH)); + + ASSERT_TRUE(f.receive(remote)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 23.0f); +} + +TEST(PanasonicClimate, ReceivesTheRemote) { + Fixture f; + ASSERT_TRUE(f.receive(PKE_20C_AUTO)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + + ASSERT_TRUE(f.receive(PKE_30C_FAN_MAX)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 30.0f); + EXPECT_TRUE(f.climate.has_custom_fan_mode()); + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Maximum"); + + ASSERT_TRUE(f.receive(JKE_COOL_25_OFF)); // Power bit clear + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 25.0f); + EXPECT_FALSE(f.climate.has_custom_fan_mode()); + EXPECT_EQ(f.climate.fan_mode, climate::CLIMATE_FAN_AUTO); +} + +TEST(PanasonicClimate, ReceivesFrostProtectionWithoutLosingTheSetPoint) { + Fixture f; + ASSERT_TRUE(f.receive(PKE_19C_AUTO)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 19.0f); + + // The remote sends 8 C or 10 C in this mode + for (const auto *frost : {PKE_10C_FAN_MAX, PKE_8C_FAN_MAX}) { + ASSERT_TRUE(f.receive(capture(frost))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_AWAY); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 19.0f); // Frost protection is a mode, not a set-point + EXPECT_EQ(f.climate.get_custom_fan_mode(), "Maximum"); + } + + ASSERT_TRUE(f.receive(PKE_20C_AUTO)); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f); +} + +TEST(PanasonicClimate, SwingVerticalIsVaneAutoAndOffIsAFixedPosition) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(24.0f); + call.set_swing_mode(climate::CLIMATE_SWING_VERTICAL); + }); + EXPECT_EQ(f.last_state()[16] & 0x0F, VANE_V_AUTO); + + // Until a position is chosen, cooling blows up and heating blows down + f.command([](auto &call) { call.set_swing_mode(climate::CLIMATE_SWING_OFF); }); + EXPECT_EQ(f.last_state()[16] & 0x0F, VANE_V_HIGHEST); + f.command([](auto &call) { call.set_mode(climate::CLIMATE_MODE_HEAT); }); + EXPECT_EQ(f.last_state()[16] & 0x0F, VANE_V_LOWEST); +} + +TEST(PanasonicClimate, VanePositionSelectsAFixedPosition) { + Fixture f; + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(24.0f); + call.set_fan_mode(climate::CLIMATE_FAN_AUTO); + }); + + f.climate.set_vane_position(3); + EXPECT_EQ(f.last_state(), frame(PKE_COOL_24C_VANE_3)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); + + // The position is kept when the mode changes + f.command([](auto &call) { call.set_mode(climate::CLIMATE_MODE_HEAT); }); + EXPECT_EQ(f.last_state()[16] & 0x0F, 3); + + for (size_t position = 1; position <= 5; position++) { + f.climate.set_vane_position(position); + EXPECT_EQ(f.last_state()[16] & 0x0F, static_cast(position)); + } + + f.climate.set_vane_position(0); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(f.last_state()[16] & 0x0F, VANE_V_AUTO); +} + +TEST(PanasonicClimate, VanePositionOutOfRangeIsIgnored) { + Fixture f; + f.command([](auto &call) { call.set_mode(climate::CLIMATE_MODE_COOL); }); + const size_t sent = f.transmitter.frames.size(); + f.climate.set_vane_position(6); + EXPECT_EQ(f.transmitter.frames.size(), sent); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(PanasonicClimate, VanePositionLeavesFrostProtection) { + Fixture f; + f.command([](auto &call) { call.set_preset(climate::CLIMATE_PRESET_AWAY); }); + f.climate.set_vane_position(3); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); + EXPECT_EQ(f.last_state()[16] & 0x0F, 3); +} + +TEST(PanasonicClimate, ReceivesTheVanePosition) { + Fixture f; + ASSERT_TRUE(f.receive(PKE_COOL_24C_VANE_3)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_OFF); + + // Swing off keeps sending that position + f.command([](auto &call) { call.set_target_temperature(22.0f); }); + EXPECT_EQ(f.last_state()[16] & 0x0F, 3); + + ASSERT_TRUE(f.receive(PKE_20C_AUTO)); // Vane auto + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + // A vane value the remote never sends leaves the vane as it was + FrameParams p; + ASSERT_TRUE(decode_frame(frame(PKE_COOL_24C_VANE_3).data(), p)); + p.vane_v = 9; + uint8_t odd[STATE_LENGTH]; + encode_frame(p, odd); + ASSERT_TRUE(f.receive(odd)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(PanasonicClimate, ReceivesSplitCaptures) { + Fixture f; + EXPECT_FALSE(f.receive(capture(PKE_20C_AUTO, Sections::FIRST, 5000))); + ASSERT_TRUE(f.receive(capture(PKE_20C_AUTO, Sections::SECOND, 5000))); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f); +} + +TEST(PanasonicClimate, IgnoresWhatItDoesNotUnderstand) { + Fixture f; + ASSERT_TRUE(f.receive(PKE_20C_AUTO)); + + uint8_t bad[STATE_LENGTH]; + memcpy(bad, PKE_30C_FAN_MAX, STATE_LENGTH); + bad[26] ^= 1; + EXPECT_FALSE(f.receive(bad)); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f); // Unchanged +} + +TEST(PanasonicClimate, HearsItsOwnTransmissions) { + // Whatever the entity sends must read back to the state it was set to, for every model + for (auto model : {Model::DKE, Model::JKE, Model::LKE, Model::NKE, Model::CKP, Model::RKR, Model::PKE}) { + SCOPED_TRACE(static_cast(model)); + Fixture f(model); + f.command([](auto &call) { + call.set_mode(climate::CLIMATE_MODE_COOL); + call.set_target_temperature(22.0f); + call.set_fan_mode("Minimum"); + }); + + Fixture g(model); + ASSERT_TRUE(g.receive(f.transmitter.frames.back())); + EXPECT_EQ(g.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(g.climate.target_temperature, 22.0f); + EXPECT_EQ(g.climate.get_custom_fan_mode(), "Minimum"); + } +} + +} // namespace esphome::panasonic_ir::testing From bb0585447471724b986006eb7d5bf89168ffda4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:54:31 +0000 Subject: [PATCH 019/170] Update tzdata requirement from >=2026.5 to >=2026.6 (#22041) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 80a1d9dc15d..aa806ddc821 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ PyYAML==6.0.3 paho-mqtt==2.1.0 colorama==0.4.6 tzlocal==5.4.5 # from time -tzdata>=2026.5 # from time +tzdata>=2026.6 # from time pyserial==3.5 platformio==6.2.0 esptool==5.6.0 From 942ca93b6d3c8418a2043f9fc6e613e2da7ee4e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:54:53 +0000 Subject: [PATCH 020/170] Bump platformdirs from 4.13.6 to 4.14.0 (#22042) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index aa806ddc821..43642b089f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.4 -platformdirs==4.13.6 # native esp-idf toolchain global cache dir +platformdirs==4.14.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.2.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 63d2800aaebdc3c51cdbbaeddb2719b8907000ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:54:56 +0000 Subject: [PATCH 021/170] Bump pytest from 9.1.1 to 9.2.0 (#22040) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3a2f1776ba1..d815b80c5f3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,7 +5,7 @@ prek==0.5.7 # .github/workflows/ci.yml reads this pin yamlrocks==0.7.1 # used by script/sync_dependency_versions.py # Unit tests -pytest==9.1.1 +pytest==9.2.0 pytest-cov==7.1.0 pytest-mock==3.17.0 pytest-timeout==2.4.0 From ccf437271e9df94c3e47ec42f1b7f27bb4588ec1 Mon Sep 17 00:00:00 2001 From: Dan Jackson Date: Sat, 31 Oct 2026 12:56:46 -0700 Subject: [PATCH 022/170] [ms8607] Fix a crash without a humidity sensor and skip unconfigured readings (#16487) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/ms8607/ms8607.cpp | 357 ++++++++++-------- esphome/components/ms8607/ms8607.h | 114 +++--- esphome/components/ms8607/sensor.py | 1 + tests/component_tests/ms8607/__init__.py | 0 tests/component_tests/ms8607/test_ms8607.py | 24 ++ tests/components/ms8607/__init__.py | 7 + tests/components/ms8607/ms8607.cpp | 310 +++++++++++++++ .../ms8607/test_ms8607_lifecycle.cpp | 241 ++++++++++++ 8 files changed, 851 insertions(+), 203 deletions(-) create mode 100644 tests/component_tests/ms8607/__init__.py create mode 100644 tests/component_tests/ms8607/test_ms8607.py create mode 100644 tests/components/ms8607/__init__.py create mode 100644 tests/components/ms8607/ms8607.cpp create mode 100644 tests/components/ms8607/test_ms8607_lifecycle.cpp diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index f44dcf0127e..c91db071fc4 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -1,12 +1,13 @@ #include "ms8607.h" +#include + #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::ms8607 { -/// TAG used for logging calls ESPHOME_LOG_TAG(TAG, "ms8607"); static constexpr uint32_t RESET_TIMEOUT_ID = 0; @@ -16,162 +17,137 @@ static constexpr uint32_t PRESSURE_TIMEOUT_ID = 3; static constexpr uint32_t HUMIDITY_TIMEOUT_ID = 4; /// Reset the Pressure/Temperature sensor -static const uint8_t MS8607_PT_CMD_RESET = 0x1E; +static constexpr uint8_t MS8607_PT_CMD_RESET = 0x1E; /// Beginning of PROM register addresses. Same for both i2c addresses. Each address has 16 bits of data, and /// PROM addresses step by two, so the LSB is always 0 -static const uint8_t MS8607_PROM_START = 0xA0; +static constexpr uint8_t MS8607_PROM_START = 0xA0; /// Last PROM register address. -static const uint8_t MS8607_PROM_END = 0xAE; +static constexpr uint8_t MS8607_PROM_END = 0xAE; /// Number of PROM registers. -static const uint8_t MS8607_PROM_COUNT = (MS8607_PROM_END - MS8607_PROM_START) >> 1; +static constexpr uint8_t MS8607_PROM_COUNT = (MS8607_PROM_END - MS8607_PROM_START) >> 1; /// Reset the Humidity sensor -static const uint8_t MS8607_CMD_H_RESET = 0xFE; +static constexpr uint8_t MS8607_CMD_H_RESET = 0xFE; /// Read relative humidity, without holding i2c master -static const uint8_t MS8607_CMD_H_MEASURE_NO_HOLD = 0xF5; +static constexpr uint8_t MS8607_CMD_H_MEASURE_NO_HOLD = 0xF5; /// Temperature correction coefficient for Relative Humidity from datasheet -static const float MS8607_H_TEMP_COEFFICIENT = -0.18; +static constexpr float MS8607_H_TEMP_COEFFICIENT = -0.18f; /// Read the converted analog value, either D1 (pressure) or D2 (temperature) -static const uint8_t MS8607_CMD_ADC_READ = 0x00; +static constexpr uint8_t MS8607_CMD_ADC_READ = 0x00; // TODO: allow OSR to be turned down for speed and/or lower power consumption via configuration. // ms8607 supports 6 different settings /// Request conversion of analog D1 (pressure) with OSR=8192 (highest oversampling ratio). Takes maximum of 17.2ms -static const uint8_t MS8607_CMD_CONV_D1_OSR_8K = 0x4A; +static constexpr uint8_t MS8607_CMD_CONV_D1_OSR_8K = 0x4A; /// Request conversion of analog D2 (temperature) with OSR=8192 (highest oversampling ratio). Takes maximum of 17.2ms -static const uint8_t MS8607_CMD_CONV_D2_OSR_8K = 0x5A; - -enum class MS8607Component::ErrorCode { - /// Component hasn't failed (yet?) - NONE = 0, - /// Both the Pressure/Temperature address and the Humidity address failed to reset - PTH_RESET_FAILED = 1, - /// Asking the Pressure/Temperature sensor to reset failed - PT_RESET_FAILED = 2, - /// Asking the Humidity sensor to reset failed - H_RESET_FAILED = 3, - /// Reading the PROM calibration values failed - PROM_READ_FAILED = 4, - /// The PROM calibration values failed the CRC check - PROM_CRC_FAILED = 5, -}; - -enum class MS8607Component::SetupStatus { - /// This component has not successfully reset the PT & H devices - NEEDS_RESET, - /// Reset commands succeeded, need to wait >= 15ms to read PROM - NEEDS_PROM_READ, - /// Successfully read PROM and ready to update sensors - SUCCESSFUL, -}; +static constexpr uint8_t MS8607_CMD_CONV_D2_OSR_8K = 0x5A; static uint8_t crc4(uint16_t *buffer, size_t length); -void MS8607Component::setup() { - this->error_code_ = ErrorCode::NONE; - this->setup_status_ = SetupStatus::NEEDS_RESET; +// Sanity check Temperature & Pressure Readings, these values are way out of range +/// value below which we consider a temperature reading to be invalid +static constexpr float TEMPERATURE_LOWER_LIMIT = -50.0f; +/// value above which we consider a temperature reading to be invalid +static constexpr float TEMPERATURE_UPPER_LIMIT = 100.0f; + +/// value below which we consider a pressure reading to be invalid +static constexpr float PRESSURE_LOWER_LIMIT = 0.0f; +/// value above which we consider a pressure reading to be invalid +static constexpr float PRESSURE_UPPER_LIMIT = 2000.0f; + +void MS8607Component::setup() { // I do not know why the device sometimes NACKs the reset command, but // try 3 times in case it's a transitory issue on this boot // Backoff: executes at now, +5ms, +30ms - this->reset_attempts_remaining_ = 3; - this->reset_interval_ = 5; this->try_reset_(); } void MS8607Component::try_reset_() { - ESP_LOGD(TAG, "Resetting both I2C addresses: 0x%02X, 0x%02X", this->address_, this->humidity_device_->get_address()); // I believe sending the reset command to both addresses is preferable to // skipping humidity if PT fails for some reason. // However, only consider the reset successful if they both ACK bool const pt_successful = this->write_bytes(MS8607_PT_CMD_RESET, nullptr, 0); - bool const h_successful = this->humidity_device_->write_bytes(MS8607_CMD_H_RESET, nullptr, 0); + bool h_successful = true; + if (this->humidity_device_ != nullptr) { + ESP_LOGD(TAG, "Resetting I2C addresses: 0x%02X, 0x%02X", this->address_, this->humidity_device_->get_i2c_address()); + h_successful = this->humidity_device_->write_bytes(MS8607_CMD_H_RESET, nullptr, 0); + } else { + ESP_LOGD(TAG, "Resetting I2C address: 0x%02X", this->address_); + } if (!(pt_successful && h_successful)) { ESP_LOGE(TAG, "Resetting I2C devices failed"); - if (!pt_successful && !h_successful) { - this->error_code_ = ErrorCode::PTH_RESET_FAILED; - } else if (!pt_successful) { - this->error_code_ = ErrorCode::PT_RESET_FAILED; - } else { - this->error_code_ = ErrorCode::H_RESET_FAILED; - } - if (--this->reset_attempts_remaining_ > 0) { uint32_t delay = this->reset_interval_; this->reset_interval_ *= 5; this->set_timeout(RESET_TIMEOUT_ID, delay, [this]() { this->try_reset_(); }); this->status_set_error(); + return; + } + // The retries set the error flag without a reason; clear it so mark_failed() can record one + this->status_clear_error(); + if (!pt_successful && !h_successful) { + this->mark_failed(LOG_STR("Temperature/Pressure and Humidity reset failed")); + } else if (!pt_successful) { + this->mark_failed(LOG_STR("Temperature/Pressure reset failed")); } else { - this->mark_failed(); + this->mark_failed(LOG_STR("Humidity reset failed")); } return; } - this->setup_status_ = SetupStatus::NEEDS_PROM_READ; - this->error_code_ = ErrorCode::NONE; this->status_clear_error(); // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library this->set_timeout(PROM_READ_TIMEOUT_ID, 15, [this]() { - if (this->read_calibration_values_from_prom_()) { - this->setup_status_ = SetupStatus::SUCCESSFUL; - this->status_clear_error(); - } else { - this->mark_failed(); - return; - } + if (this->read_calibration_values_from_prom_()) + this->setup_complete_ = true; }); } void MS8607Component::update() { - if (this->setup_status_ != SetupStatus::SUCCESSFUL) { + if (!this->setup_complete_) { // setup is still occurring, either because reset had to retry or due to the 15ms // delay needed between reset & reading the PROM values return; } + this->update_ok_ = true; + // Updating happens async and sequentially. - // Temperature, then pressure, then humidity + // Temperature, then optionally pressure, then optionally humidity + // Temperature is used for compensation of pressure & humidity, so we'll always want it this->request_read_temperature_(); } void MS8607Component::dump_config() { ESP_LOGCONFIG(TAG, "MS8607:"); LOG_I2C_DEVICE(this); - // LOG_I2C_DEVICE doesn't work for humidity, the `address_` is protected. Log using get_address() - ESP_LOGCONFIG(TAG, " Address: 0x%02X", this->humidity_device_->get_address()); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - switch (this->error_code_) { - case ErrorCode::PT_RESET_FAILED: - ESP_LOGE(TAG, "Temperature/Pressure RESET failed"); - break; - case ErrorCode::H_RESET_FAILED: - ESP_LOGE(TAG, "Humidity RESET failed"); - break; - case ErrorCode::PTH_RESET_FAILED: - ESP_LOGE(TAG, "Temperature/Pressure && Humidity RESET failed"); - break; - case ErrorCode::PROM_READ_FAILED: - ESP_LOGE(TAG, "Reading PROM failed"); - break; - case ErrorCode::PROM_CRC_FAILED: - ESP_LOGE(TAG, "PROM values failed CRC"); - break; - case ErrorCode::NONE: - default: - ESP_LOGE(TAG, "Error reason unknown %u", static_cast(this->error_code_)); - break; - } + if (this->humidity_device_ != nullptr) { + ESP_LOGCONFIG(TAG, " Address: 0x%02X", this->humidity_device_->get_i2c_address()); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + // Empty at boot, since the PROM is read after a delay; useful on a device that is already running + ESP_LOGVV(TAG, + " Calibration Values:\n" + " Pressure Sensitivity: 0x%04X\n" + " Pressure Offset: 0x%04X\n" + " Pressure Sensitivity Temperature Coefficient: 0x%04X\n" + " Pressure Offset Temperature Coefficient: 0x%04X\n" + " Reference Temperature: 0x%04X\n" + " Temperature Coefficient of Temperature: 0x%04X", + this->calibration_values_.pressure_sensitivity, this->calibration_values_.pressure_offset, + this->calibration_values_.pressure_sensitivity_temperature_coefficient, + this->calibration_values_.pressure_offset_temperature_coefficient, + this->calibration_values_.reference_temperature, + this->calibration_values_.temperature_coefficient_of_temperature); } bool MS8607Component::read_calibration_values_from_prom_() { @@ -186,8 +162,7 @@ bool MS8607Component::read_calibration_values_from_prom_() { } if (!successful) { - ESP_LOGE(TAG, "Reading calibration values from PROM failed"); - this->error_code_ = ErrorCode::PROM_READ_FAILED; + this->mark_failed(LOG_STR("Reading the PROM calibration values failed")); return false; } @@ -198,7 +173,7 @@ bool MS8607Component::read_calibration_values_from_prom_() { if (expected_crc != actual_crc) { ESP_LOGE(TAG, "Incorrect CRC value. Provided value 0x%01X != calculated value 0x%01X", expected_crc, actual_crc); - this->error_code_ = ErrorCode::PROM_CRC_FAILED; + this->mark_failed(LOG_STR("PROM calibration values failed the CRC check")); return false; } @@ -249,10 +224,20 @@ static uint8_t crc4(uint16_t *buffer, size_t length) { return (crc_remainder >> 12) & 0xF; // only the most significant 4 bits } +bool MS8607Component::read_adc_(uint32_t &value) { + uint8_t bytes[3]; // 24 bits + if (!this->read_bytes(MS8607_CMD_ADC_READ, bytes, 3)) + return false; + value = encode_uint24(bytes[0], bytes[1], bytes[2]); + return true; +} + void MS8607Component::request_read_temperature_() { // Tell MS8607 to start ADC conversion of temperature sensor if (!this->write_bytes(MS8607_CMD_CONV_D2_OSR_8K, nullptr, 0)) { - this->status_set_warning(); + ESP_LOGW(TAG, "Requesting ADC Read of temperature failed"); + this->update_ok_ = false; + this->finalize_update_(); return; } @@ -261,53 +246,95 @@ void MS8607Component::request_read_temperature_() { } void MS8607Component::read_temperature_() { - uint8_t bytes[3]; // 24 bits - if (!this->read_bytes(MS8607_CMD_ADC_READ, bytes, 3)) { - this->status_set_warning(); + uint32_t d2_raw_temperature; + if (!this->read_adc_(d2_raw_temperature)) { + ESP_LOGW(TAG, "ADC Read of temperature failed"); + this->update_ok_ = false; + this->finalize_update_(); return; } - const uint32_t d2_raw_temperature = encode_uint32(0, bytes[0], bytes[1], bytes[2]); - this->request_read_pressure_(d2_raw_temperature); + const CompensatedTemperature temperature = compensated_temperature(d2_raw_temperature, this->calibration_values_); + if (temperature.temperature_float < TEMPERATURE_LOWER_LIMIT || + temperature.temperature_float > TEMPERATURE_UPPER_LIMIT) { + ESP_LOGW(TAG, "Treating this temperature read of %0.2f°C as a failed read, skipping it", + temperature.temperature_float); + this->update_ok_ = false; + this->finalize_update_(); + return; + } + + PUBLISH_STATE(this->temperature_sensor_, temperature.temperature_float); + + if (this->pressure_sensor_ == nullptr || !this->request_read_pressure_(d2_raw_temperature)) { + this->finish_update_(temperature.temperature_float); + } } -void MS8607Component::request_read_pressure_(uint32_t d2_raw_temperature) { +bool MS8607Component::request_read_pressure_(uint32_t d2_raw_temperature) { if (!this->write_bytes(MS8607_CMD_CONV_D1_OSR_8K, nullptr, 0)) { - this->status_set_warning(); - return; + ESP_LOGW(TAG, "Requesting ADC Read of pressure failed"); + this->update_ok_ = false; + return false; } // datasheet says 17.2ms max conversion time at OSR 8192 this->set_timeout(PRESSURE_TIMEOUT_ID, 20, [this, d2_raw_temperature]() { this->read_pressure_(d2_raw_temperature); }); + return true; } void MS8607Component::read_pressure_(uint32_t d2_raw_temperature) { - uint8_t bytes[3]; // 24 bits - if (!this->read_bytes(MS8607_CMD_ADC_READ, bytes, 3)) { - this->status_set_warning(); - return; + const CompensatedTemperature temperature = compensated_temperature(d2_raw_temperature, this->calibration_values_); + + uint32_t d1_raw_pressure; + if (!this->read_adc_(d1_raw_pressure)) { + ESP_LOGW(TAG, "ADC Read of pressure failed"); + this->update_ok_ = false; + } else if (d1_raw_pressure == 0) { + // Sensor returns all zeros if the ADC conversion isn't done yet. Check the timeout in request_read_pressure_ + ESP_LOGW(TAG, "ADC Read of pressure returned all zeros, the conversion had not finished"); + this->update_ok_ = false; + } else { + const float pressure = compensated_pressure(d1_raw_pressure, this->calibration_values_, temperature); + if (pressure < PRESSURE_LOWER_LIMIT || pressure > PRESSURE_UPPER_LIMIT) { + ESP_LOGW(TAG, "Treating this pressure read of %.2fhPa as a failed read, skipping it", pressure); + this->update_ok_ = false; + } else { + this->pressure_sensor_->publish_state(pressure); // hPa aka mbar + } } - const uint32_t d1_raw_pressure = encode_uint32(0, bytes[0], bytes[1], bytes[2]); - this->calculate_values_(d2_raw_temperature, d1_raw_pressure); + + this->finish_update_(temperature.temperature_float); } -void MS8607Component::request_read_humidity_(float temperature_float) { +void MS8607Component::finish_update_(float temperature) { + if (this->humidity_device_ != nullptr) { + // now that we have temperature (to compensate the humidity with), kick off that read + this->request_read_humidity_(temperature); + } else { + this->finalize_update_(); + } +} + +void MS8607Component::request_read_humidity_(float temperature) { if (!this->humidity_device_->write_bytes(MS8607_CMD_H_MEASURE_NO_HOLD, nullptr, 0)) { ESP_LOGW(TAG, "Request to measure humidity failed"); - this->status_set_warning(); + this->update_ok_ = false; + this->finalize_update_(); return; } // datasheet says 15.89ms max conversion time at OSR 8192 - this->set_timeout(HUMIDITY_TIMEOUT_ID, 20, [this, temperature_float]() { this->read_humidity_(temperature_float); }); + this->set_timeout(HUMIDITY_TIMEOUT_ID, 20, [this, temperature]() { this->read_humidity_(temperature); }); } -void MS8607Component::read_humidity_(float temperature_float) { +void MS8607Component::read_humidity_(float temperature) { uint8_t bytes[3]; if (!this->humidity_device_->read_bytes_raw(bytes, 3)) { ESP_LOGW(TAG, "Failed to read the measured humidity value"); - this->status_set_warning(); + this->update_ok_ = false; + this->finalize_update_(); return; } @@ -317,95 +344,115 @@ void MS8607Component::read_humidity_(float temperature_float) { uint8_t const expected_crc = bytes[2]; uint8_t const actual_crc = crc8(bytes, 2, 0, 0x31, true); if (expected_crc != actual_crc) { - ESP_LOGE(TAG, "Incorrect Humidity CRC value. Provided value 0x%01X != calculated value 0x%01X", expected_crc, + ESP_LOGW(TAG, "Incorrect Humidity CRC value. Provided value 0x%01X != calculated value 0x%01X", expected_crc, actual_crc); - this->status_set_warning(); + this->update_ok_ = false; + this->finalize_update_(); return; } if (!(humidity & 0x2)) { // data sheet says Bit1 should always set, but nothing about what happens if it isn't - ESP_LOGE(TAG, "Humidity status bit was not set to 1?"); + ESP_LOGW(TAG, "Humidity status bit was not set to 1?"); + } + if (humidity == 0) { + // treat this like a bad I2C read, because we received all zeros, despite the datasheet saying + // there should be at least Bit1 set. CRC might have passed because all zeros results in expected CRC of 0. + ESP_LOGW(TAG, "Treating this humidity read as a failed read, skipping it"); + this->update_ok_ = false; + this->finalize_update_(); + return; } humidity &= ~(0b11); // strip status & unassigned bits from data - // map 16 bit humidity value into range [-6%, 118%] - float const humidity_partial = float(humidity) / (1 << 16); - float const humidity_percentage = std::lerp(-6.0f, 118.0f, humidity_partial); - float const compensated_humidity_percentage = - humidity_percentage + (20 - temperature_float) * MS8607_H_TEMP_COEFFICIENT; - ESP_LOGD(TAG, "Compensated for temperature, humidity=%.2f%%", compensated_humidity_percentage); - - PUBLISH_STATE(this->humidity_sensor_, compensated_humidity_percentage); - this->status_clear_warning(); + this->humidity_sensor_->publish_state(compensated_humidity(humidity, temperature)); + this->finalize_update_(); } -void MS8607Component::calculate_values_(uint32_t d2_raw_temperature, uint32_t d1_raw_pressure) { - // Perform the first order pressure/temperature calculation +void MS8607Component::finalize_update_() { this->status_comm_result(this->update_ok_, nullptr); } + +CompensatedTemperature compensated_temperature(uint32_t d2_raw_temperature, + const CalibrationValues &calibration_values) { + CompensatedTemperature result; + // Perform the first order temperature calculation // d_t: "difference between actual and reference temperature" = D2 - [C5] * 2**8 - const int32_t d_t = int32_t(d2_raw_temperature) - (int32_t(this->calibration_values_.reference_temperature) << 8); + result.d_t = int32_t(d2_raw_temperature) - (int32_t(calibration_values.reference_temperature) << 8); + const int64_t d_t_squared = int64_t(result.d_t) * result.d_t; + // actual temperature as hundredths of degree celsius in range [-4000, 8500] // 2000 + d_t * [C6] / (2**23) - int32_t temperature = - 2000 + ((int64_t(d_t) * this->calibration_values_.temperature_coefficient_of_temperature) >> 23); + result.first_order_temperature = + 2000 + ((int64_t(result.d_t) * calibration_values.temperature_coefficient_of_temperature) >> 23); + + // Perform the second order temperature compensation, for non-linearity over temperature range + int32_t temperature_2; + if (result.first_order_temperature < 2000) { + // T2 = 3 * (d_t**2) / 2**33 + temperature_2 = int32_t((3 * d_t_squared) >> 33); + } else { + // T2 = 5 * (d_t**2) / 2**38 + temperature_2 = int32_t((5 * d_t_squared) >> 38); + } + + result.temperature_float = (result.first_order_temperature - temperature_2) / 100.0f; + + return result; +} + +float compensated_pressure(uint32_t d1_raw_pressure, const CalibrationValues &calibration_values, + const CompensatedTemperature &temperature_values) { + // Perform the first order pressure calculation // offset at actual temperature. [C2] * (2**17) + (d_t * [C4] / (2**6)) - int64_t pressure_offset = (int64_t(this->calibration_values_.pressure_offset) << 17) + - ((int64_t(d_t) * this->calibration_values_.pressure_offset_temperature_coefficient) >> 6); + int64_t pressure_offset = + (int64_t(calibration_values.pressure_offset) << 17) + + ((int64_t(temperature_values.d_t) * calibration_values.pressure_offset_temperature_coefficient) >> 6); // sensitivity at actual temperature. [C1] * (2**16) + ([C3] * d_t) / (2**7) int64_t pressure_sensitivity = - (int64_t(this->calibration_values_.pressure_sensitivity) << 16) + - ((int64_t(d_t) * this->calibration_values_.pressure_sensitivity_temperature_coefficient) >> 7); + (int64_t(calibration_values.pressure_sensitivity) << 16) + + ((int64_t(temperature_values.d_t) * calibration_values.pressure_sensitivity_temperature_coefficient) >> 7); - // Perform the second order compensation, for non-linearity over temperature range - const int64_t d_t_squared = int64_t(d_t) * d_t; - int64_t temperature_2 = 0; + // Perform the second order pressure compensation, for non-linearity over temperature range int32_t pressure_offset_2 = 0; int32_t pressure_sensitivity_2 = 0; - if (temperature < 2000) { + if (temperature_values.first_order_temperature < 2000) { // (TEMP - 2000)**2 / 2**4 - const int32_t low_temperature_adjustment = (temperature - 2000) * (temperature - 2000) >> 4; + const int32_t low_temperature_adjustment = + (temperature_values.first_order_temperature - 2000) * (temperature_values.first_order_temperature - 2000) >> 4; - // T2 = 3 * (d_t**2) / 2**33 - temperature_2 = (3 * d_t_squared) >> 33; // OFF2 = 61 * (TEMP-2000)**2 / 2**4 pressure_offset_2 = 61 * low_temperature_adjustment; // SENS2 = 29 * (TEMP-2000)**2 / 2**4 pressure_sensitivity_2 = 29 * low_temperature_adjustment; - if (temperature < -1500) { + if (temperature_values.first_order_temperature < -1500) { // (TEMP+1500)**2 - const int32_t very_low_temperature_adjustment = (temperature + 1500) * (temperature + 1500); + const int32_t very_low_temperature_adjustment = + (temperature_values.first_order_temperature + 1500) * (temperature_values.first_order_temperature + 1500); // OFF2 = OFF2 + 17 * (TEMP+1500)**2 pressure_offset_2 += 17 * very_low_temperature_adjustment; // SENS2 = SENS2 + 9 * (TEMP+1500)**2 pressure_sensitivity_2 += 9 * very_low_temperature_adjustment; } - } else { - // T2 = 5 * (d_t**2) / 2**38 - temperature_2 = (5 * d_t_squared) >> 38; } - temperature -= temperature_2; pressure_offset -= pressure_offset_2; pressure_sensitivity -= pressure_sensitivity_2; // Temperature compensated pressure. [1000, 120000] => [10.00 mbar, 1200.00 mbar] const int32_t pressure = (((d1_raw_pressure * pressure_sensitivity) >> 21) - pressure_offset) >> 15; - const float temperature_float = temperature / 100.0f; - const float pressure_float = pressure / 100.0f; - ESP_LOGD(TAG, "Temperature=%0.2f°C, Pressure=%0.2fhPa", temperature_float, pressure_float); + return pressure / 100.0f; +} - PUBLISH_STATE(this->temperature_sensor_, temperature_float); - PUBLISH_STATE(this->pressure_sensor_, pressure_float); // hPa aka mbar - this->status_clear_warning(); - - if (this->humidity_sensor_ != nullptr) { - // now that we have temperature (to compensate the humidity with), kick off that read - this->request_read_humidity_(temperature_float); - } +float compensated_humidity(float humidity_float, float temperature_float) { + // map 16 bit humidity value into range [-6%, 119%] + // Datasheet is inconsistent on upper max value (118 vs 119), but example value, + // calculation equation, and max value in table all indicate it should be 119% + float const humidity_partial = humidity_float / (1 << 16); + float const humidity_percentage = std::lerp(-6.0f, 119.0f, humidity_partial); + return humidity_percentage + (20 - temperature_float) * MS8607_H_TEMP_COEFFICIENT; } } // namespace esphome::ms8607 diff --git a/esphome/components/ms8607/ms8607.h b/esphome/components/ms8607/ms8607.h index 8ce4c864a53..cb86e595400 100644 --- a/esphome/components/ms8607/ms8607.h +++ b/esphome/components/ms8607/ms8607.h @@ -6,15 +6,50 @@ namespace esphome::ms8607 { -/** - Class for I2CDevice used to communicate with the Humidity sensor - on the chip. See MS8607Component instead - */ -class MS8607HumidityDevice final : public i2c::I2CDevice { - public: - uint8_t get_address() { return address_; } +/// Pressure & temperature calibration values, read from the PROM +struct CalibrationValues { + /// Pressure sensitivity | SENS-T1. [C1] + uint16_t pressure_sensitivity; + /// Temperature coefficient of pressure sensitivity | TCS. [C3] + uint16_t pressure_sensitivity_temperature_coefficient; + /// Pressure offset | OFF-T1. [C2] + uint16_t pressure_offset; + /// Temperature coefficient of pressure offset | TCO. [C4] + uint16_t pressure_offset_temperature_coefficient; + /// Reference temperature | T-REF. [C5] + uint16_t reference_temperature; + /// Temperature coefficient of the temperature | TEMPSENS. [C6] + uint16_t temperature_coefficient_of_temperature; }; +struct CompensatedTemperature { + /// difference between actual and reference temperature + int32_t d_t; + /// temperature as hundredths of degree celsius in range [-4000, 8500], after first order + /// temperature calculation + int32_t first_order_temperature; + /// actual temperature, after 2nd order temperature calculation, in degrees celsius as a float + float temperature_float; +}; + +/// use raw temperature and calibration values to figure out actual temperature value +/// return value includes some intermediate values needed by the pressure compensation algorithm +/// Precondition: values must be within typical values from the device to avoid Undefined Behavior. +CompensatedTemperature compensated_temperature(uint32_t d2_raw_temperature, + const CalibrationValues &calibration_values); + +/// use raw pressure, calibration values, and current temperature to figure out actual pressure +/// Precondition: calibration_values & temperature_values must be within typical values for the device. +/// Unbounded values can cause Undefined Behavior. +float compensated_pressure(uint32_t d1_raw_pressure, const CalibrationValues &calibration_values, + const CompensatedTemperature &temperature_values); + +/// convert raw humidity value into correct range & apply temperature compensation calculation +float compensated_humidity(float humidity_float, float temperature_float); + +/// I2C device for the humidity sensor's own address on the chip. See MS8607Component instead +class MS8607HumidityDevice final : public i2c::I2CDevice {}; + /** Temperature, pressure, and humidity sensor. @@ -41,67 +76,50 @@ class MS8607Component final : public PollingComponent, public i2c::I2CDevice { void update() override; void dump_config() override; - void set_humidity_device(MS8607HumidityDevice *humidity_device) { humidity_device_ = humidity_device; } + void set_humidity_device(MS8607HumidityDevice *humidity_device) { this->humidity_device_ = humidity_device; } protected: /// Attempt to reset both I2C devices, retrying with backoff on failure void try_reset_(); - /** - Read and store the Pressure & Temperature calibration settings from the PROM. - Intended to be called during setup(), this will set the `failure_reason_` - */ + /// Read and CRC-check the PROM calibration values; marks the component failed on error bool read_calibration_values_from_prom_(); + /// Read a 24-bit converted ADC value, either D1 (pressure) or D2 (temperature) + bool read_adc_(uint32_t &value); /// Start async temperature read void request_read_temperature_(); - /// Process async temperature read + /// Process async temperature read, publish it and hand off to pressure or humidity void read_temperature_(); - /// start async pressure read - void request_read_pressure_(uint32_t raw_temperature); - /// process async pressure read - void read_pressure_(uint32_t raw_temperature); + /// Start async pressure read; false if the request failed + bool request_read_pressure_(uint32_t d2_raw_temperature); + /// Process async pressure read and publish it. Takes the raw temperature rather than the + /// compensated one so the timeout callback fits in std::function's inline storage + void read_pressure_(uint32_t d2_raw_temperature); + /// Read humidity if it is configured, otherwise end the update cycle + void finish_update_(float temperature); /// start async humidity read - void request_read_humidity_(float temperature_float); + void request_read_humidity_(float temperature); /// process async humidity read - void read_humidity_(float temperature_float); - /// use raw temperature & pressure to calculate & publish values - void calculate_values_(uint32_t raw_temperature, uint32_t raw_pressure); + void read_humidity_(float temperature); + /// End the update cycle, setting or clearing the warning from update_ok_ + void finalize_update_(); /** I2CDevice object to communicate with secondary I2C address for the humidity sensor * * The MS8607 only has one set of I2C pins, despite using two different addresses. - * - * Default address for humidity is 0x40 + * Set exactly when the humidity sensor is configured. Default address for humidity is 0x40 */ MS8607HumidityDevice *humidity_device_{nullptr}; /// This device's pressure & temperature calibration values, read from PROM - struct CalibrationValues { - /// Pressure sensitivity | SENS-T1. [C1] - uint16_t pressure_sensitivity; - /// Temperature coefficient of pressure sensitivity | TCS. [C3] - uint16_t pressure_sensitivity_temperature_coefficient; - /// Pressure offset | OFF-T1. [C2] - uint16_t pressure_offset; - /// Temperature coefficient of pressure offset | TCO. [C4] - uint16_t pressure_offset_temperature_coefficient; - /// Reference temperature | T-REF. [C5] - uint16_t reference_temperature; - /// Temperature coefficient of the temperature | TEMPSENS. [C6] - uint16_t temperature_coefficient_of_temperature; - } calibration_values_; + CalibrationValues calibration_values_{}; - /// Possible failure reasons of this component - enum class ErrorCode; - /// Keep track of the reason why this component failed, to augment the dumped config - ErrorCode error_code_; - - /// Current progress through required component setup - enum class SetupStatus; - /// Current step in the multi-step & possibly delayed setup() process - SetupStatus setup_status_; - uint32_t reset_interval_{5}; - uint8_t reset_attempts_remaining_{0}; + /// Set once the PROM calibration values have been read + bool setup_complete_{false}; + uint8_t reset_interval_{5}; + uint8_t reset_attempts_remaining_{3}; + /// False once any step of the current update cycle failed + bool update_ok_{true}; }; } // namespace esphome::ms8607 diff --git a/esphome/components/ms8607/sensor.py b/esphome/components/ms8607/sensor.py index 83609916ef4..ebfe69bfcd9 100644 --- a/esphome/components/ms8607/sensor.py +++ b/esphome/components/ms8607/sensor.py @@ -59,6 +59,7 @@ CONFIG_SCHEMA = ( ) .extend(cv.polling_component_schema("60s")) .extend(i2c.i2c_device_schema(0x76)) # default address for temp/pressure + .add_extra(cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_PRESSURE, CONF_HUMIDITY)) ) diff --git a/tests/component_tests/ms8607/__init__.py b/tests/component_tests/ms8607/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/ms8607/test_ms8607.py b/tests/component_tests/ms8607/test_ms8607.py new file mode 100644 index 00000000000..515de602b83 --- /dev/null +++ b/tests/component_tests/ms8607/test_ms8607.py @@ -0,0 +1,24 @@ +"""Tests for the MS8607 sensor configuration.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.ms8607.sensor import CONFIG_SCHEMA + + +def test_at_least_one_sensor_is_required() -> None: + """A component without a sensor has nothing to publish.""" + with pytest.raises(cv.Invalid, match="Must contain at least one of"): + CONFIG_SCHEMA({"id": "ms8607_sensor", "i2c_id": "i2c_bus"}) + + +def test_single_sensor_is_accepted() -> None: + config = CONFIG_SCHEMA( + { + "id": "ms8607_sensor", + "i2c_id": "i2c_bus", + "pressure": {"name": "MS8607 Pressure"}, + } + ) + + assert config["pressure"]["name"] == "MS8607 Pressure" diff --git a/tests/components/ms8607/__init__.py b/tests/components/ms8607/__init__.py new file mode 100644 index 00000000000..133e3489c57 --- /dev/null +++ b/tests/components/ms8607/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # A sensor-only platform declares its dependencies on the platform, which the host + # unit-test build does not walk, so name them here for the build. + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/ms8607/ms8607.cpp b/tests/components/ms8607/ms8607.cpp new file mode 100644 index 00000000000..3e5d4e88421 --- /dev/null +++ b/tests/components/ms8607/ms8607.cpp @@ -0,0 +1,310 @@ +#include + +#include "esphome/components/ms8607/ms8607.h" + +namespace esphome::ms8607::testing { + +/// Create a CalibrationValues object with provided values, defaults to the +/// "Example / Typical" values from datasheet: +/// https://www.te.com/commerce/DocumentDelivery/DDEController?Action=showdoc&DocId=Data+Sheet%7FMS8607-02BA01%7FB3%7Fpdf%7FEnglish%7FENG_DS_MS8607-02BA01_B3.pdf%7FCAT-BLPS0018 +/* +Note that calibration values read from my MS8607 are in the same range +Pressure Sensitivity: 0xA932 == 43314 +Pressure Offset: 0xAC7F == 44159 +Pressure Sensitivity Temperature Coefficient: 0x6773 == 26483 +Pressure Offset Temperature Coefficient: 0x6DC3 == 28009 +Reference Temperature: 0x7691 == 30353 +Temperature Coefficient of Temperature: 0x6B04 == 27396 +*/ +static CalibrationValues create_calibration_values(uint16_t c1 = 46372, uint16_t c2 = 43981, uint16_t c3 = 29059, + uint16_t c4 = 27842, uint16_t c5 = 31553, uint16_t c6 = 28165) { + return { + .pressure_sensitivity = c1, + .pressure_sensitivity_temperature_coefficient = c3, + .pressure_offset = c2, + .pressure_offset_temperature_coefficient = c4, + .reference_temperature = c5, + .temperature_coefficient_of_temperature = c6, + }; +} + +TEST(MS8607Test, Category1TemperatureCriticalBoundaries) { + auto calibration_values = create_calibration_values(); + uint32_t const reference_temperature = calibration_values.reference_temperature << 8; + + // Exact Reference Temperature (20°C Standard Baseline) + { + uint32_t const d2_raw_temperature = reference_temperature; + auto res = compensated_temperature(d2_raw_temperature, calibration_values); + EXPECT_EQ(res.d_t, 0); + EXPECT_EQ(res.first_order_temperature, 2000); + EXPECT_NEAR(res.temperature_float, 20.0f, 1e-3f); + } + + // Just Above Crossover (20.01°C) + { + uint32_t const d2_raw_temperature = reference_temperature + 2000; + auto res = compensated_temperature(d2_raw_temperature, calibration_values); + EXPECT_EQ(res.d_t, 2000); + EXPECT_EQ(res.first_order_temperature, 2006); + EXPECT_NEAR(res.temperature_float, 20.06f, 1e-3f); + } + + // Just Below Crossover (19.99°C) + { + uint32_t const d2_raw_temperature = reference_temperature - 2000; + auto res = compensated_temperature(d2_raw_temperature, calibration_values); + EXPECT_EQ(res.d_t, -2000); + EXPECT_EQ(res.first_order_temperature, 1993); + EXPECT_NEAR(res.temperature_float, 19.93f, 1e-3f); + } + + // Maximum Specified Temperature Before 2nd Order (+84.32°C) + { + uint32_t const d2_raw_temperature = 10013516; + auto res = compensated_temperature(d2_raw_temperature, calibration_values); + EXPECT_EQ(res.d_t, 1935948); + EXPECT_EQ(res.first_order_temperature, 8500); + EXPECT_NEAR(res.temperature_float, 84.32f, 1e-3f); + } + + // Maximum Specified Temperature (+85°C) + { + uint32_t const d2_raw_temperature = 10034215; + auto res = compensated_temperature(d2_raw_temperature, calibration_values); + EXPECT_EQ(res.d_t, 1956647); + EXPECT_EQ(res.first_order_temperature, 8569); + EXPECT_NEAR(res.temperature_float, 85.00f, 1e-3f); + } + + // Minimum Specified Temperature (-40°C) + { + uint32_t const d2_raw_temperature = 6537387; + auto res = compensated_temperature(d2_raw_temperature, calibration_values); + EXPECT_EQ(res.d_t, -1540181); + EXPECT_EQ(res.first_order_temperature, -3172); + EXPECT_NEAR(res.temperature_float, -40.00f, 1e-3f); + } + + // Minimum Specified Temperature Before 2nd Order (-51.15°C) + // Note: I suspect this is out of range + { + uint32_t const d2_raw_temperature = 6290732; + auto res = compensated_temperature(d2_raw_temperature, calibration_values); + EXPECT_EQ(res.d_t, -1786836); + EXPECT_EQ(res.first_order_temperature, -4000); + EXPECT_NEAR(res.temperature_float, -51.15f, 1e-3f); + } +} + +float test_pressure_helper(const uint32_t d1, const uint32_t d2, const CalibrationValues &calibration_values) { + auto temp_res = compensated_temperature(d2, calibration_values); + return compensated_pressure(d1, calibration_values, temp_res); +} + +TEST(MS8607Test, Category2PressureCompensationMath) { + auto calibration_values = create_calibration_values(); + + // Standard Mid-Scale Barometric Pressure (~1013 mbar @ 20°C) + { + float const pressure = + test_pressure_helper(6268671, calibration_values.reference_temperature << 8, calibration_values); + EXPECT_NEAR(pressure, 1013.00f, 1e-2f); + } + + // Lowest Pressure at Reference Temp (10 mbar @ 20°C) + { + float const pressure = + test_pressure_helper(4000671, calibration_values.reference_temperature << 8, calibration_values); + EXPECT_NEAR(pressure, 10.00f, 1e-2f); + } + + // Highest Pressure at Reference Temp (1200 mbar @ 20°C) + { + float const pressure = + test_pressure_helper(6691519, calibration_values.reference_temperature << 8, calibration_values); + EXPECT_NEAR(pressure, 1200.00f, 1e-2f); + } + + // Top of the data sheet range at Reference Temp (2000 mbar @ 20°C) + { + float const pressure = + test_pressure_helper(8500494, calibration_values.reference_temperature << 8, calibration_values); + EXPECT_NEAR(pressure, 2000.00f, 1e-2f); + } + + // Lowest Pressure at Lowest Temperature (10 mbar @ -40°C) + { + float const pressure = test_pressure_helper(3991039, 6537387, calibration_values); + EXPECT_NEAR(pressure, 10.00f, 1e-2f); + } + + // Sea Level Pressure at Lowest Temperature (1013 mbar @ -40°C) + { + float const pressure = test_pressure_helper(6626079, 6537387, calibration_values); + EXPECT_NEAR(pressure, 1013.00f, 1e-2f); + } + + // Highest Pressure at Lowest Temperature (1200 mbar @ -40°C) + { + float const pressure = test_pressure_helper(7117343, 6537387, calibration_values); + EXPECT_NEAR(pressure, 1200.00f, 1e-2f); + } + + // Lowest Pressure at Highest Temperature (10 mbar @ +85°C) + { + float const pressure = test_pressure_helper(4002959, 10034215, calibration_values); + EXPECT_NEAR(pressure, 10.00f, 1e-2f); + } + + // Sea Level Pressure at Highest Temperature (1013 mbar @ 85°C) + { + float const pressure = test_pressure_helper(5981727, 10034215, calibration_values); + EXPECT_NEAR(pressure, 1013.00f, 1e-2f); + } + + // Highest Pressure at Highest Temperature (1200 mbar @ +85°C) + { + float const pressure = test_pressure_helper(6350655, 10034215, calibration_values); + EXPECT_NEAR(pressure, 1200.00f, 1e-2f); + } + + // Lowest Pressure at Below Reference Temperature (10 mbar @ +0°C) + { + float const pressure = test_pressure_helper(3998959, 7514695, calibration_values); + EXPECT_NEAR(pressure, 10.00f, 1e-2f); + } + + // Sea Level Pressure at Below Reference Temperature (1013 mbar @ +0°C) + { + float const pressure = test_pressure_helper(6371807, 7514695, calibration_values); + EXPECT_NEAR(pressure, 1013.00f, 1e-2f); + } + + // Highest Pressure at at Below Reference Temperature (1200 mbar @ +0°C) + { + float const pressure = test_pressure_helper(6814191, 7514695, calibration_values); + EXPECT_NEAR(pressure, 1200.00f, 1e-2f); + } +} + +TEST(MS8607Test, Category3HumidityTemperatureCompensation) { + // Datasheet example: 0x7C80 (i.e. 31872) @ 20°C is 54.80% + { + float const humidity = compensated_humidity(0x7C80, 20.0f); + EXPECT_NEAR(humidity, 54.80f, 1e-2f); + } + + // Datasheet example at lower temperature (0x7C80 @ 0°C) + { + float const humidity = compensated_humidity(0x7C80, 0.0f); + EXPECT_NEAR(humidity, 54.80f - 3.6f, 1e-2f); + } + + // Datasheet example at higher temperature (0x7C80 @ 40°C) + { + float const humidity = compensated_humidity(0x7C80, 40.0f); + EXPECT_NEAR(humidity, 54.80f + 3.6f, 1e-2f); + } + + // Lowest humidity, without temperature compensation + { + float const humidity = compensated_humidity(0, 20.0f); + EXPECT_NEAR(humidity, -6.0f, 1e-2f); + } + + // Highest humidity, without temperature compensation + { + // bottom two bits are status bits, and are stripped by the caller + float const humidity = compensated_humidity(0xFFFC, 20.0f); + EXPECT_NEAR(humidity, 118.99f, 1e-2f); + } + + // 100% humidity at 20° + { + float const humidity = compensated_humidity(55575, 20.0f); + EXPECT_NEAR(humidity, 100.00f, 1e-2f); + } + + // Humidity Compensation at High Temperature (100% RH @ +85°C) + { + float const humidity = compensated_humidity(55575, 85.0f); + EXPECT_NEAR(humidity, 111.70f, 1e-2f); + } + + // Humidity Compensation at Extreme Low Temp (100% RH @ -40°C) + { + float const humidity = compensated_humidity(55575, -40.0f); + EXPECT_NEAR(humidity, 89.20f, 1e-2f); + } + + // Low Humidity at Extreme Low Temp (0% RH @ -40°C) + { + float const humidity = compensated_humidity(0, -40.0f); + EXPECT_NEAR(humidity, -16.80f, 1e-2f); + } +} + +TEST(MS8607Test, Category4MathematicalEdgeCases) { + auto calibration_values = create_calibration_values(); + + // Minimum value accepted by read_* methods + { + // these values are below what the data sheet accepts, but should avoid Undefined Behavior + auto temp_res = compensated_temperature(6315741, calibration_values); + float const pressure = compensated_pressure(3961087, calibration_values, temp_res); + float const humidity = compensated_humidity(0, temp_res.temperature_float); + + EXPECT_NEAR(temp_res.temperature_float, -50.0f, 1e-2f); + EXPECT_NEAR(pressure, 0.0f, 1e-2f); + EXPECT_NEAR(humidity, -18.6f, 1e-2f); + + // Check humidity CRC, if we got all zeros on that read. The Humidity read should never be all + // zeros, but if it is, this shows that the CRC would pass and the component should bail + uint8_t bytes[3]; + bytes[0] = bytes[1] = bytes[2] = 0; + uint8_t const actual_crc = crc8(bytes, 2, 0, 0x31, true); + EXPECT_EQ(actual_crc, bytes[2]); + } + + // Maximum values accepted by read_* methods (100°C and 2000 mbar, the PRESSURE_UPPER_LIMIT) + { + // these values are above what the data sheet accepts, but should avoid Undefined Behavior + auto temp_res = compensated_temperature(10491904, calibration_values); + float const pressure = compensated_pressure(7815674, calibration_values, temp_res); + float const hum = compensated_humidity(0xFFFC, temp_res.temperature_float); + + EXPECT_NEAR(temp_res.temperature_float, 100.0f, 1e-2f); + EXPECT_NEAR(pressure, 2000.0f, 1e-2f); + EXPECT_NEAR(hum, 133.39f, 1e-2f); + } + + // Boundary of temperature values that avoid signed 32-bit integer overflow in compensated_pressure(). + // When first_order_temperature (aka T) < -1500, second order compensation's biggest risk of overflow is: + // pressure_offset_2 = 61 * ((T - 2000)^2 >> 4) + 17 * (T + 1500)^2 + // With unconstrained raw D2 inputs, first_order_temperature can reach ~ -129070, overflowing int32_t. + // The mathematical lower limit before int32_t overflow (pressure_offset_2 <= INT32_MAX) is T = -10926 (-109.26°C). + // In normal operation, read_temperature_() guards this by checking temperature_float against TEMPERATURE_LOWER_LIMIT + // (-50°C). + { + // 4227713 will trigger overflow, 4227714 doesn't + auto temperature_boundary = compensated_temperature(4227714, calibration_values); + + EXPECT_EQ(temperature_boundary.first_order_temperature, -10926) + << "This specific value is the smallest acceptable value before compensated_pressure triggers overflow"; + + // Here's the condensed version of the calculation that gets closest to overflow + { + const int32_t t = temperature_boundary.first_order_temperature; + const int32_t pressure_offset_2 = 61 * (((t - 2000) * (t - 2000)) >> 4) + 17 * (t + 1500) * (t + 1500); + EXPECT_EQ(pressure_offset_2, 2'147'439'204) << "Just below 2 ^ 31"; + } + + // calculated pressure is arbitrary, but sending in UINT32_MAX (an otherwise invalid value) to show (via UBSan) + // that it's safe at this temp + float const pressure = compensated_pressure(UINT32_MAX, calibration_values, temperature_boundary); + EXPECT_NEAR(pressure, 663499.f, 1e2f); + } +} + +} // namespace esphome::ms8607::testing diff --git a/tests/components/ms8607/test_ms8607_lifecycle.cpp b/tests/components/ms8607/test_ms8607_lifecycle.cpp new file mode 100644 index 00000000000..37c43ccc978 --- /dev/null +++ b/tests/components/ms8607/test_ms8607_lifecycle.cpp @@ -0,0 +1,241 @@ +#ifdef USE_HOST + +#include + +#include +#include +#include +#include +#include "esphome/components/host/preferences.h" +#include "esphome/components/ms8607/ms8607.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::ms8607::testing { + +static constexpr uint8_t PT_ADDRESS = 0x76; +static constexpr uint8_t H_ADDRESS = 0x40; +/// Marks a raw read (no command byte) in the transaction log and in fail_command +static constexpr int RAW_READ = -1; + +// Datasheet example: C1..C6, then D1 and D2 give 20.00 °C and 1100.02 mbar +static constexpr std::array CALIBRATION{46372, 43981, 29059, 27842, 31553, 28165}; +static constexpr uint32_t D1_PRESSURE = 6465444; +static constexpr uint32_t D2_TEMPERATURE = 8077636; +static constexpr uint16_t HUMIDITY_RAW = 0x7946; // status bit 1 set + +/// Datasheet CRC-4 over the PROM words, with the CRC nibble of word 0 cleared +static uint8_t prom_crc(const std::array &prom) { + uint16_t remainder = 0; + auto apply = [&remainder](uint8_t byte) { + remainder ^= byte; + for (int bit = 0; bit < 8; ++bit) + remainder = (remainder & 0x8000) ? (remainder << 1) ^ 0x3000 : remainder << 1; + }; + for (uint16_t word : prom) { + apply(word >> 8); + apply(word & 0xFF); + } + apply(0); + apply(0); + return remainder >> 12; +} + +class MS8607Bus final : public i2c::I2CBus { + public: + std::array prom{}; + uint32_t d1{D1_PRESSURE}; + uint32_t d2{D2_TEMPERATURE}; + uint16_t humidity{HUMIDITY_RAW}; + /// (address, command) pair that answers with a NACK + std::pair fail{0, 0}; + std::vector> log; + + MS8607Bus() { + std::copy(CALIBRATION.begin(), CALIBRATION.end(), this->prom.begin() + 1); + this->prom[0] = 0x0123 | (prom_crc(this->prom) << 12); + } + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + const int command = size != 0 ? data[0] : RAW_READ; + this->log.emplace_back(address, command); + if (std::pair{address, command} == this->fail) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (address == PT_ADDRESS) { + if (command == 0x4A || command == 0x5A) { + this->converted_ = command == 0x4A ? this->d1 : this->d2; + } else if (command >= 0xA0 && command <= 0xAC && out_size == 2) { + const uint16_t word = this->prom[(command - 0xA0) / 2]; + out[0] = word >> 8; + out[1] = word; + } else if (command == 0x00 && out_size == 3) { + out[0] = this->converted_ >> 16; + out[1] = this->converted_ >> 8; + out[2] = this->converted_; + } else if (command != 0x1E) { + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + return i2c::ERROR_OK; + } + if (address == H_ADDRESS) { + if (command == RAW_READ && out_size == 3) { + out[0] = this->humidity >> 8; + out[1] = this->humidity; + out[2] = crc8(out, 2, 0, 0x31, true); + } else if (command != 0xFE && command != 0xF5) { + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + return i2c::ERROR_OK; + } + return i2c::ERROR_NOT_ACKNOWLEDGED; + } + + bool sent(uint8_t address, int command = RAW_READ, bool any_command = true) const { + for (const auto &[logged_address, logged_command] : this->log) { + if (logged_address == address && (any_command || logged_command == command)) + return true; + } + return false; + } + bool sent_command(uint8_t address, int command) const { return this->sent(address, command, false); } + + protected: + uint32_t converted_{0}; +}; + +class MS8607LifecycleTest : public ::testing::Test { + protected: + MS8607Bus bus_; + MS8607Component chip_; + MS8607HumidityDevice humidity_device_; + sensor::Sensor temperature_; + sensor::Sensor pressure_; + sensor::Sensor humidity_; + + void SetUp() override { + new (&App) Application; + App.pre_setup("ms8607-lifecycle-tests", 22, "MS8607 tests", 12); + host::setup_preferences(); + this->chip_.set_i2c_bus(&this->bus_); + this->chip_.set_i2c_address(PT_ADDRESS); + this->humidity_device_.set_i2c_bus(&this->bus_); + this->humidity_device_.set_i2c_address(H_ADDRESS); + } + + void TearDown() override { + for (uint32_t id = 0; id < 5; ++id) + App.scheduler.cancel_timeout(&this->chip_, id); + App.scheduler.call(millis()); + App.~Application(); + } + + static void run_timers(uint32_t duration) { + App.scheduler.call(millis()); + delay(duration); + App.scheduler.call(millis()); + } + + void use_humidity() { + this->chip_.set_humidity_sensor(&this->humidity_); + this->chip_.set_humidity_device(&this->humidity_device_); + } + + /// Reset, then the PROM read after its 15 ms delay + void start() { + this->chip_.setup(); + this->chip_.dump_config(); + run_timers(20); + ASSERT_FALSE(this->chip_.is_failed()); + } + + /// One update; each conversion step waits 20 ms + void cycle() { + this->bus_.log.clear(); + this->chip_.update(); + for (int step = 0; step < 4; ++step) + run_timers(25); + } +}; + +TEST_F(MS8607LifecycleTest, TemperatureOnly) { + this->chip_.set_temperature_sensor(&this->temperature_); + this->start(); + this->cycle(); + ASSERT_TRUE(this->temperature_.has_state()); + EXPECT_NEAR(this->temperature_.state, 20.0f, 0.01f); + EXPECT_FALSE(this->bus_.sent(H_ADDRESS)); + EXPECT_FALSE(this->bus_.sent_command(PT_ADDRESS, 0x4A)); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(MS8607LifecycleTest, PressureOnly) { + this->chip_.set_pressure_sensor(&this->pressure_); + this->start(); + this->cycle(); + EXPECT_TRUE(this->bus_.sent_command(PT_ADDRESS, 0x5A)); + ASSERT_TRUE(this->pressure_.has_state()); + EXPECT_NEAR(this->pressure_.state, 1100.02f, 0.01f); + EXPECT_FALSE(this->temperature_.has_state()); + EXPECT_FALSE(this->bus_.sent(H_ADDRESS)); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(MS8607LifecycleTest, HumidityOnly) { + this->use_humidity(); + this->start(); + this->cycle(); + EXPECT_TRUE(this->bus_.sent_command(PT_ADDRESS, 0x5A)); + EXPECT_FALSE(this->bus_.sent_command(PT_ADDRESS, 0x4A)); + EXPECT_TRUE(this->bus_.sent_command(H_ADDRESS, 0xF5)); + EXPECT_TRUE(this->bus_.sent_command(H_ADDRESS, RAW_READ)); + ASSERT_TRUE(this->humidity_.has_state()); + EXPECT_FLOAT_EQ(this->humidity_.state, compensated_humidity(HUMIDITY_RAW & ~0b11, 20.0f)); + EXPECT_FALSE(this->temperature_.has_state()); + EXPECT_FALSE(this->pressure_.has_state()); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(MS8607LifecycleTest, FailedPressureKeepsWarningUntilCleanCycle) { + this->chip_.set_temperature_sensor(&this->temperature_); + this->chip_.set_pressure_sensor(&this->pressure_); + this->use_humidity(); + this->start(); + this->bus_.d1 = 0; // an unfinished conversion reads as zeros + this->cycle(); + EXPECT_FALSE(this->pressure_.has_state()); + EXPECT_TRUE(this->humidity_.has_state()); + EXPECT_TRUE(this->chip_.status_has_warning()); + + this->bus_.d1 = D1_PRESSURE; + this->cycle(); + EXPECT_TRUE(this->pressure_.has_state()); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +// Only the humidity address NACKs, so the reason recorded is the humidity one. The host test build has no +// logger, so the reason text itself cannot be read back here. +TEST_F(MS8607LifecycleTest, HumidityResetFailureMarksFailedAfterRetries) { + this->use_humidity(); + this->bus_.fail = {H_ADDRESS, 0xFE}; + this->chip_.setup(); + run_timers(10); + EXPECT_FALSE(this->chip_.is_failed()); + run_timers(30); + EXPECT_TRUE(this->chip_.is_failed()); + EXPECT_TRUE(this->chip_.status_has_error()); + int pt_resets = 0; + int h_resets = 0; + for (const auto &entry : this->bus_.log) { + pt_resets += entry == std::pair{PT_ADDRESS, 0x1E}; + h_resets += entry == std::pair{H_ADDRESS, 0xFE}; + } + EXPECT_EQ(pt_resets, 3); + EXPECT_EQ(h_resets, 3); + EXPECT_FALSE(this->bus_.sent_command(PT_ADDRESS, 0xA0)); +} + +} // namespace esphome::ms8607::testing + +#endif From c81754be7811b748d64753f48145efce54382717 Mon Sep 17 00:00:00 2001 From: Greg Cormier Date: Sat, 31 Oct 2026 16:01:44 -0400 Subject: [PATCH 023/170] [xgzp68xx] Add XGZP6899DC variant (#21779) Co-authored-by: J. Nick Koston --- esphome/components/xgzp68xx/sensor.py | 150 ++++++++++++++---- esphome/components/xgzp68xx/xgzp6899dc.cpp | 103 ++++++++++++ esphome/components/xgzp68xx/xgzp6899dc.h | 39 +++++ tests/component_tests/xgzp68xx/__init__.py | 1 + .../xgzp68xx/config/legacy.yaml | 19 +++ .../config/legacy_pressure_range.yaml | 17 ++ .../xgzp68xx/config/xgzp6899dc.yaml | 21 +++ .../config/xgzp6899dc_inverted_range.yaml | 18 +++ .../xgzp68xx/config/xgzp6899dc_k_value.yaml | 19 +++ .../config/xgzp6899dc_missing_range.yaml | 16 ++ .../config/xgzp6899dc_no_oversampling.yaml | 18 +++ .../config/xgzp6899dc_temperature_only.yaml | 16 ++ tests/component_tests/xgzp68xx/test_config.py | 83 ++++++++++ tests/components/xgzp68xx/common.h | 94 +++++++++++ tests/components/xgzp68xx/common.yaml | 10 ++ .../xgzp68xx/sensor/xgzp6899dc_test.cpp | 124 +++++++++++++++ tests/components/xgzp68xx/test.esp32-idf.yaml | 3 +- .../components/xgzp68xx/test.esp8266-ard.yaml | 3 +- .../components/xgzp68xx/test.rp2040-ard.yaml | 3 +- 19 files changed, 721 insertions(+), 36 deletions(-) create mode 100644 esphome/components/xgzp68xx/xgzp6899dc.cpp create mode 100644 esphome/components/xgzp68xx/xgzp6899dc.h create mode 100644 tests/component_tests/xgzp68xx/__init__.py create mode 100644 tests/component_tests/xgzp68xx/config/legacy.yaml create mode 100644 tests/component_tests/xgzp68xx/config/legacy_pressure_range.yaml create mode 100644 tests/component_tests/xgzp68xx/config/xgzp6899dc.yaml create mode 100644 tests/component_tests/xgzp68xx/config/xgzp6899dc_inverted_range.yaml create mode 100644 tests/component_tests/xgzp68xx/config/xgzp6899dc_k_value.yaml create mode 100644 tests/component_tests/xgzp68xx/config/xgzp6899dc_missing_range.yaml create mode 100644 tests/component_tests/xgzp68xx/config/xgzp6899dc_no_oversampling.yaml create mode 100644 tests/component_tests/xgzp68xx/config/xgzp6899dc_temperature_only.yaml create mode 100644 tests/component_tests/xgzp68xx/test_config.py create mode 100644 tests/components/xgzp68xx/common.h create mode 100644 tests/components/xgzp68xx/sensor/xgzp6899dc_test.cpp diff --git a/esphome/components/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index 778fb48647f..077d8f19066 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -5,6 +5,7 @@ from esphome.const import ( CONF_OVERSAMPLING, CONF_PRESSURE, CONF_TEMPERATURE, + CONF_VARIANT, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, @@ -17,6 +18,11 @@ DEPENDENCIES = ["i2c"] CODEOWNERS = ["@gcormier"] CONF_K_VALUE = "k_value" +CONF_PRESSURE_MAX = "pressure_max" +CONF_PRESSURE_MIN = "pressure_min" + +VARIANT_XGZP68XX = "xgzp68xx" +VARIANT_XGZP6899DC = "xgzp6899dc" xgzp68xx_ns = cg.esphome_ns.namespace("xgzp68xx") XGZP68XXOversampling = xgzp68xx_ns.enum("XGZP68XXOversampling") @@ -31,48 +37,132 @@ OVERSAMPLING_OPTIONS = XGZP68XXOversampling.options( "32768X", prefix="XGZP68XX_OVERSAMPLING_", ) +# The XGZP6899DC CFG_OSR register codes run in order from 256X (0) to 32768X (7) +XGZP6899DC_OVERSAMPLING_OPTIONS = { + "256X": 0, + "512X": 1, + "1024X": 2, + "2048X": 3, + "4096X": 4, + "8192X": 5, + "16384X": 6, + "32768X": 7, +} XGZP68XXComponent = xgzp68xx_ns.class_( "XGZP68XXComponent", cg.PollingComponent, i2c.I2CDevice ) +XGZP6899DCComponent = xgzp68xx_ns.class_( + "XGZP6899DCComponent", cg.PollingComponent, i2c.I2CDevice +) -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(XGZP68XXComponent), - cv.Optional(CONF_PRESSURE): sensor.sensor_schema( - unit_of_measurement=UNIT_PASCAL, - accuracy_decimals=1, - device_class=DEVICE_CLASS_PRESSURE, - state_class=STATE_CLASS_MEASUREMENT, - ).extend( - { - cv.Optional(CONF_OVERSAMPLING, default="4096X"): cv.enum( - OVERSAMPLING_OPTIONS, upper=True - ), - } - ), - cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - accuracy_decimals=1, - device_class=DEVICE_CLASS_TEMPERATURE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional(CONF_K_VALUE, default=4096): cv.int_range(min=1, max=65535), - } +pressure_pa = cv.float_with_unit("pressure", "(Pa)", optional_unit=True) + + +def _temperature_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, ) - .extend(cv.polling_component_schema("60s")) - .extend(i2c.i2c_device_schema(0x6D)) + + +def _pressure_schema(oversampling: cv.Optional, options: dict) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_PASCAL, + accuracy_decimals=1, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ).extend({oversampling: cv.enum(options, upper=True)}) + + +def _xgzp68xx_schema() -> cv.Schema: + return ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XGZP68XXComponent), + cv.Optional(CONF_PRESSURE): _pressure_schema( + cv.Optional(CONF_OVERSAMPLING, default="4096X"), + OVERSAMPLING_OPTIONS, + ), + cv.Optional(CONF_TEMPERATURE): _temperature_schema(), + cv.Optional(CONF_K_VALUE, default=4096): cv.int_range(min=1, max=65535), + # The likely mistake is a 6899DC config without its variant line + **{ + cv.Optional(key): cv.invalid( + f"'{key}' is only used with 'variant: {VARIANT_XGZP6899DC}'" + ) + for key in (CONF_PRESSURE_MIN, CONF_PRESSURE_MAX) + }, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x6D)) + ) + + +def _validate_pressure_range(config: ConfigType) -> ConfigType: + # The range only scales the pressure reading, so a temperature-only config can leave it out + if CONF_PRESSURE not in config: + return config + for key in (CONF_PRESSURE_MIN, CONF_PRESSURE_MAX): + if key not in config: + raise cv.Invalid(f"'{key}' is required with a pressure sensor", path=[key]) + if config[CONF_PRESSURE_MAX] <= config[CONF_PRESSURE_MIN]: + raise cv.Invalid( + f"'{CONF_PRESSURE_MAX}' must be greater than '{CONF_PRESSURE_MIN}'" + ) + return config + + +def _xgzp6899dc_schema() -> cv.All: + return cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(XGZP6899DCComponent), + # No default: the factory oversampling setting is kept unless one is given + cv.Optional(CONF_PRESSURE): _pressure_schema( + cv.Optional(CONF_OVERSAMPLING), XGZP6899DC_OVERSAMPLING_OPTIONS + ), + cv.Optional(CONF_TEMPERATURE): _temperature_schema(), + cv.Optional(CONF_PRESSURE_MIN): pressure_pa, + cv.Optional(CONF_PRESSURE_MAX): pressure_pa, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x58)), + _validate_pressure_range, + ) + + +CONFIG_SCHEMA = cv.typed_schema( + { + VARIANT_XGZP68XX: _xgzp68xx_schema(), + VARIANT_XGZP6899DC: _xgzp6899dc_schema(), + }, + key=CONF_VARIANT, + default_type=VARIANT_XGZP68XX, + lower=True, ) async def to_code(config: ConfigType) -> None: - var = await i2c.new_i2c_component(config) + # The XGZP6899DC takes the rated span of the part in Pa, unused without a pressure sensor + args = [] + if config[CONF_VARIANT] == VARIANT_XGZP6899DC: + args.append( + config[CONF_PRESSURE_MAX] - config[CONF_PRESSURE_MIN] + if CONF_PRESSURE in config + else 0.0 + ) + var = await i2c.new_i2c_component(config, *args) sensors = sensor.sub_sensors(config) await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) if await sensors(CONF_PRESSURE, var.set_pressure_sensor): - oversampling = config[CONF_PRESSURE][CONF_OVERSAMPLING] - cg.add(var.set_pressure_oversampling(oversampling)) + cg.add_optional( + var.set_pressure_oversampling, config[CONF_PRESSURE].get(CONF_OVERSAMPLING) + ) - cg.add(var.set_k_value(config[CONF_K_VALUE])) + cg.add_optional(var.set_k_value, config.get(CONF_K_VALUE)) diff --git a/esphome/components/xgzp68xx/xgzp6899dc.cpp b/esphome/components/xgzp68xx/xgzp6899dc.cpp new file mode 100644 index 00000000000..9180b2791c2 --- /dev/null +++ b/esphome/components/xgzp68xx/xgzp6899dc.cpp @@ -0,0 +1,103 @@ +#include "xgzp6899dc.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::xgzp68xx { + +ESPHOME_LOG_TAG(TAG, "xgzp6899dc.sensor"); + +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; + +static constexpr uint8_t ID_REGISTER = 0x00; +static constexpr uint8_t CHIP_CONTROL_REGISTER = 0x01; +static constexpr uint8_t CFG_OSR_REGISTER = 0x02; +static constexpr uint8_t DATA_REGISTER = 0x04; // 3 pressure bytes then 2 temperature bytes, MSB first +static constexpr uint8_t CAL_OFFSET_REGISTER = 0x20; +static constexpr uint8_t CAL_SHIFT_REGISTER = 0x21; + +static constexpr uint8_t START_MEASUREMENT = 0x01; +static constexpr uint8_t CFG_OSR_P_MASK = 0x1C; +static constexpr uint8_t CFG_OSR_MODE_MASK = 0x03; +static constexpr uint8_t CFG_OSR_MODE_NORMAL = 0x01; +static constexpr uint32_t MEASUREMENT_TIME_MS = 20; +// Pressure (Pa) = signed 24-bit raw / 2^21 * (PMAX - PMIN) +static constexpr float PRESSURE_FULL_SCALE = 2097152.0f; +static constexpr float TEMPERATURE_REFERENCE = 25.0f; + +// 2^exponent built from its bits, without pulling in libm; valid for exponents -126 to 127 +static float power_of_two(int exponent) { return bit_cast(static_cast(127 + exponent) << 23); } + +XGZP6899DCComponent::XGZP6899DCComponent(float pressure_span) : pressure_scale_(pressure_span / PRESSURE_FULL_SCALE) {} + +void XGZP6899DCComponent::setup() { + uint8_t chip_id; + uint8_t cal_offset; + uint8_t cal_shift; + if (!this->read_byte(ID_REGISTER, &chip_id) || !this->read_byte(CAL_OFFSET_REGISTER, &cal_offset) || + !this->read_byte(CAL_SHIFT_REGISTER, &cal_shift)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + // The ID register holds the I2C address of the part, so a mismatch most likely means a different sensor + if (chip_id != this->get_i2c_address()) { + ESP_LOGW(TAG, "ID register reads 0x%02X, expected 0x%02X; check the configured variant", chip_id, + this->get_i2c_address()); + } + + // Temperature = (raw - offset) / 2^shift + 25. Offset bits 6:0 are a power-of-two exponent and bit 7 is the + // sign, e.g. 0x8D = -8192; the shift is a tenth of its register + const float offset = power_of_two(cal_offset & 0x7F) * ((cal_offset & 0x80) ? -1.0f : 1.0f); + this->temperature_scale_ = power_of_two(-(cal_shift / 10)); + this->temperature_bias_ = TEMPERATURE_REFERENCE - offset * this->temperature_scale_; + ESP_LOGD(TAG, "Cal_coff 0x%02X 0x%02X (offset %.0f, shift %u)", cal_offset, cal_shift, offset, cal_shift / 10); + + if (this->pressure_oversampling_ != UINT8_MAX) { + if (!this->update_register(CFG_OSR_REGISTER, CFG_OSR_P_MASK | CFG_OSR_MODE_MASK, + (this->pressure_oversampling_ << 2) | CFG_OSR_MODE_NORMAL)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + } +} + +void XGZP6899DCComponent::update() { + if (!this->status_comm_result(this->write_byte(CHIP_CONTROL_REGISTER, START_MEASUREMENT))) + return; + this->set_timeout(MEASUREMENT_TIMEOUT_ID, MEASUREMENT_TIME_MS, [this]() { this->read_data_(); }); +} + +void XGZP6899DCComponent::read_data_() { + // The datasheet only documents single-register reads, so read each byte separately + uint8_t data[5]; + for (size_t i = 0; i < sizeof(data); i++) { + if (!this->status_comm_result(this->read_byte(DATA_REGISTER + i, &data[i]))) + return; + } + const uint32_t pressure_raw = encode_uint24(data[0], data[1], data[2]); + const int16_t temperature_raw = static_cast(encode_uint16(data[3], data[4])); + ESP_LOGV(TAG, "Got raw pressure=%" PRIu32 ", raw temperature=%d", pressure_raw, temperature_raw); + + PUBLISH_STATE(this->pressure_sensor_, static_cast(sign_extend<24>(pressure_raw)) * this->pressure_scale_); + PUBLISH_STATE(this->temperature_sensor_, temperature_raw * this->temperature_scale_ + this->temperature_bias_); +} + +void XGZP6899DCComponent::dump_config() { + ESP_LOGCONFIG(TAG, "XGZP6899DC:"); + LOG_SENSOR(" ", "Temperature: ", this->temperature_sensor_); + LOG_SENSOR(" ", "Pressure: ", this->pressure_sensor_); + if (this->pressure_sensor_ != nullptr) { + ESP_LOGCONFIG(TAG, " Span: %.0f Pa", this->pressure_scale_ * PRESSURE_FULL_SCALE); + if (this->pressure_oversampling_ == UINT8_MAX) { + ESP_LOGCONFIG(TAG, " Oversampling: factory setting"); + } else { + ESP_LOGCONFIG(TAG, " Oversampling: %" PRIu32 "x", UINT32_C(256) << this->pressure_oversampling_); + } + } + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::xgzp68xx diff --git a/esphome/components/xgzp68xx/xgzp6899dc.h b/esphome/components/xgzp68xx/xgzp6899dc.h new file mode 100644 index 00000000000..9d1a3a500b5 --- /dev/null +++ b/esphome/components/xgzp68xx/xgzp6899dc.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::xgzp68xx { + +/// XGZP6899DC: the XGZP6899D revision at I2C address 0x58 (datasheet V3.2), with a new register map. +class XGZP6899DCComponent final : public PollingComponent, public i2c::I2CDevice { + public: + /// @param pressure_span Rated range of the part in Pa (PMAX - PMIN), for example 1000 for a +/-500 Pa part. + explicit XGZP6899DCComponent(float pressure_span); + + SUB_SENSOR(temperature) + SUB_SENSOR(pressure) + /// Set the CFG_OSR pressure oversampling code (0 = 256X ... 7 = 32768X). Unset keeps the factory value. + void set_pressure_oversampling(uint8_t pressure_oversampling) { + this->pressure_oversampling_ = pressure_oversampling; + } + + void update() override; + void setup() override; + void dump_config() override; + + protected: + void read_data_(); + + // Pa per raw count + float pressure_scale_; + // Temperature = raw * temperature_scale_ + temperature_bias_, from the calibration registers + float temperature_scale_{0.0f}; + float temperature_bias_{0.0f}; + uint8_t pressure_oversampling_{UINT8_MAX}; +}; + +} // namespace esphome::xgzp68xx diff --git a/tests/component_tests/xgzp68xx/__init__.py b/tests/component_tests/xgzp68xx/__init__.py new file mode 100644 index 00000000000..9d24f99fd3e --- /dev/null +++ b/tests/component_tests/xgzp68xx/__init__.py @@ -0,0 +1 @@ +"""XGZP68xx regression tests.""" diff --git a/tests/component_tests/xgzp68xx/config/legacy.yaml b/tests/component_tests/xgzp68xx/config/legacy.yaml new file mode 100644 index 00000000000..95c95eb97a2 --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/legacy.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + k_value: 4096 + temperature: + name: Temperature + pressure: + name: Pressure + oversampling: 1024x diff --git a/tests/component_tests/xgzp68xx/config/legacy_pressure_range.yaml b/tests/component_tests/xgzp68xx/config/legacy_pressure_range.yaml new file mode 100644 index 00000000000..abc2b93c51d --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/legacy_pressure_range.yaml @@ -0,0 +1,17 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + pressure_min: -500 + pressure_max: 500 + pressure: + name: Pressure diff --git a/tests/component_tests/xgzp68xx/config/xgzp6899dc.yaml b/tests/component_tests/xgzp68xx/config/xgzp6899dc.yaml new file mode 100644 index 00000000000..0d23bb6adc2 --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/xgzp6899dc.yaml @@ -0,0 +1,21 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + variant: xgzp6899dc + pressure_min: -500 + pressure_max: 500Pa + temperature: + name: Temperature + pressure: + name: Pressure + oversampling: 8192x diff --git a/tests/component_tests/xgzp68xx/config/xgzp6899dc_inverted_range.yaml b/tests/component_tests/xgzp68xx/config/xgzp6899dc_inverted_range.yaml new file mode 100644 index 00000000000..822b87742e0 --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/xgzp6899dc_inverted_range.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + variant: xgzp6899dc + pressure_min: 500 + pressure_max: -500 + pressure: + name: Pressure diff --git a/tests/component_tests/xgzp68xx/config/xgzp6899dc_k_value.yaml b/tests/component_tests/xgzp68xx/config/xgzp6899dc_k_value.yaml new file mode 100644 index 00000000000..671f9ade75c --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/xgzp6899dc_k_value.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + variant: xgzp6899dc + pressure_min: -500 + pressure_max: 500 + k_value: 4096 + pressure: + name: Pressure diff --git a/tests/component_tests/xgzp68xx/config/xgzp6899dc_missing_range.yaml b/tests/component_tests/xgzp68xx/config/xgzp6899dc_missing_range.yaml new file mode 100644 index 00000000000..63eede29c34 --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/xgzp6899dc_missing_range.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + variant: xgzp6899dc + pressure: + name: Pressure diff --git a/tests/component_tests/xgzp68xx/config/xgzp6899dc_no_oversampling.yaml b/tests/component_tests/xgzp68xx/config/xgzp6899dc_no_oversampling.yaml new file mode 100644 index 00000000000..2c333ea5c8b --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/xgzp6899dc_no_oversampling.yaml @@ -0,0 +1,18 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + variant: XGZP6899DC + pressure_min: -0.5kPa + pressure_max: 0.5kPa + pressure: + name: Pressure diff --git a/tests/component_tests/xgzp68xx/config/xgzp6899dc_temperature_only.yaml b/tests/component_tests/xgzp68xx/config/xgzp6899dc_temperature_only.yaml new file mode 100644 index 00000000000..0d0ff48762b --- /dev/null +++ b/tests/component_tests/xgzp68xx/config/xgzp6899dc_temperature_only.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: xgzp68xx-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: xgzp68xx + variant: xgzp6899dc + temperature: + name: Temperature diff --git a/tests/component_tests/xgzp68xx/test_config.py b/tests/component_tests/xgzp68xx/test_config.py new file mode 100644 index 00000000000..f533b45ec03 --- /dev/null +++ b/tests/component_tests/xgzp68xx/test_config.py @@ -0,0 +1,83 @@ +"""Exercise XGZP68xx variant selection, validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_legacy_variant_is_default( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("legacy.yaml")) + assert ") xgzp68xx::XGZP68XXComponent()" in source + assert "set_i2c_address(0x6D)" in source + assert "set_pressure_oversampling(xgzp68xx::XGZP68XX_OVERSAMPLING_1024X)" in source + assert "set_k_value(4096)" in source + assert "XGZP6899DC" not in source + + +def test_xgzp6899dc_variant( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("xgzp6899dc.yaml")) + assert ") xgzp68xx::XGZP6899DCComponent(1000.0f)" in source + assert "set_i2c_address(0x58)" in source + # 8192X is CFG_OSR code 0b101 + assert "set_pressure_oversampling(5)" in source + assert "set_temperature_sensor" in source + assert "set_k_value" not in source + + +def test_xgzp6899dc_keeps_factory_oversampling( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("xgzp6899dc_no_oversampling.yaml")) + assert ") xgzp68xx::XGZP6899DCComponent(1000.0f)" in source + assert "set_pressure_oversampling" not in source + + +@pytest.mark.parametrize( + ("config_file", "message"), + [ + ("xgzp6899dc_k_value.yaml", "[k_value] is an invalid option"), + ( + "xgzp6899dc_missing_range.yaml", + "'pressure_min' is required with a pressure sensor", + ), + ( + "xgzp6899dc_inverted_range.yaml", + "'pressure_max' must be greater than 'pressure_min'", + ), + ( + "legacy_pressure_range.yaml", + "'pressure_min' is only used with 'variant: xgzp6899dc'", + ), + ], +) +def test_invalid_configs_are_rejected( + config_file: str, + message: str, + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path(config_file) + assert read_config({}) is None + assert message in capsys.readouterr().out + + +def test_xgzp6899dc_temperature_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("xgzp6899dc_temperature_only.yaml")) + assert ") xgzp68xx::XGZP6899DCComponent(0.0f)" in source + assert "set_temperature_sensor" in source + assert "set_pressure_sensor" not in source + assert "set_pressure_oversampling" not in source diff --git a/tests/components/xgzp68xx/common.h b/tests/components/xgzp68xx/common.h new file mode 100644 index 00000000000..e196e6b23d9 --- /dev/null +++ b/tests/components/xgzp68xx/common.h @@ -0,0 +1,94 @@ +#pragma once + +#include + +#include +#include +#include + +#include "esphome/components/i2c/i2c_bus.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/xgzp68xx/xgzp6899dc.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::xgzp68xx::testing { + +static constexpr uint8_t ADDRESS = 0x58; + +// One I2C transaction: a single register write (value set) or a single register read. +struct Transaction { + uint8_t reg; + bool is_write; + uint8_t value; + bool operator==(const Transaction &other) const = default; +}; + +inline Transaction read_of(uint8_t reg) { return {reg, false, 0}; } +inline Transaction write_of(uint8_t reg, uint8_t value) { return {reg, true, value}; } + +// The sensor's 8-bit register file. +class RegisterBus final : public i2c::I2CBus { + public: + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + EXPECT_EQ(address, ADDRESS); + if (this->failing[data[0]] || (size == 2 && this->fail_writes)) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (size == 2) { + this->registers[data[0]] = data[1]; + this->transactions.push_back(write_of(data[0], data[1])); + } + if (out_size > 0) { + // The driver only reads one register at a time + EXPECT_EQ(out_size, 1u); + out[0] = this->registers[data[0]]; + this->transactions.push_back(read_of(data[0])); + } + return i2c::ERROR_OK; + } + + // Pressure and temperature raw values as the chip returns them + void set_data(int32_t pressure_raw, int16_t temperature_raw) { + this->registers[0x04] = pressure_raw >> 16; + this->registers[0x05] = pressure_raw >> 8; + this->registers[0x06] = pressure_raw; + this->registers[0x07] = static_cast(temperature_raw) >> 8; + this->registers[0x08] = temperature_raw; + } + + std::array registers{}; + std::vector transactions; + std::array failing{}; + bool fail_writes{false}; +}; + +struct Fixture { + RegisterBus bus; + sensor::Sensor pressure; + sensor::Sensor temperature; + // A -500 Pa to +500 Pa part + XGZP6899DCComponent chip{1000.0f}; + + Fixture() { + static char name[] = "test_xgzp6899dc"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->chip.set_i2c_bus(&this->bus); + this->chip.set_i2c_address(ADDRESS); + this->chip.set_pressure_sensor(&this->pressure); + this->chip.set_temperature_sensor(&this->temperature); + this->bus.registers[0x00] = ADDRESS; + // Cal_coff read from a real part: offset 4096, shift 6 + this->bus.registers[0x20] = 0x0C; + this->bus.registers[0x21] = 0x3C; + } + + void measure() { + this->chip.update(); + delay(25); + App.scheduler.call(millis()); + } +}; + +} // namespace esphome::xgzp68xx::testing diff --git a/tests/components/xgzp68xx/common.yaml b/tests/components/xgzp68xx/common.yaml index 00e51e764c6..fdf53862357 100644 --- a/tests/components/xgzp68xx/common.yaml +++ b/tests/components/xgzp68xx/common.yaml @@ -7,3 +7,13 @@ sensor: pressure: name: Differential pressure oversampling: 1024x + - platform: xgzp68xx + i2c_id: i2c_bus + variant: xgzp6899dc + pressure_min: -500 + pressure_max: 500 + temperature: + name: XGZP6899DC Temperature + pressure: + name: XGZP6899DC Differential pressure + oversampling: 8192x diff --git a/tests/components/xgzp68xx/sensor/xgzp6899dc_test.cpp b/tests/components/xgzp68xx/sensor/xgzp6899dc_test.cpp new file mode 100644 index 00000000000..3c5d5ba0da0 --- /dev/null +++ b/tests/components/xgzp68xx/sensor/xgzp6899dc_test.cpp @@ -0,0 +1,124 @@ +#include + +#include +#include + +#include "../common.h" + +namespace esphome::xgzp68xx::testing { + +TEST(XGZP6899DCTest, MatchesHardwareReading) { + Fixture f; + f.chip.setup(); + ASSERT_FALSE(f.chip.is_failed()); + // About -21.5 Pa and 22.6 C, as read at rest on hardware + f.bus.set_data(-45088, 3942); + f.measure(); + EXPECT_NEAR(f.pressure.state, -21.5f, 0.01f); + EXPECT_FLOAT_EQ(f.temperature.state, 22.59375f); + EXPECT_FALSE(f.chip.status_has_warning()); +} + +TEST(XGZP6899DCTest, TransactionsMatchVendorSequence) { + Fixture f; + f.chip.setup(); + f.measure(); + // Single-register transactions only, and CFG_OSR is never touched without an oversampling setting + EXPECT_EQ(f.bus.transactions, (std::vector{ + read_of(0x00), + read_of(0x20), + read_of(0x21), + write_of(0x01, 0x01), + read_of(0x04), + read_of(0x05), + read_of(0x06), + read_of(0x07), + read_of(0x08), + })); +} + +TEST(XGZP6899DCTest, FullScaleAndNegativeOffset) { + Fixture f; + // Datasheet example: 0x8D is -8192, 0x46 is shift 7 + f.bus.registers[0x20] = 0x8D; + f.bus.registers[0x21] = 0x46; + f.chip.setup(); + f.bus.set_data(1 << 20, -8192 + 128); + f.measure(); + // 2^20 / 2^21 * 1000 Pa + EXPECT_FLOAT_EQ(f.pressure.state, 500.0f); + EXPECT_FLOAT_EQ(f.temperature.state, 26.0f); +} + +TEST(XGZP6899DCTest, OversamplingKeepsTemperatureBitsAndSetsNormalMode) { + Fixture f; + // OSR_T 111b, OSR_P 000b, MODE 10b + f.bus.registers[0x02] = 0xE2; + f.chip.set_pressure_oversampling(5); + f.chip.setup(); + ASSERT_FALSE(f.chip.is_failed()); + EXPECT_EQ(f.bus.registers[0x02], 0xF5); +} + +TEST(XGZP6899DCTest, WrongIdOnlyWarns) { + Fixture f; + f.bus.registers[0x00] = 0x00; + f.chip.setup(); + EXPECT_FALSE(f.chip.is_failed()); +} + +TEST(XGZP6899DCTest, SilentBusAtSetupFails) { + Fixture f; + f.bus.failing.fill(true); + f.chip.setup(); + EXPECT_TRUE(f.chip.is_failed()); +} + +TEST(XGZP6899DCTest, FailedCalibrationReadFails) { + Fixture f; + f.bus.failing[0x21] = true; + f.chip.setup(); + EXPECT_TRUE(f.chip.is_failed()); +} + +TEST(XGZP6899DCTest, FailedOversamplingReadFails) { + Fixture f; + f.bus.failing[0x02] = true; + f.chip.set_pressure_oversampling(5); + f.chip.setup(); + EXPECT_TRUE(f.chip.is_failed()); +} + +TEST(XGZP6899DCTest, FailedOversamplingWriteFails) { + Fixture f; + f.bus.fail_writes = true; + f.chip.set_pressure_oversampling(5); + f.chip.setup(); + EXPECT_TRUE(f.chip.is_failed()); +} + +TEST(XGZP6899DCTest, FailedStartSetsWarningAndRecovers) { + Fixture f; + f.chip.setup(); + f.bus.failing.fill(true); + f.chip.update(); + EXPECT_TRUE(f.chip.status_has_warning()); + + f.bus.failing.fill(false); + f.bus.set_data(0, 4096); + f.measure(); + EXPECT_FALSE(f.chip.status_has_warning()); + EXPECT_FLOAT_EQ(f.temperature.state, 25.0f); +} + +TEST(XGZP6899DCTest, FailedDataReadPublishesNothing) { + Fixture f; + f.chip.setup(); + f.bus.failing[0x07] = true; + f.measure(); + EXPECT_TRUE(f.chip.status_has_warning()); + EXPECT_FALSE(f.pressure.has_state()); + EXPECT_FALSE(f.temperature.has_state()); +} + +} // namespace esphome::xgzp68xx::testing diff --git a/tests/components/xgzp68xx/test.esp32-idf.yaml b/tests/components/xgzp68xx/test.esp32-idf.yaml index b47e39c3898..56881d6caf0 100644 --- a/tests/components/xgzp68xx/test.esp32-idf.yaml +++ b/tests/components/xgzp68xx/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + xgzp68xx: !include common.yaml diff --git a/tests/components/xgzp68xx/test.esp8266-ard.yaml b/tests/components/xgzp68xx/test.esp8266-ard.yaml index 4a98b9388ab..accc76e5306 100644 --- a/tests/components/xgzp68xx/test.esp8266-ard.yaml +++ b/tests/components/xgzp68xx/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + xgzp68xx: !include common.yaml diff --git a/tests/components/xgzp68xx/test.rp2040-ard.yaml b/tests/components/xgzp68xx/test.rp2040-ard.yaml index 319a7c71a65..8ebab5c224d 100644 --- a/tests/components/xgzp68xx/test.rp2040-ard.yaml +++ b/tests/components/xgzp68xx/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + xgzp68xx: !include common.yaml From 90e3f57adb3ca1d157d346da242ae213969bfe56 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:47:00 -1000 Subject: [PATCH 024/170] Bump aioesphomeapi from 46.24.0 to 46.25.0 (#22046) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bdraco <663432+bdraco@users.noreply.github.com> Co-authored-by: J. Nick Koston --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 43642b089f0..7079bc85bba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.24.0 +aioesphomeapi==46.25.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 62264c969596472d2907e37433d7fa191b439184 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 13:09:10 -1000 Subject: [PATCH 025/170] [ms8607] Use the global App in the lifecycle test (#22071) --- tests/components/ms8607/test_ms8607_lifecycle.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/components/ms8607/test_ms8607_lifecycle.cpp b/tests/components/ms8607/test_ms8607_lifecycle.cpp index 37c43ccc978..bc15f7602eb 100644 --- a/tests/components/ms8607/test_ms8607_lifecycle.cpp +++ b/tests/components/ms8607/test_ms8607_lifecycle.cpp @@ -6,7 +6,6 @@ #include #include #include -#include "esphome/components/host/preferences.h" #include "esphome/components/ms8607/ms8607.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -115,20 +114,17 @@ class MS8607LifecycleTest : public ::testing::Test { sensor::Sensor humidity_; void SetUp() override { - new (&App) Application; - App.pre_setup("ms8607-lifecycle-tests", 22, "MS8607 tests", 12); - host::setup_preferences(); this->chip_.set_i2c_bus(&this->bus_); this->chip_.set_i2c_address(PT_ADDRESS); this->humidity_device_.set_i2c_bus(&this->bus_); this->humidity_device_.set_i2c_address(H_ADDRESS); } + // Drop the chip's pending timeouts so none outlives it into a later test void TearDown() override { for (uint32_t id = 0; id < 5; ++id) App.scheduler.cancel_timeout(&this->chip_, id); App.scheduler.call(millis()); - App.~Application(); } static void run_timers(uint32_t duration) { From 9dd043160ae9409eb59c55f69e2fce5a3371c961 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:21:03 +1100 Subject: [PATCH 026/170] [rtttl] Build speaker samples in single precision (#22059) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/rtttl/rtttl.cpp | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 63fbac49e71..2fe34833ba1 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -31,11 +32,7 @@ static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10; #ifdef USE_SPEAKER static constexpr uint16_t SAMPLE_BUFFER_SIZE = 2048; static constexpr uint16_t SAMPLE_RATE = 16000; - -inline double deg2rad(double degrees) { - static constexpr double PI_ON_180 = M_PI / 180.0; - return degrees * PI_ON_180; -} +static constexpr float RADIANS_PER_TURN = 2.0f * std::numbers::pi_v; #endif // USE_SPEAKER #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -123,13 +120,14 @@ void Rtttl::loop() { if (this->samples_sent_ != this->samples_count_) { int16_t sample[SAMPLE_BUFFER_SIZE]; uint16_t sample_index = 0; - double rem = 0.0; + // Single precision: ESP32 FPUs have no double support, and a software sin() per sample blocks the loop + const float radians_per_step = this->samples_per_wave_ != 0 ? RADIANS_PER_TURN / this->samples_per_wave_ : 0.0f; while (sample_index < SAMPLE_BUFFER_SIZE && this->samples_sent_ < this->samples_count_) { // Try and send out the remainder of the existing note, one per `loop()` if (this->samples_per_wave_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note - rem = ((this->samples_sent_ << 10) % this->samples_per_wave_) * (360.0 / this->samples_per_wave_); - sample[sample_index] = INT16_MAX * sin(deg2rad(rem)); + const uint32_t step = (this->samples_sent_ << 10) % this->samples_per_wave_; + sample[sample_index] = static_cast(INT16_MAX * sinf(step * radians_per_step)); } else { sample[sample_index] = 0; } From 4a16cf1783f4bc65fb1519fa9762fb1e428dc315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 1 Nov 2026 00:22:11 +0100 Subject: [PATCH 027/170] [m5stack_unit_bldc] Add M5Stack M5Unit-BLDC platform support (#18086) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + .../components/m5stack_unit_bldc/__init__.py | 79 ++++++ .../m5stack_unit_bldc/m5stack_unit_bldc.cpp | 231 ++++++++++++++++++ .../m5stack_unit_bldc/m5stack_unit_bldc.h | 142 +++++++++++ .../m5stack_unit_bldc/number/__init__.py | 68 ++++++ .../m5stack_unit_bldc/select/__init__.py | 29 +++ .../m5stack_unit_bldc/sensor/__init__.py | 39 +++ .../m5stack_unit_bldc/text_sensor/__init__.py | 18 ++ .../m5stack_unit_bldc/common-closed_loop.yaml | 13 + .../common-sensors_only.yaml | 10 + .../components/m5stack_unit_bldc/common.yaml | 34 +++ .../test-sensors_only.esp32-idf.yaml | 3 + .../m5stack_unit_bldc/test.esp32-idf.yaml | 3 + .../validate-closed_loop.esp32-idf.yaml | 3 + 14 files changed, 673 insertions(+) create mode 100644 esphome/components/m5stack_unit_bldc/__init__.py create mode 100644 esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp create mode 100644 esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h create mode 100644 esphome/components/m5stack_unit_bldc/number/__init__.py create mode 100644 esphome/components/m5stack_unit_bldc/select/__init__.py create mode 100644 esphome/components/m5stack_unit_bldc/sensor/__init__.py create mode 100644 esphome/components/m5stack_unit_bldc/text_sensor/__init__.py create mode 100644 tests/components/m5stack_unit_bldc/common-closed_loop.yaml create mode 100644 tests/components/m5stack_unit_bldc/common-sensors_only.yaml create mode 100644 tests/components/m5stack_unit_bldc/common.yaml create mode 100644 tests/components/m5stack_unit_bldc/test-sensors_only.esp32-idf.yaml create mode 100644 tests/components/m5stack_unit_bldc/test.esp32-idf.yaml create mode 100644 tests/components/m5stack_unit_bldc/validate-closed_loop.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index eea5352c74b..a07f37e4f01 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -384,6 +384,7 @@ esphome/components/ltr_als_ps/* @latonita esphome/components/lvgl/* @clydebarrow esphome/components/m5stack_4relay/* @KoenBreeman esphome/components/m5stack_8angle/* @rnauber +esphome/components/m5stack_unit_bldc/* @lboue esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell diff --git a/esphome/components/m5stack_unit_bldc/__init__.py b/esphome/components/m5stack_unit_bldc/__init__.py new file mode 100644 index 00000000000..8bd0e1a52cd --- /dev/null +++ b/esphome/components/m5stack_unit_bldc/__init__.py @@ -0,0 +1,79 @@ +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_DIRECTION, CONF_MODE, CONF_MODEL +from esphome.types import ConfigType + +CODEOWNERS = ["@lboue"] +DEPENDENCIES = ["i2c"] +DOMAIN = "m5stack_unit_bldc" +MULTI_CONF = True + +CONF_M5STACK_UNIT_BLDC_ID = "m5stack_unit_bldc_id" +CONF_POLE_PAIRS = "pole_pairs" +CONF_PID = "pid" +CONF_P = "p" +CONF_I = "i" +CONF_D = "d" +CONF_SAVE_TO_FLASH = "save_to_flash" + +# The device stores each gain multiplied by 100 in a signed 32-bit register +_GAIN = cv.float_range(min=-(2**31) / 100, max=(2**31 - 1) / 100) + +m5stack_unit_bldc_ns = cg.esphome_ns.namespace("m5stack_unit_bldc") +M5StackUnitBldc = m5stack_unit_bldc_ns.class_( + "M5StackUnitBldc", i2c.I2CDevice, cg.PollingComponent +) + +ControlMode = m5stack_unit_bldc_ns.enum("ControlMode", is_class=True) +CONTROL_MODES = ControlMode.options("open_loop", "closed_loop", prefix="CONTROL_MODE_") + +Direction = m5stack_unit_bldc_ns.enum("Direction", is_class=True) +DIRECTIONS = Direction.options("forward", "backward", prefix="DIRECTION_") + +MotorModel = m5stack_unit_bldc_ns.enum("MotorModel", is_class=True) +MOTOR_MODELS = MotorModel.options("low_speed", "high_speed", prefix="MOTOR_MODEL_") + +PID_SCHEMA = cv.Schema( + { + cv.Required(CONF_P): _GAIN, + cv.Required(CONF_I): _GAIN, + cv.Required(CONF_D): _GAIN, + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(M5StackUnitBldc), + cv.Optional(CONF_MODE, default="open_loop"): cv.enum( + CONTROL_MODES, lower=True + ), + cv.Optional(CONF_DIRECTION, default="forward"): cv.enum( + DIRECTIONS, lower=True + ), + cv.Optional(CONF_MODEL, default="low_speed"): cv.enum( + MOTOR_MODELS, lower=True + ), + cv.Required(CONF_POLE_PAIRS): cv.int_range(min=1, max=255), + cv.Optional(CONF_PID): PID_SCHEMA, + cv.Optional(CONF_SAVE_TO_FLASH, default=False): cv.boolean, + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(0x65)) +) + + +async def to_code(config: ConfigType) -> None: + var = await i2c.new_i2c_component( + config, + config[CONF_MODE], + config[CONF_DIRECTION], + config[CONF_MODEL], + config[CONF_POLE_PAIRS], + config[CONF_SAVE_TO_FLASH], + ) + if pid := config.get(CONF_PID): + p, i, d = (round(pid[key] * 100) for key in (CONF_P, CONF_I, CONF_D)) + cg.add(var.set_pid(p, i, d)) diff --git a/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp new file mode 100644 index 00000000000..3b1f569cc8d --- /dev/null +++ b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp @@ -0,0 +1,231 @@ +#include "m5stack_unit_bldc.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" +#include "esphome/core/progmem.h" + +namespace esphome::m5stack_unit_bldc { + +ESPHOME_LOG_TAG(TAG, "m5stack_unit_bldc"); + +#ifdef USE_TEXT_SENSOR +// Indexed by the REG_MOTOR_STATUS byte; anything past "Error" reads as "Unknown" +PROGMEM_STRING_TABLE(MotorStatusStrings, "Standby", "Running", "Error", "Unknown"); +#endif + +// The device latches a new direction only once the motor has stopped (datasheet note [1] "Change direction"); the +// RPM readback says when that is, polled at this rate for at most this many polls +static constexpr uint32_t DIRECTION_CHANGE_POLL_MS = 200; +static constexpr uint8_t DIRECTION_CHANGE_MAX_POLLS = 25; +// What was last saved to the unit's flash, keyed by the I2C address +static constexpr uint32_t SAVE_PREF_HASH = 0x7B1D4C25; + +bool M5StackUnitBldc::write_command_raw_(float value) { + if (this->control_mode_ == ControlMode::CONTROL_MODE_CLOSED_LOOP) + return this->write_reg_(REG_SET_RPM, value); + return this->write_reg_(REG_PWM, static_cast(value)); +} + +bool M5StackUnitBldc::read_command_raw_(float *value) { + if (this->control_mode_ == ControlMode::CONTROL_MODE_CLOSED_LOOP) + return this->read_reg_(REG_SET_RPM, value); + uint16_t duty; + if (!this->read_reg_(REG_PWM, &duty)) + return false; + *value = duty; + return true; +} + +void M5StackUnitBldc::setup() { + if (!this->write_byte(REG_MODE, static_cast(this->control_mode_))) { + this->mark_failed(LOG_STR("Failed to write control mode")); + return; + } + // The unit keeps driving its last command across an ESP reboot, so the cache and the number start from it, and + // a running motor only takes the direction through the stop and restore sequence + float command; + if (!this->read_command_raw_(&command)) { + this->mark_failed(LOG_STR("Failed to read the motor command")); + return; + } + this->last_command_ = command; + // REG_DIRECTION is readable (datasheet register 0x60, W/R), so a boot that already matches the configured + // direction skips the stop-and-restore cycle entirely instead of spinning the motor down for no reason. A + // failed read is treated the same as a mismatch, falling back to the unconditional write below. + uint8_t current_direction; + bool direction_matches = this->read_byte(REG_DIRECTION, ¤t_direction) && + current_direction == static_cast(this->initial_direction_); + if (!direction_matches && + (command != 0 ? !this->write_direction_raw_(this->initial_direction_) + : !this->write_byte(REG_DIRECTION, static_cast(this->initial_direction_)))) { + this->mark_failed(LOG_STR("Failed to write initial direction")); + return; + } + this->current_direction_ = this->initial_direction_; +#ifdef USE_SELECT + if (this->direction_select_ != nullptr) + this->direction_select_->publish_state(static_cast(this->initial_direction_)); +#endif +#ifdef USE_NUMBER + if (this->command_number_ != nullptr) + this->command_number_->publish_state(command); +#endif + + const uint8_t motor_config[2] = {static_cast(this->motor_model_), this->pole_pairs_}; + if (!this->write_reg_(REG_MOTOR_CONFIG, motor_config)) { + this->mark_failed(LOG_STR("Failed to write motor model/pole pairs")); + return; + } + if (this->has_pid_ && !this->write_reg_(REG_PID, this->pid_)) { + this->mark_failed(LOG_STR("Failed to write PID gains")); + return; + } + + // The unit's flash has limited write cycles and its live registers do not say whether they are persisted, so + // a hash of what was last saved is kept on the ESP: each configuration is saved once, and a failed save is + // retried at the next boot + if (this->save_to_flash_) { + uint32_t wanted = fnv1a_hash_extend(FNV1_OFFSET_BASIS, static_cast(this->motor_model_)); + wanted = fnv1a_hash_extend(wanted, this->pole_pairs_); + if (this->has_pid_) { + for (const int32_t gain : this->pid_) + wanted = fnv1a_hash_extend(wanted, gain); + } + this->save_pref_ = global_preferences->make_preference(SAVE_PREF_HASH ^ this->address_); + uint32_t saved = 0; + this->save_pref_.load(&saved); + if (saved != wanted) { + if (this->write_byte(REG_SAVE_TO_FLASH, 1)) { + this->save_pref_.save(&wanted); + } else { + ESP_LOGW(TAG, "Failed to save settings to flash"); + } + } + } + + // Without a readback entity there is nothing to poll +#ifdef USE_SENSOR + if (this->rpm_sensor_ != nullptr || this->frequency_sensor_ != nullptr) + return; +#endif +#ifdef USE_TEXT_SENSOR + if (this->status_text_sensor_ != nullptr) + return; +#endif + this->stop_poller(); +} + +void M5StackUnitBldc::update() { + bool ok = true; +#ifdef USE_SENSOR + float value; + if (this->rpm_sensor_ != nullptr) { + if (this->read_reg_(REG_READBACK_RPM, &value)) { + this->rpm_sensor_->publish_state(value); + } else { + ok = false; + } + } + if (this->frequency_sensor_ != nullptr) { + if (this->read_reg_(REG_READBACK_FREQ, &value)) { + this->frequency_sensor_->publish_state(value); + } else { + ok = false; + } + } +#endif +#ifdef USE_TEXT_SENSOR + uint8_t status; + if (this->status_text_sensor_ != nullptr) { + if (!this->read_byte(REG_MOTOR_STATUS, &status)) { + ok = false; + } else if (status != this->last_status_) { + this->last_status_ = status; + this->status_text_sensor_->publish_state( + MotorStatusStrings::get_progmem_str(status, MotorStatusStrings::LAST_INDEX)); + } + } +#endif + this->status_comm_result(ok); +} + +void M5StackUnitBldc::dump_config() { + ESP_LOGCONFIG(TAG, + "M5Unit-BLDC:\n" + " Control mode: %s\n" + " Direction: %s\n" + " Motor model: %s\n" + " Pole pairs: %u", + this->control_mode_ == ControlMode::CONTROL_MODE_CLOSED_LOOP ? LOG_STR_LITERAL("closed loop") + : LOG_STR_LITERAL("open loop"), + this->initial_direction_ == Direction::DIRECTION_BACKWARD ? LOG_STR_LITERAL("backward") + : LOG_STR_LITERAL("forward"), + this->motor_model_ == MotorModel::MOTOR_MODEL_HIGH_SPEED ? LOG_STR_LITERAL("high speed") + : LOG_STR_LITERAL("low speed"), + this->pole_pairs_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +// Outside a direction change the cache only follows a write the device accepted, so a refused command is not +// applied later when the direction change restores the command +bool M5StackUnitBldc::write_command(float value) { + if (this->is_failed()) + return false; + if (!this->direction_change_pending_ && !this->status_comm_result(this->write_command_raw_(value))) + return false; + this->last_command_ = value; + return true; +} + +bool M5StackUnitBldc::write_direction(Direction direction) { + if (this->is_failed()) + return false; + // The direction already driven needs no stop-and-restore cycle + if (direction == this->current_direction_ && !this->direction_change_pending_) + return true; + return this->status_comm_result(this->write_direction_raw_(direction)); +} + +// The new direction latches only after the motor stops: command zero, wait for the readback to confirm the stop, +// then restore. A change already pending has stopped the motor, so only the direction byte is replaced. +bool M5StackUnitBldc::write_direction_raw_(Direction direction) { + if (!this->write_byte(REG_DIRECTION, static_cast(direction))) + return false; + if (!this->direction_change_pending_ && !this->write_command_raw_(0)) { + // The new byte would latch at the next stop; put the driven direction back, best effort + this->write_byte(REG_DIRECTION, static_cast(this->current_direction_)); + return false; + } + this->current_direction_ = direction; + this->direction_change_pending_ = true; + this->direction_change_polls_ = 0; + this->set_interval(DIRECTION_CHANGE_POLL_ID, DIRECTION_CHANGE_POLL_MS, + [this]() { this->finish_direction_change_(); }); + return true; +} + +void M5StackUnitBldc::finish_direction_change_() { + float rpm; + const bool stopped = this->read_reg_(REG_READBACK_RPM, &rpm) && rpm == 0; + if (!stopped && ++this->direction_change_polls_ < DIRECTION_CHANGE_MAX_POLLS) + return; + this->cancel_interval(DIRECTION_CHANGE_POLL_ID); + this->direction_change_pending_ = false; + if (!stopped) { + // Restoring now could start the motor in the old direction, so the output stays off + ESP_LOGW(TAG, "Motor still turning after %u ms; output left at zero", + static_cast(DIRECTION_CHANGE_POLL_MS * DIRECTION_CHANGE_MAX_POLLS)); + this->last_command_ = 0; +#ifdef USE_NUMBER + if (this->command_number_ != nullptr) + this->command_number_->publish_state(0); +#endif + return; + } + if (this->last_command_ != 0) + this->status_comm_result(this->write_command_raw_(this->last_command_)); +} + +} // namespace esphome::m5stack_unit_bldc diff --git a/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h new file mode 100644 index 00000000000..9e0faf84710 --- /dev/null +++ b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h @@ -0,0 +1,142 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/preferences.h" +#include "esphome/components/i2c/i2c.h" +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +namespace esphome::m5stack_unit_bldc { + +// I2C register map, from M5Stack's "Unit BLDC I2C Protocol" datasheet and the M5UnitBLDC library +// (https://github.com/m5stack/M5Unit-BLDC). Multi-byte values are little-endian. +enum : uint8_t { + REG_MODE = 0x00, // 1B, 0: open loop, 1: closed loop + REG_PWM = 0x10, // 2B, uint16_t duty 0-2047 + REG_READBACK_RPM = 0x20, // 4B, read only, float + REG_READBACK_FREQ = 0x30, // 4B, read only, float Hz + REG_SET_RPM = 0x40, // 4B, float, closed loop only + REG_PID = 0x50, // 12B, 3x int32_t, each gain * 100 + REG_DIRECTION = 0x60, // 1B, 0: forward, 1: backward + REG_MOTOR_CONFIG = 0x70, // 2B, motor model, pole pairs + REG_MOTOR_STATUS = 0x80, // 1B, read only, 0: standby, 1: running, 2: error + REG_SAVE_TO_FLASH = 0xF0, // 1B, write 1 to persist model, pole pairs and PID +}; + +enum class ControlMode : uint8_t { CONTROL_MODE_OPEN_LOOP = 0, CONTROL_MODE_CLOSED_LOOP = 1 }; +// Values match the direction select's option order +enum class Direction : uint8_t { DIRECTION_FORWARD = 0, DIRECTION_BACKWARD = 1 }; +enum class MotorModel : uint8_t { MOTOR_MODEL_LOW_SPEED = 0, MOTOR_MODEL_HIGH_SPEED = 1 }; + +/// Hub for one M5Stack M5Unit-BLDC brushless motor driver. +class M5StackUnitBldc : public i2c::I2CDevice, public PollingComponent { + public: + M5StackUnitBldc(ControlMode mode, Direction initial_direction, MotorModel model, uint8_t pole_pairs, + bool save_to_flash) + : current_direction_(initial_direction), + control_mode_(mode), + initial_direction_(initial_direction), + motor_model_(model), + pole_pairs_(pole_pairs), + save_to_flash_(save_to_flash) {} + + void setup() override; + void update() override; + void dump_config() override; + + /// PID gains as the device stores them, each multiplied by 100. + void set_pid(int32_t p, int32_t i, int32_t d) { + this->pid_[0] = p; + this->pid_[1] = i; + this->pid_[2] = d; + this->has_pid_ = true; + } + +#ifdef USE_SENSOR + void set_rpm_sensor(sensor::Sensor *rpm_sensor) { this->rpm_sensor_ = rpm_sensor; } + void set_frequency_sensor(sensor::Sensor *frequency_sensor) { this->frequency_sensor_ = frequency_sensor; } +#endif +#ifdef USE_TEXT_SENSOR + void set_status_text_sensor(text_sensor::TextSensor *status_text_sensor) { + this->status_text_sensor_ = status_text_sensor; + } +#endif +#ifdef USE_NUMBER + /// The PWM duty number in open loop, the target RPM number in closed loop. + void set_command_number(number::Number *command_number) { this->command_number_ = command_number; } + void control_command(float value) { + if (this->write_command(value)) + this->command_number_->publish_state(value); + } +#endif +#ifdef USE_SELECT + void set_direction_select(select::Select *direction_select) { this->direction_select_ = direction_select; } + void control_direction(size_t index) { + if (this->write_direction(static_cast(index))) + this->direction_select_->publish_state(index); + } +#endif + + /// Send the PWM duty (open loop) or target RPM (closed loop). False when refused or the write failed; + /// commands are refused once setup failed, since the motor configuration may be incomplete. + bool write_command(float value); + bool write_direction(Direction direction); + + protected: + static constexpr uint32_t DIRECTION_CHANGE_POLL_ID = 0; + + template bool read_reg_(uint8_t reg, T *value) { + return this->read_bytes(reg, reinterpret_cast(value), sizeof(T)); + } + template bool write_reg_(uint8_t reg, const T &value) { + return this->write_bytes(reg, reinterpret_cast(&value), sizeof(T)); + } + bool write_command_raw_(float value); + bool write_direction_raw_(Direction direction); + bool read_command_raw_(float *value); + void finish_direction_change_(); + +#ifdef USE_SENSOR + sensor::Sensor *rpm_sensor_{nullptr}; + sensor::Sensor *frequency_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *status_text_sensor_{nullptr}; +#endif +#ifdef USE_SELECT + select::Select *direction_select_{nullptr}; +#endif +#ifdef USE_NUMBER + number::Number *command_number_{nullptr}; +#endif + int32_t pid_[3]{}; + ESPPreferenceObject save_pref_; + // Last command, restored after the stop a direction change needs + float last_command_{0}; + Direction current_direction_; // what the unit drives, so selecting it again does not stop the motor + const ControlMode control_mode_; + const Direction initial_direction_; + const MotorModel motor_model_; + const uint8_t pole_pairs_; + const bool save_to_flash_; + bool has_pid_{false}; + // While a direction change waits for the motor to stop, commands are only cached + bool direction_change_pending_{false}; + uint8_t direction_change_polls_{0}; +#ifdef USE_TEXT_SENSOR + uint8_t last_status_{0xFF}; +#endif +}; + +} // namespace esphome::m5stack_unit_bldc diff --git a/esphome/components/m5stack_unit_bldc/number/__init__.py b/esphome/components/m5stack_unit_bldc/number/__init__.py new file mode 100644 index 00000000000..176a95dc1eb --- /dev/null +++ b/esphome/components/m5stack_unit_bldc/number/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import CONF_MODE, ICON_ROTATE_RIGHT, UNIT_REVOLUTIONS_PER_MINUTE +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import CONF_M5STACK_UNIT_BLDC_ID, M5StackUnitBldc + +CONF_PWM = "pwm" +CONF_TARGET_RPM = "target_rpm" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_M5STACK_UNIT_BLDC_ID): cv.use_id(M5StackUnitBldc), + cv.Optional(CONF_PWM): number.number_schema( + number.ApplyNumber, + icon=ICON_ROTATE_RIGHT, + ), + cv.Optional(CONF_TARGET_RPM): number.number_schema( + number.ApplyNumber, + icon=ICON_ROTATE_RIGHT, + unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE, + ), +} + + +def _final_validate(config: ConfigType) -> None: + is_closed_loop = ( + fv.get_declaring_config(config[CONF_M5STACK_UNIT_BLDC_ID])[CONF_MODE] + == "closed_loop" + ) + if CONF_PWM in config and is_closed_loop: + raise cv.Invalid( + f"'{CONF_PWM}' has no effect while the hub's mode is 'closed_loop'; " + f"use '{CONF_TARGET_RPM}' instead", + path=[CONF_PWM], + ) + if CONF_TARGET_RPM in config and not is_closed_loop: + raise cv.Invalid( + f"'{CONF_TARGET_RPM}' has no effect while the hub's mode is 'open_loop'; " + f"use '{CONF_PWM}' instead", + path=[CONF_TARGET_RPM], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_M5STACK_UNIT_BLDC_ID]) + # Validation allows only the key that matches the hub's mode, so both share the command setter + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PWM, + hub.set_command_number, + apply="control_command(value)", + min_value=0, + max_value=2047, + step=1, + ) + await numbers( + CONF_TARGET_RPM, + hub.set_command_number, + apply="control_command(value)", + min_value=0, + max_value=20000, + step=1, + ) diff --git a/esphome/components/m5stack_unit_bldc/select/__init__.py b/esphome/components/m5stack_unit_bldc/select/__init__.py new file mode 100644 index 00000000000..9e0a99c79e2 --- /dev/null +++ b/esphome/components/m5stack_unit_bldc/select/__init__.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_DIRECTION, ICON_ROTATE_RIGHT +from esphome.types import ConfigType + +from .. import CONF_M5STACK_UNIT_BLDC_ID, M5StackUnitBldc + +# The order matches the Direction enum +DIRECTION_OPTIONS = ["Forward", "Backward"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_M5STACK_UNIT_BLDC_ID): cv.use_id(M5StackUnitBldc), + cv.Optional(CONF_DIRECTION): select.select_schema( + select.ApplySelect, + icon=ICON_ROTATE_RIGHT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_M5STACK_UNIT_BLDC_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_DIRECTION, + hub.set_direction_select, + apply="control_direction(index)", + options=DIRECTION_OPTIONS, + ) diff --git a/esphome/components/m5stack_unit_bldc/sensor/__init__.py b/esphome/components/m5stack_unit_bldc/sensor/__init__.py new file mode 100644 index 00000000000..358644a3fe7 --- /dev/null +++ b/esphome/components/m5stack_unit_bldc/sensor/__init__.py @@ -0,0 +1,39 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_FREQUENCY, + DEVICE_CLASS_FREQUENCY, + ICON_ROTATE_RIGHT, + STATE_CLASS_MEASUREMENT, + UNIT_HERTZ, + UNIT_REVOLUTIONS_PER_MINUTE, +) +from esphome.types import ConfigType + +from .. import CONF_M5STACK_UNIT_BLDC_ID, M5StackUnitBldc + +CONF_RPM = "rpm" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_M5STACK_UNIT_BLDC_ID): cv.use_id(M5StackUnitBldc), + cv.Optional(CONF_RPM): sensor.sensor_schema( + unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE, + icon=ICON_ROTATE_RIGHT, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_FREQUENCY): sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=1, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_M5STACK_UNIT_BLDC_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RPM, hub.set_rpm_sensor) + await sensors(CONF_FREQUENCY, hub.set_frequency_sensor) diff --git a/esphome/components/m5stack_unit_bldc/text_sensor/__init__.py b/esphome/components/m5stack_unit_bldc/text_sensor/__init__.py new file mode 100644 index 00000000000..8b9615559e1 --- /dev/null +++ b/esphome/components/m5stack_unit_bldc/text_sensor/__init__.py @@ -0,0 +1,18 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_STATUS +from esphome.types import ConfigType + +from .. import CONF_M5STACK_UNIT_BLDC_ID, M5StackUnitBldc + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_M5STACK_UNIT_BLDC_ID): cv.use_id(M5StackUnitBldc), + cv.Optional(CONF_STATUS): text_sensor.text_sensor_schema(), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_M5STACK_UNIT_BLDC_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_STATUS, hub.set_status_text_sensor) diff --git a/tests/components/m5stack_unit_bldc/common-closed_loop.yaml b/tests/components/m5stack_unit_bldc/common-closed_loop.yaml new file mode 100644 index 00000000000..03c6a47a7d3 --- /dev/null +++ b/tests/components/m5stack_unit_bldc/common-closed_loop.yaml @@ -0,0 +1,13 @@ +m5stack_unit_bldc: + i2c_id: i2c_bus + id: bldc_hub + mode: closed_loop + direction: backward + model: high_speed + pole_pairs: 7 + +number: + - platform: m5stack_unit_bldc + m5stack_unit_bldc_id: bldc_hub + target_rpm: + name: Motor Target RPM diff --git a/tests/components/m5stack_unit_bldc/common-sensors_only.yaml b/tests/components/m5stack_unit_bldc/common-sensors_only.yaml new file mode 100644 index 00000000000..4a184e88816 --- /dev/null +++ b/tests/components/m5stack_unit_bldc/common-sensors_only.yaml @@ -0,0 +1,10 @@ +m5stack_unit_bldc: + i2c_id: i2c_bus + id: bldc_hub + pole_pairs: 7 + +sensor: + - platform: m5stack_unit_bldc + m5stack_unit_bldc_id: bldc_hub + rpm: + name: Motor RPM diff --git a/tests/components/m5stack_unit_bldc/common.yaml b/tests/components/m5stack_unit_bldc/common.yaml new file mode 100644 index 00000000000..c6280cc7ad7 --- /dev/null +++ b/tests/components/m5stack_unit_bldc/common.yaml @@ -0,0 +1,34 @@ +m5stack_unit_bldc: + i2c_id: i2c_bus + id: bldc_hub + pole_pairs: 7 + pid: + p: 1.0 + i: 0.0 + d: 0.0 + +number: + - platform: m5stack_unit_bldc + m5stack_unit_bldc_id: bldc_hub + pwm: + name: Motor PWM + +select: + - platform: m5stack_unit_bldc + m5stack_unit_bldc_id: bldc_hub + direction: + name: Motor Direction + +sensor: + - platform: m5stack_unit_bldc + m5stack_unit_bldc_id: bldc_hub + rpm: + name: Motor RPM + frequency: + name: Motor Frequency + +text_sensor: + - platform: m5stack_unit_bldc + m5stack_unit_bldc_id: bldc_hub + status: + name: Motor Status diff --git a/tests/components/m5stack_unit_bldc/test-sensors_only.esp32-idf.yaml b/tests/components/m5stack_unit_bldc/test-sensors_only.esp32-idf.yaml new file mode 100644 index 00000000000..91675bd7236 --- /dev/null +++ b/tests/components/m5stack_unit_bldc/test-sensors_only.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + m5stack_unit_bldc: !include common-sensors_only.yaml diff --git a/tests/components/m5stack_unit_bldc/test.esp32-idf.yaml b/tests/components/m5stack_unit_bldc/test.esp32-idf.yaml new file mode 100644 index 00000000000..ac830c3c21e --- /dev/null +++ b/tests/components/m5stack_unit_bldc/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + m5stack_unit_bldc: !include common.yaml diff --git a/tests/components/m5stack_unit_bldc/validate-closed_loop.esp32-idf.yaml b/tests/components/m5stack_unit_bldc/validate-closed_loop.esp32-idf.yaml new file mode 100644 index 00000000000..a64c0cc4ef9 --- /dev/null +++ b/tests/components/m5stack_unit_bldc/validate-closed_loop.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + m5stack_unit_bldc: !include common-closed_loop.yaml From cc65a82a1e3adb7f5b5e11ce2524687187173c42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 13:24:00 -1000 Subject: [PATCH 028/170] [components] Turn platform packages with only __init__.py into modules (#22061) --- CODEOWNERS | 9 --------- .../airton/{select/__init__.py => select.py} | 2 +- .../airton/{switch/__init__.py => switch.py} | 2 +- .../at581x/{switch/__init__.py => switch.py} | 2 +- .../atm90e32/{button/__init__.py => button.py} | 2 +- .../{text_sensor/__init__.py => text_sensor.py} | 2 +- .../bh1745/{output/__init__.py => output.py} | 2 +- .../bl0940/{button/__init__.py => button.py} | 4 ++-- .../components/copy/{button/__init__.py => button.py} | 0 .../components/copy/{sensor/__init__.py => sensor.py} | 0 .../copy/{text_sensor/__init__.py => text_sensor.py} | 0 .../{binary_sensor/__init__.py => binary_sensor.py} | 0 .../{binary_sensor/__init__.py => binary_sensor.py} | 0 .../custom/{climate/__init__.py => climate.py} | 0 .../components/custom/{cover/__init__.py => cover.py} | 0 .../components/custom/{light/__init__.py => light.py} | 0 .../custom/{output/__init__.py => output.py} | 0 .../custom/{sensor/__init__.py => sensor.py} | 0 .../custom/{switch/__init__.py => switch.py} | 0 .../custom/{text_sensor/__init__.py => text_sensor.py} | 0 .../dfrobot_sen0395/{switch/__init__.py => switch.py} | 2 +- .../dfrobot_sen0609/{button/__init__.py => button.py} | 2 +- .../dfrobot_sen0609/{number/__init__.py => number.py} | 2 +- .../dfrobot_sen0609/{select/__init__.py => select.py} | 2 +- .../dfrobot_sen0609/{switch/__init__.py => switch.py} | 2 +- .../{binary_sensor/__init__.py => binary_sensor.py} | 2 +- .../dlms_meter/{sensor/__init__.py => sensor.py} | 2 +- .../{text_sensor/__init__.py => text_sensor.py} | 2 +- .../{binary_sensor/__init__.py => binary_sensor.py} | 2 +- .../emc2101/{output/__init__.py => output.py} | 2 +- .../emontx/{text_sensor/__init__.py => text_sensor.py} | 2 +- .../es8388/{select/__init__.py => select.py} | 2 +- .../espectre/{button/__init__.py => button.py} | 2 +- .../espectre/{select/__init__.py => select.py} | 2 +- .../factory_reset/{button/__init__.py => button.py} | 0 .../factory_reset/{switch/__init__.py => switch.py} | 0 .../gp8403/{output/__init__.py => output.py} | 2 +- .../{binary_sensor/__init__.py => binary_sensor.py} | 2 +- .../components/haier/{button/__init__.py => button.py} | 2 +- .../components/haier/{select/__init__.py => select.py} | 2 +- .../components/haier/{sensor/__init__.py => sensor.py} | 2 +- .../components/haier/{switch/__init__.py => switch.py} | 2 +- .../haier/{text_sensor/__init__.py => text_sensor.py} | 2 +- .../haier_tundra/{button/__init__.py => button.py} | 2 +- .../hitachi_ac344/{switch/__init__.py => switch.py} | 2 +- .../hoermann_hcp/{button/__init__.py => button.py} | 2 +- .../{media_player/__init__.py => media_player.py} | 0 .../{text_sensor/__init__.py => text_sensor.py} | 2 +- .../ld2410/{button/__init__.py => button.py} | 2 +- .../ld2410/{number/__init__.py => number.py} | 2 +- .../ld2410/{select/__init__.py => select.py} | 2 +- .../ld2410/{switch/__init__.py => switch.py} | 2 +- .../ld2412/{button/__init__.py => button.py} | 2 +- .../ld2412/{number/__init__.py => number.py} | 2 +- .../ld2412/{select/__init__.py => select.py} | 2 +- .../ld2412/{switch/__init__.py => switch.py} | 2 +- .../ld2420/{button/__init__.py => button.py} | 2 +- .../ld2450/{button/__init__.py => button.py} | 2 +- .../ld2450/{number/__init__.py => number.py} | 2 +- .../ld2450/{select/__init__.py => select.py} | 2 +- .../ld2450/{switch/__init__.py => switch.py} | 2 +- .../ld6002b/{button/__init__.py => button.py} | 4 ++-- .../ld6002b/{number/__init__.py => number.py} | 4 ++-- .../ld6002b/{select/__init__.py => select.py} | 4 ++-- .../ld6002b/{switch/__init__.py => switch.py} | 4 ++-- .../ld6004/{button/__init__.py => button.py} | 4 ++-- .../ld6004/{number/__init__.py => number.py} | 4 ++-- .../ld6004/{select/__init__.py => select.py} | 4 ++-- .../ld6004/{switch/__init__.py => switch.py} | 4 ++-- .../{binary_sensor/__init__.py => binary_sensor.py} | 8 ++++---- .../components/lvgl/{sensor/__init__.py => sensor.py} | 10 +++++----- .../lvgl/{text_sensor/__init__.py => text_sensor.py} | 8 ++++---- .../m5stack_4relay/{switch/__init__.py => switch.py} | 2 +- .../mcp4728/{output/__init__.py => output.py} | 2 +- .../micronova/{button/__init__.py => button.py} | 2 +- .../mitsubishi_cn105/{select/__init__.py => select.py} | 2 +- .../number/{sensor/__init__.py => sensor.py} | 2 +- .../{binary_sensor/__init__.py => binary_sensor.py} | 2 +- .../opentherm/{sensor/__init__.py => sensor.py} | 2 +- .../pcm5122/{switch/__init__.py => switch.py} | 2 +- .../{binary_sensor/__init__.py => binary_sensor.py} | 2 +- .../pipsolar/{sensor/__init__.py => sensor.py} | 2 +- .../pipsolar/{switch/__init__.py => switch.py} | 2 +- .../{text_sensor/__init__.py => text_sensor.py} | 2 +- .../restart/{button/__init__.py => button.py} | 0 .../restart/{switch/__init__.py => switch.py} | 0 .../safe_mode/{button/__init__.py => button.py} | 2 +- .../safe_mode/{switch/__init__.py => switch.py} | 2 +- .../seeed_mr24hpc1/{button/__init__.py => button.py} | 2 +- .../seeed_mr24hpc1/{number/__init__.py => number.py} | 2 +- .../seeed_mr24hpc1/{select/__init__.py => select.py} | 2 +- .../seeed_mr24hpc1/{switch/__init__.py => switch.py} | 2 +- .../seeed_mr60fda2/{button/__init__.py => button.py} | 2 +- .../seeed_mr60fda2/{select/__init__.py => select.py} | 2 +- .../{switch/__init__.py => switch.py} | 2 +- .../shutdown/{button/__init__.py => button.py} | 0 .../shutdown/{switch/__init__.py => switch.py} | 0 .../tas58xx/{select/__init__.py => select.py} | 4 ++-- .../tas58xx/{switch/__init__.py => switch.py} | 3 ++- .../text/{text_sensor/__init__.py => text_sensor.py} | 2 +- .../tm1638/{output/__init__.py => output.py} | 2 +- .../tm1638/{switch/__init__.py => switch.py} | 2 +- .../{output/__init__.py => output.py} | 2 +- 103 files changed, 104 insertions(+), 112 deletions(-) rename esphome/components/airton/{select/__init__.py => select.py} (97%) rename esphome/components/airton/{switch/__init__.py => switch.py} (94%) rename esphome/components/at581x/{switch/__init__.py => switch.py} (93%) rename esphome/components/atm90e32/{button/__init__.py => button.py} (98%) rename esphome/components/atm90e32/{text_sensor/__init__.py => text_sensor.py} (97%) rename esphome/components/bh1745/{output/__init__.py => output.py} (93%) rename esphome/components/bl0940/{button/__init__.py => button.py} (90%) rename esphome/components/copy/{button/__init__.py => button.py} (100%) rename esphome/components/copy/{sensor/__init__.py => sensor.py} (100%) rename esphome/components/copy/{text_sensor/__init__.py => text_sensor.py} (100%) rename esphome/components/cst816/{binary_sensor/__init__.py => binary_sensor.py} (100%) rename esphome/components/custom/{binary_sensor/__init__.py => binary_sensor.py} (100%) rename esphome/components/custom/{climate/__init__.py => climate.py} (100%) rename esphome/components/custom/{cover/__init__.py => cover.py} (100%) rename esphome/components/custom/{light/__init__.py => light.py} (100%) rename esphome/components/custom/{output/__init__.py => output.py} (100%) rename esphome/components/custom/{sensor/__init__.py => sensor.py} (100%) rename esphome/components/custom/{switch/__init__.py => switch.py} (100%) rename esphome/components/custom/{text_sensor/__init__.py => text_sensor.py} (100%) rename esphome/components/dfrobot_sen0395/{switch/__init__.py => switch.py} (95%) rename esphome/components/dfrobot_sen0609/{button/__init__.py => button.py} (94%) rename esphome/components/dfrobot_sen0609/{number/__init__.py => number.py} (99%) rename esphome/components/dfrobot_sen0609/{select/__init__.py => select.py} (94%) rename esphome/components/dfrobot_sen0609/{switch/__init__.py => switch.py} (99%) rename esphome/components/dlms_meter/{binary_sensor/__init__.py => binary_sensor.py} (97%) rename esphome/components/dlms_meter/{sensor/__init__.py => sensor.py} (99%) rename esphome/components/dlms_meter/{text_sensor/__init__.py => text_sensor.py} (98%) rename esphome/components/ds3231/{binary_sensor/__init__.py => binary_sensor.py} (97%) rename esphome/components/emc2101/{output/__init__.py => output.py} (89%) rename esphome/components/emontx/{text_sensor/__init__.py => text_sensor.py} (95%) rename esphome/components/es8388/{select/__init__.py => select.py} (97%) rename esphome/components/espectre/{button/__init__.py => button.py} (91%) rename esphome/components/espectre/{select/__init__.py => select.py} (98%) rename esphome/components/factory_reset/{button/__init__.py => button.py} (100%) rename esphome/components/factory_reset/{switch/__init__.py => switch.py} (100%) rename esphome/components/gp8403/{output/__init__.py => output.py} (94%) rename esphome/components/haier/{binary_sensor/__init__.py => binary_sensor.py} (97%) rename esphome/components/haier/{button/__init__.py => button.py} (95%) rename esphome/components/haier/{select/__init__.py => select.py} (98%) rename esphome/components/haier/{sensor/__init__.py => sensor.py} (99%) rename esphome/components/haier/{switch/__init__.py => switch.py} (97%) rename esphome/components/haier/{text_sensor/__init__.py => text_sensor.py} (96%) rename esphome/components/haier_tundra/{button/__init__.py => button.py} (96%) rename esphome/components/hitachi_ac344/{switch/__init__.py => switch.py} (96%) rename esphome/components/hoermann_hcp/{button/__init__.py => button.py} (95%) rename esphome/components/i2s_audio/{media_player/__init__.py => media_player.py} (100%) rename esphome/components/key_collector/{text_sensor/__init__.py => text_sensor.py} (92%) rename esphome/components/ld2410/{button/__init__.py => button.py} (95%) rename esphome/components/ld2410/{number/__init__.py => number.py} (98%) rename esphome/components/ld2410/{select/__init__.py => select.py} (98%) rename esphome/components/ld2410/{switch/__init__.py => switch.py} (96%) rename esphome/components/ld2412/{button/__init__.py => button.py} (97%) rename esphome/components/ld2412/{number/__init__.py => number.py} (98%) rename esphome/components/ld2412/{select/__init__.py => select.py} (98%) rename esphome/components/ld2412/{switch/__init__.py => switch.py} (96%) rename esphome/components/ld2420/{button/__init__.py => button.py} (97%) rename esphome/components/ld2450/{button/__init__.py => button.py} (89%) rename esphome/components/ld2450/{number/__init__.py => number.py} (97%) rename esphome/components/ld2450/{select/__init__.py => select.py} (93%) rename esphome/components/ld2450/{switch/__init__.py => switch.py} (92%) rename esphome/components/ld6002b/{button/__init__.py => button.py} (93%) rename esphome/components/ld6002b/{number/__init__.py => number.py} (84%) rename esphome/components/ld6002b/{select/__init__.py => select.py} (85%) rename esphome/components/ld6002b/{switch/__init__.py => switch.py} (90%) rename esphome/components/ld6004/{button/__init__.py => button.py} (90%) rename esphome/components/ld6004/{number/__init__.py => number.py} (95%) rename esphome/components/ld6004/{select/__init__.py => select.py} (96%) rename esphome/components/ld6004/{switch/__init__.py => switch.py} (86%) rename esphome/components/lvgl/{binary_sensor/__init__.py => binary_sensor.py} (85%) rename esphome/components/lvgl/{sensor/__init__.py => sensor.py} (75%) rename esphome/components/lvgl/{text_sensor/__init__.py => text_sensor.py} (80%) rename esphome/components/m5stack_4relay/{switch/__init__.py => switch.py} (95%) rename esphome/components/mcp4728/{output/__init__.py => output.py} (97%) rename esphome/components/micronova/{button/__init__.py => button.py} (98%) rename esphome/components/mitsubishi_cn105/{select/__init__.py => select.py} (99%) rename esphome/components/number/{sensor/__init__.py => sensor.py} (96%) rename esphome/components/opentherm/{binary_sensor/__init__.py => binary_sensor.py} (94%) rename esphome/components/opentherm/{sensor/__init__.py => sensor.py} (96%) rename esphome/components/pcm5122/{switch/__init__.py => switch.py} (95%) rename esphome/components/pipsolar/{binary_sensor/__init__.py => binary_sensor.py} (98%) rename esphome/components/pipsolar/{sensor/__init__.py => sensor.py} (99%) rename esphome/components/pipsolar/{switch/__init__.py => switch.py} (97%) rename esphome/components/pipsolar/{text_sensor/__init__.py => text_sensor.py} (94%) rename esphome/components/restart/{button/__init__.py => button.py} (100%) rename esphome/components/restart/{switch/__init__.py => switch.py} (100%) rename esphome/components/safe_mode/{button/__init__.py => button.py} (95%) rename esphome/components/safe_mode/{switch/__init__.py => switch.py} (95%) rename esphome/components/seeed_mr24hpc1/{button/__init__.py => button.py} (95%) rename esphome/components/seeed_mr24hpc1/{number/__init__.py => number.py} (98%) rename esphome/components/seeed_mr24hpc1/{select/__init__.py => select.py} (98%) rename esphome/components/seeed_mr24hpc1/{switch/__init__.py => switch.py} (94%) rename esphome/components/seeed_mr60fda2/{button/__init__.py => button.py} (96%) rename esphome/components/seeed_mr60fda2/{select/__init__.py => select.py} (97%) rename esphome/components/seeed_multi_channel_relay/{switch/__init__.py => switch.py} (93%) rename esphome/components/shutdown/{button/__init__.py => button.py} (100%) rename esphome/components/shutdown/{switch/__init__.py => switch.py} (100%) rename esphome/components/tas58xx/{select/__init__.py => select.py} (94%) rename esphome/components/tas58xx/{switch/__init__.py => switch.py} (91%) rename esphome/components/text/{text_sensor/__init__.py => text_sensor.py} (96%) rename esphome/components/tm1638/{output/__init__.py => output.py} (91%) rename esphome/components/tm1638/{switch/__init__.py => switch.py} (91%) rename esphome/components/waveshare_io_ch32v003/{output/__init__.py => output.py} (96%) diff --git a/CODEOWNERS b/CODEOWNERS index a07f37e4f01..3515f55c8fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -33,8 +33,6 @@ esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/airton/* @Procsiab -esphome/components/airton/select/* @Procsiab -esphome/components/airton/switch/* @Procsiab esphome/components/airton_serial/* @guiguid esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alpha3/* @jan-hofmeier @@ -262,14 +260,7 @@ esphome/components/growatt_solar/* @leeuwte esphome/components/gsl3670/* @clydebarrow esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/haier/* @paveldn -esphome/components/haier/binary_sensor/* @paveldn -esphome/components/haier/button/* @paveldn -esphome/components/haier/select/* @paveldn -esphome/components/haier/sensor/* @paveldn -esphome/components/haier/switch/* @paveldn -esphome/components/haier/text_sensor/* @paveldn esphome/components/haier_tundra/* @fauxpark -esphome/components/haier_tundra/button/* @fauxpark esphome/components/haier_tundra/switch/* @fauxpark esphome/components/haier_water_heater/* @TheLexus esphome/components/havells_solar/* @sourabhjaiswal diff --git a/esphome/components/airton/select/__init__.py b/esphome/components/airton/select.py similarity index 97% rename from esphome/components/airton/select/__init__.py rename to esphome/components/airton/select.py index 6a09f951a03..79b5301c4c6 100644 --- a/esphome/components/airton/select/__init__.py +++ b/esphome/components/airton/select.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from ..climate import ( +from .climate import ( CONF_AIRTON_ID, CONF_VERTICAL_DIRECTION, VERTICAL_DIRECTIONS, diff --git a/esphome/components/airton/switch/__init__.py b/esphome/components/airton/switch.py similarity index 94% rename from esphome/components/airton/switch/__init__.py rename to esphome/components/airton/switch.py index 88200891f26..e9320f03d00 100644 --- a/esphome/components/airton/switch/__init__.py +++ b/esphome/components/airton/switch.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_DISPLAY, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from ..climate import CONF_AIRTON_ID, CONF_SLEEP_MODE, AirtonClimate, airton_ns +from .climate import CONF_AIRTON_ID, CONF_SLEEP_MODE, AirtonClimate, airton_ns CODEOWNERS = ["@Procsiab"] diff --git a/esphome/components/at581x/switch/__init__.py b/esphome/components/at581x/switch.py similarity index 93% rename from esphome/components/at581x/switch/__init__.py rename to esphome/components/at581x/switch.py index b5f3b100cac..e3cb798d078 100644 --- a/esphome/components/at581x/switch/__init__.py +++ b/esphome/components/at581x/switch.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ICON_WIFI from esphome.types import ConfigType -from .. import CONF_AT581X_ID, AT581XComponent +from . import CONF_AT581X_ID, AT581XComponent DEPENDENCIES = ["at581x"] diff --git a/esphome/components/atm90e32/button/__init__.py b/esphome/components/atm90e32/button.py similarity index 98% rename from esphome/components/atm90e32/button/__init__.py rename to esphome/components/atm90e32/button.py index cd252f3bf84..1912d0f68ac 100644 --- a/esphome/components/atm90e32/button/__init__.py +++ b/esphome/components/atm90e32/button.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE from esphome.types import ConfigType -from ..sensor import ATM90E32Component +from .sensor import ATM90E32Component CONF_RUN_GAIN_CALIBRATION = "run_gain_calibration" CONF_CLEAR_GAIN_CALIBRATION = "clear_gain_calibration" diff --git a/esphome/components/atm90e32/text_sensor/__init__.py b/esphome/components/atm90e32/text_sensor.py similarity index 97% rename from esphome/components/atm90e32/text_sensor/__init__.py rename to esphome/components/atm90e32/text_sensor.py index 6b959c19fa8..25c1f051389 100644 --- a/esphome/components/atm90e32/text_sensor/__init__.py +++ b/esphome/components/atm90e32/text_sensor.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C from esphome.types import ConfigType -from ..sensor import ATM90E32Component +from .sensor import ATM90E32Component CONF_PHASE_STATUS = "phase_status" CONF_FREQUENCY_STATUS = "frequency_status" diff --git a/esphome/components/bh1745/output/__init__.py b/esphome/components/bh1745/output.py similarity index 93% rename from esphome/components/bh1745/output/__init__.py rename to esphome/components/bh1745/output.py index 42167e9d418..b09586e7881 100644 --- a/esphome/components/bh1745/output/__init__.py +++ b/esphome/components/bh1745/output.py @@ -3,7 +3,7 @@ from esphome.components import output import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_BH1745_ID, BH1745Component +from . import CONF_BH1745_ID, BH1745Component DEPENDENCIES = ["bh1745"] diff --git a/esphome/components/bl0940/button/__init__.py b/esphome/components/bl0940/button.py similarity index 90% rename from esphome/components/bl0940/button/__init__.py rename to esphome/components/bl0940/button.py index 690fd306163..0f796bc95a8 100644 --- a/esphome/components/bl0940/button/__init__.py +++ b/esphome/components/bl0940/button.py @@ -3,8 +3,8 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG, ICON_RESTART from esphome.types import ConfigType -from .. import CONF_BL0940_ID -from ..sensor import BL0940 +from . import CONF_BL0940_ID +from .sensor import BL0940 CONFIG_SCHEMA = button.button_schema( button.ApplyButton, diff --git a/esphome/components/copy/button/__init__.py b/esphome/components/copy/button.py similarity index 100% rename from esphome/components/copy/button/__init__.py rename to esphome/components/copy/button.py diff --git a/esphome/components/copy/sensor/__init__.py b/esphome/components/copy/sensor.py similarity index 100% rename from esphome/components/copy/sensor/__init__.py rename to esphome/components/copy/sensor.py diff --git a/esphome/components/copy/text_sensor/__init__.py b/esphome/components/copy/text_sensor.py similarity index 100% rename from esphome/components/copy/text_sensor/__init__.py rename to esphome/components/copy/text_sensor.py diff --git a/esphome/components/cst816/binary_sensor/__init__.py b/esphome/components/cst816/binary_sensor.py similarity index 100% rename from esphome/components/cst816/binary_sensor/__init__.py rename to esphome/components/cst816/binary_sensor.py diff --git a/esphome/components/custom/binary_sensor/__init__.py b/esphome/components/custom/binary_sensor.py similarity index 100% rename from esphome/components/custom/binary_sensor/__init__.py rename to esphome/components/custom/binary_sensor.py diff --git a/esphome/components/custom/climate/__init__.py b/esphome/components/custom/climate.py similarity index 100% rename from esphome/components/custom/climate/__init__.py rename to esphome/components/custom/climate.py diff --git a/esphome/components/custom/cover/__init__.py b/esphome/components/custom/cover.py similarity index 100% rename from esphome/components/custom/cover/__init__.py rename to esphome/components/custom/cover.py diff --git a/esphome/components/custom/light/__init__.py b/esphome/components/custom/light.py similarity index 100% rename from esphome/components/custom/light/__init__.py rename to esphome/components/custom/light.py diff --git a/esphome/components/custom/output/__init__.py b/esphome/components/custom/output.py similarity index 100% rename from esphome/components/custom/output/__init__.py rename to esphome/components/custom/output.py diff --git a/esphome/components/custom/sensor/__init__.py b/esphome/components/custom/sensor.py similarity index 100% rename from esphome/components/custom/sensor/__init__.py rename to esphome/components/custom/sensor.py diff --git a/esphome/components/custom/switch/__init__.py b/esphome/components/custom/switch.py similarity index 100% rename from esphome/components/custom/switch/__init__.py rename to esphome/components/custom/switch.py diff --git a/esphome/components/custom/text_sensor/__init__.py b/esphome/components/custom/text_sensor.py similarity index 100% rename from esphome/components/custom/text_sensor/__init__.py rename to esphome/components/custom/text_sensor.py diff --git a/esphome/components/dfrobot_sen0395/switch/__init__.py b/esphome/components/dfrobot_sen0395/switch.py similarity index 95% rename from esphome/components/dfrobot_sen0395/switch/__init__.py rename to esphome/components/dfrobot_sen0395/switch.py index daa67d59372..6e4e269eb6c 100644 --- a/esphome/components/dfrobot_sen0395/switch/__init__.py +++ b/esphome/components/dfrobot_sen0395/switch.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component, dfrobot_sen0395_ns +from . import CONF_DFROBOT_SEN0395_ID, DfrobotSen0395Component, dfrobot_sen0395_ns DEPENDENCIES = ["dfrobot_sen0395"] diff --git a/esphome/components/dfrobot_sen0609/button/__init__.py b/esphome/components/dfrobot_sen0609/button.py similarity index 94% rename from esphome/components/dfrobot_sen0609/button/__init__.py rename to esphome/components/dfrobot_sen0609/button.py index ab26f8b289d..a4b7592f171 100644 --- a/esphome/components/dfrobot_sen0609/button/__init__.py +++ b/esphome/components/dfrobot_sen0609/button.py @@ -5,7 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_FACTORY_RESET, CONF_ID, CONF_RESTART from esphome.types import ConfigType -from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), diff --git a/esphome/components/dfrobot_sen0609/number/__init__.py b/esphome/components/dfrobot_sen0609/number.py similarity index 99% rename from esphome/components/dfrobot_sen0609/number/__init__.py rename to esphome/components/dfrobot_sen0609/number.py index 6d6e8fd176e..d8fe54820fb 100644 --- a/esphome/components/dfrobot_sen0609/number/__init__.py +++ b/esphome/components/dfrobot_sen0609/number.py @@ -21,7 +21,7 @@ from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType -from .. import ( +from . import ( CONF_DFROBOT_SEN0609_ID, MAX_RANGES, MODE_PRESENCE, diff --git a/esphome/components/dfrobot_sen0609/select/__init__.py b/esphome/components/dfrobot_sen0609/select.py similarity index 94% rename from esphome/components/dfrobot_sen0609/select/__init__.py rename to esphome/components/dfrobot_sen0609/select.py index 6f1ff32888f..cb8fdbaf934 100644 --- a/esphome/components/dfrobot_sen0609/select/__init__.py +++ b/esphome/components/dfrobot_sen0609/select.py @@ -5,7 +5,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG, ICON_SCALE from esphome.types import ConfigType -from .. import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component +from . import CONF_DFROBOT_SEN0609_ID, DFRobotSEN0609Component CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), diff --git a/esphome/components/dfrobot_sen0609/switch/__init__.py b/esphome/components/dfrobot_sen0609/switch.py similarity index 99% rename from esphome/components/dfrobot_sen0609/switch/__init__.py rename to esphome/components/dfrobot_sen0609/switch.py index c89a9703ef1..8cf6716c399 100644 --- a/esphome/components/dfrobot_sen0609/switch/__init__.py +++ b/esphome/components/dfrobot_sen0609/switch.py @@ -10,7 +10,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import ( +from . import ( CONF_DFROBOT_SEN0609_ID, MODE_SPEED_AND_DISTANCE, DFRobotSEN0609Component, diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor.py similarity index 97% rename from esphome/components/dlms_meter/binary_sensor/__init__.py rename to esphome/components/dlms_meter/binary_sensor.py index 2a51ad09909..9027c70f276 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/binary_sensor.py @@ -3,7 +3,7 @@ from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import ( +from . import ( CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor.py similarity index 99% rename from esphome/components/dlms_meter/sensor/__init__.py rename to esphome/components/dlms_meter/sensor.py index 15b9deb012c..6a054155450 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor.py @@ -3,7 +3,7 @@ from esphome.components import sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import ( +from . import ( CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor.py similarity index 98% rename from esphome/components/dlms_meter/text_sensor/__init__.py rename to esphome/components/dlms_meter/text_sensor.py index 4e2e48487a8..49a74d697c6 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor.py @@ -3,7 +3,7 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import ( +from . import ( CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, diff --git a/esphome/components/ds3231/binary_sensor/__init__.py b/esphome/components/ds3231/binary_sensor.py similarity index 97% rename from esphome/components/ds3231/binary_sensor/__init__.py rename to esphome/components/ds3231/binary_sensor.py index 3703d73c475..927b4973772 100644 --- a/esphome/components/ds3231/binary_sensor/__init__.py +++ b/esphome/components/ds3231/binary_sensor.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC from esphome.types import ConfigType -from .. import CONF_DS3231_ID, DS3231Component +from . import CONF_DS3231_ID, DS3231Component DEPENDENCIES = ["ds3231"] diff --git a/esphome/components/emc2101/output/__init__.py b/esphome/components/emc2101/output.py similarity index 89% rename from esphome/components/emc2101/output/__init__.py rename to esphome/components/emc2101/output.py index 0282f6264fa..75953a627f3 100644 --- a/esphome/components/emc2101/output/__init__.py +++ b/esphome/components/emc2101/output.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -from .. import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA +from . import CONF_EMC2101_ID, EMC2101_COMPONENT_SCHEMA DEPENDENCIES = ["emc2101"] diff --git a/esphome/components/emontx/text_sensor/__init__.py b/esphome/components/emontx/text_sensor.py similarity index 95% rename from esphome/components/emontx/text_sensor/__init__.py rename to esphome/components/emontx/text_sensor.py index a3e381b9b5d..c240a5d0542 100644 --- a/esphome/components/emontx/text_sensor/__init__.py +++ b/esphome/components/emontx/text_sensor.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_VERSION, ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP from esphome.types import ConfigType -from .. import CONF_EMONTX_ID, EmonTx +from . import CONF_EMONTX_ID, EmonTx CONFIG_SCHEMA = cv.Schema( { diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select.py similarity index 97% rename from esphome/components/es8388/select/__init__.py rename to esphome/components/es8388/select.py index 334c041ef1a..ebd02b59004 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select.py @@ -8,7 +8,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from ..audio_dac import CONF_ES8388_ID, ES8388, es8388_ns +from .audio_dac import CONF_ES8388_ID, ES8388, es8388_ns CONF_DAC_OUTPUT = "dac_output" CONF_ADC_INPUT_MIC = "adc_input_mic" diff --git a/esphome/components/espectre/button/__init__.py b/esphome/components/espectre/button.py similarity index 91% rename from esphome/components/espectre/button/__init__.py rename to esphome/components/espectre/button.py index 0edb2a56fc9..e66626a7ed0 100644 --- a/esphome/components/espectre/button/__init__.py +++ b/esphome/components/espectre/button.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import CONF_ESPECTRE_ID, ESPectreComponent +from . import CONF_ESPECTRE_ID, ESPectreComponent DEPENDENCIES = ["espectre"] diff --git a/esphome/components/espectre/select/__init__.py b/esphome/components/espectre/select.py similarity index 98% rename from esphome/components/espectre/select/__init__.py rename to esphome/components/espectre/select.py index 167ba40d829..953202acfc8 100644 --- a/esphome/components/espectre/select/__init__.py +++ b/esphome/components/espectre/select.py @@ -5,7 +5,7 @@ from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.core import CORE from esphome.types import ConfigType -from .. import ( +from . import ( CONF_ESPECTRE_ID, DOMAIN, ESPectreComponent, diff --git a/esphome/components/factory_reset/button/__init__.py b/esphome/components/factory_reset/button.py similarity index 100% rename from esphome/components/factory_reset/button/__init__.py rename to esphome/components/factory_reset/button.py diff --git a/esphome/components/factory_reset/switch/__init__.py b/esphome/components/factory_reset/switch.py similarity index 100% rename from esphome/components/factory_reset/switch/__init__.py rename to esphome/components/factory_reset/switch.py diff --git a/esphome/components/gp8403/output/__init__.py b/esphome/components/gp8403/output.py similarity index 94% rename from esphome/components/gp8403/output/__init__.py rename to esphome/components/gp8403/output.py index 1edd87913b5..c09c2b3fa95 100644 --- a/esphome/components/gp8403/output/__init__.py +++ b/esphome/components/gp8403/output.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_CHANNEL from esphome.types import ConfigType -from .. import CONF_GP8403_ID, GP8403Component +from . import CONF_GP8403_ID, GP8403Component DEPENDENCIES = ["gp8403"] diff --git a/esphome/components/haier/binary_sensor/__init__.py b/esphome/components/haier/binary_sensor.py similarity index 97% rename from esphome/components/haier/binary_sensor/__init__.py rename to esphome/components/haier/binary_sensor.py index 2486b631557..822f853829e 100644 --- a/esphome/components/haier/binary_sensor/__init__.py +++ b/esphome/components/haier/binary_sensor.py @@ -3,7 +3,7 @@ from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_FAN, ICON_RADIATOR -from ..climate import CONF_HAIER_ID, HonClimate +from .climate import CONF_HAIER_ID, HonClimate CODEOWNERS = ["@paveldn"] BinarySensorTypeEnum = HonClimate.enum("SubBinarySensorType", True) diff --git a/esphome/components/haier/button/__init__.py b/esphome/components/haier/button.py similarity index 95% rename from esphome/components/haier/button/__init__.py rename to esphome/components/haier/button.py index a38cb696944..6ef13c111c2 100644 --- a/esphome/components/haier/button/__init__.py +++ b/esphome/components/haier/button.py @@ -2,7 +2,7 @@ from esphome.components import button import esphome.config_validation as cv from esphome.types import ConfigType -from ..climate import CONF_HAIER_ID, HonClimate +from .climate import CONF_HAIER_ID, HonClimate CODEOWNERS = ["@paveldn"] diff --git a/esphome/components/haier/select/__init__.py b/esphome/components/haier/select.py similarity index 98% rename from esphome/components/haier/select/__init__.py rename to esphome/components/haier/select.py index 3f6c5b6bd02..6770646244b 100644 --- a/esphome/components/haier/select/__init__.py +++ b/esphome/components/haier/select.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from ..climate import ( +from .climate import ( CONF_HAIER_ID, CONF_HORIZONTAL_AIRFLOW, CONF_VERTICAL_AIRFLOW, diff --git a/esphome/components/haier/sensor/__init__.py b/esphome/components/haier/sensor.py similarity index 99% rename from esphome/components/haier/sensor/__init__.py rename to esphome/components/haier/sensor.py index e9025b560ee..3fce469ea39 100644 --- a/esphome/components/haier/sensor/__init__.py +++ b/esphome/components/haier/sensor.py @@ -27,7 +27,7 @@ from esphome.const import ( UNIT_WATT, ) -from ..climate import CONF_HAIER_ID, HonClimate +from .climate import CONF_HAIER_ID, HonClimate CODEOWNERS = ["@paveldn"] SensorTypeEnum = HonClimate.enum("SubSensorType", True) diff --git a/esphome/components/haier/switch/__init__.py b/esphome/components/haier/switch.py similarity index 97% rename from esphome/components/haier/switch/__init__.py rename to esphome/components/haier/switch.py index 5ae01b6c4ed..34542809ef5 100644 --- a/esphome/components/haier/switch/__init__.py +++ b/esphome/components/haier/switch.py @@ -5,7 +5,7 @@ from esphome.const import CONF_BEEPER, CONF_DISPLAY, ENTITY_CATEGORY_CONFIG import esphome.final_validate as fv from esphome.types import ConfigType -from ..climate import CONF_HAIER_ID, CONF_PROTOCOL, PROTOCOL_HON, HaierClimateBase +from .climate import CONF_HAIER_ID, CONF_PROTOCOL, PROTOCOL_HON, HaierClimateBase CODEOWNERS = ["@paveldn"] diff --git a/esphome/components/haier/text_sensor/__init__.py b/esphome/components/haier/text_sensor.py similarity index 96% rename from esphome/components/haier/text_sensor/__init__.py rename to esphome/components/haier/text_sensor.py index 67f9fe12245..d20481929db 100644 --- a/esphome/components/haier/text_sensor/__init__.py +++ b/esphome/components/haier/text_sensor.py @@ -3,7 +3,7 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ENTITY_CATEGORY_NONE -from ..climate import CONF_HAIER_ID, HonClimate +from .climate import CONF_HAIER_ID, HonClimate CODEOWNERS = ["@paveldn"] TextSensorTypeEnum = HonClimate.enum("SubTextSensorType", True) diff --git a/esphome/components/haier_tundra/button/__init__.py b/esphome/components/haier_tundra/button.py similarity index 96% rename from esphome/components/haier_tundra/button/__init__.py rename to esphome/components/haier_tundra/button.py index 358e88b2e1b..9f10903105b 100644 --- a/esphome/components/haier_tundra/button/__init__.py +++ b/esphome/components/haier_tundra/button.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_LIGHT from esphome.types import ConfigType -from ..climate import HaierTundra +from .climate import HaierTundra CODEOWNERS = ["@fauxpark"] diff --git a/esphome/components/hitachi_ac344/switch/__init__.py b/esphome/components/hitachi_ac344/switch.py similarity index 96% rename from esphome/components/hitachi_ac344/switch/__init__.py rename to esphome/components/hitachi_ac344/switch.py index 62d454f0a92..b19ee4fb14a 100644 --- a/esphome/components/hitachi_ac344/switch/__init__.py +++ b/esphome/components/hitachi_ac344/switch.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from ..climate import HitachiClimate +from .climate import HitachiClimate CONF_HITACHI_AC344_ID = "hitachi_ac344_id" CONF_MILDEW_PROOF = "mildew_proof" diff --git a/esphome/components/hoermann_hcp/button/__init__.py b/esphome/components/hoermann_hcp/button.py similarity index 95% rename from esphome/components/hoermann_hcp/button/__init__.py rename to esphome/components/hoermann_hcp/button.py index 42d0360553a..d9c8e2d57ee 100644 --- a/esphome/components/hoermann_hcp/button/__init__.py +++ b/esphome/components/hoermann_hcp/button.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import ICON_AIR_FILTER from esphome.types import ConfigType -from .. import CONF_HOERMANN_HCP_ID, HoermannHcp +from . import CONF_HOERMANN_HCP_ID, HoermannHcp DEPENDENCIES = ["hoermann_hcp"] diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player.py similarity index 100% rename from esphome/components/i2s_audio/media_player/__init__.py rename to esphome/components/i2s_audio/media_player.py diff --git a/esphome/components/key_collector/text_sensor/__init__.py b/esphome/components/key_collector/text_sensor.py similarity index 92% rename from esphome/components/key_collector/text_sensor/__init__.py rename to esphome/components/key_collector/text_sensor.py index e32d15df2e6..d6cdb61bb16 100644 --- a/esphome/components/key_collector/text_sensor/__init__.py +++ b/esphome/components/key_collector/text_sensor.py @@ -6,7 +6,7 @@ from esphome.const import CONF_ID from esphome.cpp_generator import literal from esphome.types import ConfigType, TemplateArgsType -from .. import CONF_ON_RESULT, CONF_SOURCE_ID, TRIGGER_TYPES, KeyCollector +from . import CONF_ON_RESULT, CONF_SOURCE_ID, TRIGGER_TYPES, KeyCollector CONFIG_SCHEMA = text_sensor.text_sensor_schema(TextSensor).extend( { diff --git a/esphome/components/ld2410/button/__init__.py b/esphome/components/ld2410/button.py similarity index 95% rename from esphome/components/ld2410/button/__init__.py rename to esphome/components/ld2410/button.py index acd7e77bd6e..5a07f1efb46 100644 --- a/esphome/components/ld2410/button/__init__.py +++ b/esphome/components/ld2410/button.py @@ -10,7 +10,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_BACKGROUND_CORRECTION, CONF_LD2410_ID, LD2410Component +from . import CONF_BACKGROUND_CORRECTION, CONF_LD2410_ID, LD2410Component CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), diff --git a/esphome/components/ld2410/number/__init__.py b/esphome/components/ld2410/number.py similarity index 98% rename from esphome/components/ld2410/number/__init__.py rename to esphome/components/ld2410/number.py index 4c32fcdc548..85bf8647fbd 100644 --- a/esphome/components/ld2410/number/__init__.py +++ b/esphome/components/ld2410/number.py @@ -20,7 +20,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2410_ID, LD2410Component +from . import CONF_LD2410_ID, LD2410Component CONF_MAX_MOVE_DISTANCE_GATE = "max_move_distance_gate" CONF_MAX_STILL_DISTANCE_GATE = "max_still_distance_gate" diff --git a/esphome/components/ld2410/select/__init__.py b/esphome/components/ld2410/select.py similarity index 98% rename from esphome/components/ld2410/select/__init__.py rename to esphome/components/ld2410/select.py index 1d053cee074..8eab5dffb91 100644 --- a/esphome/components/ld2410/select/__init__.py +++ b/esphome/components/ld2410/select.py @@ -13,7 +13,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2410_ID, LD2410Component +from . import CONF_LD2410_ID, LD2410Component CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" diff --git a/esphome/components/ld2410/switch/__init__.py b/esphome/components/ld2410/switch.py similarity index 96% rename from esphome/components/ld2410/switch/__init__.py rename to esphome/components/ld2410/switch.py index 2c42cbc193e..c5302a6097f 100644 --- a/esphome/components/ld2410/switch/__init__.py +++ b/esphome/components/ld2410/switch.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2410_ID, LD2410Component +from . import CONF_LD2410_ID, LD2410Component CONF_ENGINEERING_MODE = "engineering_mode" diff --git a/esphome/components/ld2412/button/__init__.py b/esphome/components/ld2412/button.py similarity index 97% rename from esphome/components/ld2412/button/__init__.py rename to esphome/components/ld2412/button.py index 3363a5df357..28646dd5383 100644 --- a/esphome/components/ld2412/button/__init__.py +++ b/esphome/components/ld2412/button.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2412_ID, LD2412Component +from . import CONF_LD2412_ID, LD2412Component CONF_START_DYNAMIC_BACKGROUND_CORRECTION = "start_dynamic_background_correction" diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number.py similarity index 98% rename from esphome/components/ld2412/number/__init__.py rename to esphome/components/ld2412/number.py index d76162d2ef1..b1e556d9d2d 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number.py @@ -20,7 +20,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2412_ID, LD2412Component +from . import CONF_LD2412_ID, LD2412Component CONF_LIGHT_THRESHOLD = "light_threshold" CONF_MAX_DISTANCE_GATE = "max_distance_gate" diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select.py similarity index 98% rename from esphome/components/ld2412/select/__init__.py rename to esphome/components/ld2412/select.py index f651d70777a..8b6d67c4235 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select.py @@ -13,7 +13,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2412_ID, LD2412Component +from . import CONF_LD2412_ID, LD2412Component CONF_DISTANCE_RESOLUTION = "distance_resolution" CONF_LIGHT_FUNCTION = "light_function" diff --git a/esphome/components/ld2412/switch/__init__.py b/esphome/components/ld2412/switch.py similarity index 96% rename from esphome/components/ld2412/switch/__init__.py rename to esphome/components/ld2412/switch.py index 2c56ced5fc6..c7ecbffccec 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2412_ID, LD2412Component +from . import CONF_LD2412_ID, LD2412Component CONF_ENGINEERING_MODE = "engineering_mode" diff --git a/esphome/components/ld2420/button/__init__.py b/esphome/components/ld2420/button.py similarity index 97% rename from esphome/components/ld2420/button/__init__.py rename to esphome/components/ld2420/button.py index 61fe4cd3dd7..cc2e3991ed7 100644 --- a/esphome/components/ld2420/button/__init__.py +++ b/esphome/components/ld2420/button.py @@ -12,7 +12,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2420_ID, LD2420Component +from . import CONF_LD2420_ID, LD2420Component CONF_APPLY_CONFIG = "apply_config" CONF_REVERT_CONFIG = "revert_config" diff --git a/esphome/components/ld2450/button/__init__.py b/esphome/components/ld2450/button.py similarity index 89% rename from esphome/components/ld2450/button/__init__.py rename to esphome/components/ld2450/button.py index 71b12d559e7..a4844d699d3 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_FACTORY_RESET, CONF_ID, CONF_RESTART from esphome.types import ConfigType -from .. import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), diff --git a/esphome/components/ld2450/number/__init__.py b/esphome/components/ld2450/number.py similarity index 97% rename from esphome/components/ld2450/number/__init__.py rename to esphome/components/ld2450/number.py index abd0d19b030..059e0306a3b 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import CONF_PRESENCE_TIMEOUT = "presence_timeout" CONF_X1 = "x1" diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select.py similarity index 93% rename from esphome/components/ld2450/select/__init__.py rename to esphome/components/ld2450/select.py index 049d4561457..3259117e5bd 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import CONF_ZONE_TYPE = "zone_type" diff --git a/esphome/components/ld2450/switch/__init__.py b/esphome/components/ld2450/switch.py similarity index 92% rename from esphome/components/ld2450/switch/__init__.py rename to esphome/components/ld2450/switch.py index 60c4aae73fe..ff789bb89ad 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_LD2450_ID, LD2450Component +from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import CONF_MULTI_TARGET = "multi_target" diff --git a/esphome/components/ld6002b/button/__init__.py b/esphome/components/ld6002b/button.py similarity index 93% rename from esphome/components/ld6002b/button/__init__.py rename to esphome/components/ld6002b/button.py index e0d7ff2589d..681e47e3dc0 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button.py @@ -5,8 +5,8 @@ from esphome.core import ID import esphome.final_validate as fv from esphome.types import ConfigType -from .. import LD6002BComponent, ld6002b_ns -from ..const import CONF_LD6002B_ID, CONF_WAKE +from . import LD6002BComponent, ld6002b_ns +from .const import CONF_LD6002B_ID, CONF_WAKE DEPENDENCIES = ["ld6002b"] diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number.py similarity index 84% rename from esphome/components/ld6002b/number/__init__.py rename to esphome/components/ld6002b/number.py index 9c4a5b8ca6f..e37451a8e24 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number.py @@ -1,8 +1,8 @@ from esphome.components.ld600x import entities from esphome.types import ConfigType -from .. import LD6002BComponent -from ..const import CONF_LD6002B_ID +from . import LD6002BComponent +from .const import CONF_LD6002B_ID DEPENDENCIES = ["ld6002b"] diff --git a/esphome/components/ld6002b/select/__init__.py b/esphome/components/ld6002b/select.py similarity index 85% rename from esphome/components/ld6002b/select/__init__.py rename to esphome/components/ld6002b/select.py index 573759035d6..74e6b70d3d8 100644 --- a/esphome/components/ld6002b/select/__init__.py +++ b/esphome/components/ld6002b/select.py @@ -2,8 +2,8 @@ from esphome.components import select from esphome.components.ld600x import entities from esphome.types import ConfigType -from .. import LD6002BComponent -from ..const import AREA_KINDS, CONF_LD6002B_ID +from . import LD6002BComponent +from .const import AREA_KINDS, CONF_LD6002B_ID DEPENDENCIES = ["ld6002b"] diff --git a/esphome/components/ld6002b/switch/__init__.py b/esphome/components/ld6002b/switch.py similarity index 90% rename from esphome/components/ld6002b/switch/__init__.py rename to esphome/components/ld6002b/switch.py index 39e791db881..033b26e9a95 100644 --- a/esphome/components/ld6002b/switch/__init__.py +++ b/esphome/components/ld6002b/switch.py @@ -4,8 +4,8 @@ from esphome.components.ld600x import entities import esphome.config_validation as cv from esphome.types import ConfigType -from .. import LD6002BComponent -from ..const import CONF_LD6002B_ID, CONF_LOW_POWER +from . import LD6002BComponent +from .const import CONF_LD6002B_ID, CONF_LOW_POWER DEPENDENCIES = ["ld6002b"] diff --git a/esphome/components/ld6004/button/__init__.py b/esphome/components/ld6004/button.py similarity index 90% rename from esphome/components/ld6004/button/__init__.py rename to esphome/components/ld6004/button.py index f8d75d0be0f..45873f14fb0 100644 --- a/esphome/components/ld6004/button/__init__.py +++ b/esphome/components/ld6004/button.py @@ -2,8 +2,8 @@ from esphome.components.ld600x import entities import esphome.config_validation as cv from esphome.types import ConfigType -from .. import LD6004Component, ld6004_ns -from ..const import CONF_LD6004_ID +from . import LD6004Component, ld6004_ns +from .const import CONF_LD6004_ID DEPENDENCIES = ["ld6004"] diff --git a/esphome/components/ld6004/number/__init__.py b/esphome/components/ld6004/number.py similarity index 95% rename from esphome/components/ld6004/number/__init__.py rename to esphome/components/ld6004/number.py index bf7918cd99c..de383f3182b 100644 --- a/esphome/components/ld6004/number/__init__.py +++ b/esphome/components/ld6004/number.py @@ -4,8 +4,8 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import LD6004Component -from ..const import CONF_LD6004_ID +from . import LD6004Component +from .const import CONF_LD6004_ID DEPENDENCIES = ["ld6004"] diff --git a/esphome/components/ld6004/select/__init__.py b/esphome/components/ld6004/select.py similarity index 96% rename from esphome/components/ld6004/select/__init__.py rename to esphome/components/ld6004/select.py index 337e4a28ea8..c286fb0460c 100644 --- a/esphome/components/ld6004/select/__init__.py +++ b/esphome/components/ld6004/select.py @@ -5,8 +5,8 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import LD6004Component -from ..const import AREA_KINDS, CONF_LD6004_ID +from . import LD6004Component +from .const import AREA_KINDS, CONF_LD6004_ID DEPENDENCIES = ["ld6004"] diff --git a/esphome/components/ld6004/switch/__init__.py b/esphome/components/ld6004/switch.py similarity index 86% rename from esphome/components/ld6004/switch/__init__.py rename to esphome/components/ld6004/switch.py index 90fd71e8986..a9997ca1463 100644 --- a/esphome/components/ld6004/switch/__init__.py +++ b/esphome/components/ld6004/switch.py @@ -2,8 +2,8 @@ from esphome.components import switch from esphome.components.ld600x import entities from esphome.types import ConfigType -from .. import LD6004Component -from ..const import CONF_LD6004_ID +from . import LD6004Component +from .const import CONF_LD6004_ID DEPENDENCIES = ["ld6004"] diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor.py similarity index 85% rename from esphome/components/lvgl/binary_sensor/__init__.py rename to esphome/components/lvgl/binary_sensor.py index e658735c750..ef68588ee7a 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor.py @@ -6,10 +6,10 @@ from esphome.components.binary_sensor import ( import esphome.config_validation as cv from esphome.const import CONF_STATE -from ..defines import CONF_CLICKABLE, CONF_WIDGET, LvConstant -from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static -from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t -from ..widgets import Widget, get_widgets +from .defines import CONF_CLICKABLE, CONF_WIDGET, LvConstant +from .lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static +from .types import LV_EVENT, LV_STATE, lv_pseudo_button_t +from .widgets import Widget, get_widgets STATE_PRESSED = "PRESSED" STATE_CHECKED = "CHECKED" diff --git a/esphome/components/lvgl/sensor/__init__.py b/esphome/components/lvgl/sensor.py similarity index 75% rename from esphome/components/lvgl/sensor/__init__.py rename to esphome/components/lvgl/sensor.py index c5ef98b05e5..bde058a4799 100644 --- a/esphome/components/lvgl/sensor/__init__.py +++ b/esphome/components/lvgl/sensor.py @@ -1,11 +1,11 @@ from esphome.components.sensor import Sensor, new_sensor, sensor_schema import esphome.config_validation as cv -from ..defines import CONF_TRIGGER, CONF_WIDGET -from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static -from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA -from ..types import LvNumber -from ..widgets import Widget, get_widgets +from .defines import CONF_TRIGGER, CONF_WIDGET +from .lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static +from .schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA +from .types import LvNumber +from .widgets import Widget, get_widgets CONFIG_SCHEMA = sensor_schema(Sensor).extend( { diff --git a/esphome/components/lvgl/text_sensor/__init__.py b/esphome/components/lvgl/text_sensor.py similarity index 80% rename from esphome/components/lvgl/text_sensor/__init__.py rename to esphome/components/lvgl/text_sensor.py index f0575fb1013..b5e6c22351b 100644 --- a/esphome/components/lvgl/text_sensor/__init__.py +++ b/esphome/components/lvgl/text_sensor.py @@ -5,10 +5,10 @@ from esphome.components.text_sensor import ( ) import esphome.config_validation as cv -from ..defines import CONF_WIDGET -from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static -from ..types import LV_EVENT, LvText -from ..widgets import get_widgets +from .defines import CONF_WIDGET +from .lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static +from .types import LV_EVENT, LvText +from .widgets import get_widgets CONFIG_SCHEMA = text_sensor_schema(TextSensor).extend( { diff --git a/esphome/components/m5stack_4relay/switch/__init__.py b/esphome/components/m5stack_4relay/switch.py similarity index 95% rename from esphome/components/m5stack_4relay/switch/__init__.py rename to esphome/components/m5stack_4relay/switch.py index 88f851a1668..f88c76242dc 100644 --- a/esphome/components/m5stack_4relay/switch/__init__.py +++ b/esphome/components/m5stack_4relay/switch.py @@ -5,7 +5,7 @@ from esphome.const import CONF_CHANNEL import esphome.final_validate as fv from esphome.types import ConfigType -from .. import CONF_M5STACK_4RELAY_ID, DOMAIN, M5Stack4Relay +from . import CONF_M5STACK_4RELAY_ID, DOMAIN, M5Stack4Relay DEPENDENCIES = ["m5stack_4relay"] diff --git a/esphome/components/mcp4728/output/__init__.py b/esphome/components/mcp4728/output.py similarity index 97% rename from esphome/components/mcp4728/output/__init__.py rename to esphome/components/mcp4728/output.py index 2ad7373f80b..8dbbd8f7598 100644 --- a/esphome/components/mcp4728/output/__init__.py +++ b/esphome/components/mcp4728/output.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_GAIN, CONF_ID from esphome.types import ConfigType -from .. import CONF_MCP4728_ID, MCP4728Component, mcp4728_ns +from . import CONF_MCP4728_ID, MCP4728Component, mcp4728_ns DEPENDENCIES = ["mcp4728"] diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button.py similarity index 98% rename from esphome/components/micronova/button/__init__.py rename to esphome/components/micronova/button.py index e203ef37aa9..cab583361f3 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button.py @@ -2,7 +2,7 @@ from esphome.components import button import esphome.config_validation as cv from esphome.types import ConfigType -from .. import ( +from . import ( CONF_MEMORY_ADDRESS, CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select.py similarity index 99% rename from esphome/components/mitsubishi_cn105/select/__init__.py rename to esphome/components/mitsubishi_cn105/select.py index 9e774bf06a0..dd1d425a7f8 100644 --- a/esphome/components/mitsubishi_cn105/select/__init__.py +++ b/esphome/components/mitsubishi_cn105/select.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv import esphome.final_validate as fv from esphome.types import ConfigType -from .. import ( +from . import ( CONF_MITSUBISHI_CN105_ID, DOMAIN, HORIZONTAL_VANE_DIRECTIONS, diff --git a/esphome/components/number/sensor/__init__.py b/esphome/components/number/sensor.py similarity index 96% rename from esphome/components/number/sensor/__init__.py rename to esphome/components/number/sensor.py index 8594e9a297d..324bbea755f 100644 --- a/esphome/components/number/sensor/__init__.py +++ b/esphome/components/number/sensor.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_SOURCE_ID from esphome.types import ConfigType -from .. import Number +from . import Number CONFIG_SCHEMA = sensor.sensor_schema(sensor.Sensor).extend( { diff --git a/esphome/components/opentherm/binary_sensor/__init__.py b/esphome/components/opentherm/binary_sensor.py similarity index 94% rename from esphome/components/opentherm/binary_sensor/__init__.py rename to esphome/components/opentherm/binary_sensor.py index 35228228eac..4b44d103248 100644 --- a/esphome/components/opentherm/binary_sensor/__init__.py +++ b/esphome/components/opentherm/binary_sensor.py @@ -3,7 +3,7 @@ from typing import Any from esphome.components import binary_sensor import esphome.config_validation as cv -from .. import const, generate, schema, validate +from . import const, generate, schema, validate DEPENDENCIES = [const.OPENTHERM] COMPONENT_TYPE = const.BINARY_SENSOR diff --git a/esphome/components/opentherm/sensor/__init__.py b/esphome/components/opentherm/sensor.py similarity index 96% rename from esphome/components/opentherm/sensor/__init__.py rename to esphome/components/opentherm/sensor.py index 9aa33f457d2..121f687eb8f 100644 --- a/esphome/components/opentherm/sensor/__init__.py +++ b/esphome/components/opentherm/sensor.py @@ -3,7 +3,7 @@ from typing import Any from esphome.components import sensor import esphome.config_validation as cv -from .. import const, generate, schema, validate +from . import const, generate, schema, validate DEPENDENCIES = [const.OPENTHERM] COMPONENT_TYPE = const.SENSOR diff --git a/esphome/components/pcm5122/switch/__init__.py b/esphome/components/pcm5122/switch.py similarity index 95% rename from esphome/components/pcm5122/switch/__init__.py rename to esphome/components/pcm5122/switch.py index da72c504369..f5efb59a5ba 100644 --- a/esphome/components/pcm5122/switch/__init__.py +++ b/esphome/components/pcm5122/switch.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_POWER_MODE, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from ..audio_dac import CONF_PCM5122, PCM5122 +from .audio_dac import CONF_PCM5122, PCM5122 CONFIG_SCHEMA = switch.switch_schema( switch.ApplySwitch, diff --git a/esphome/components/pipsolar/binary_sensor/__init__.py b/esphome/components/pipsolar/binary_sensor.py similarity index 98% rename from esphome/components/pipsolar/binary_sensor/__init__.py rename to esphome/components/pipsolar/binary_sensor.py index 62c0ed85381..7efca718e24 100644 --- a/esphome/components/pipsolar/binary_sensor/__init__.py +++ b/esphome/components/pipsolar/binary_sensor.py @@ -3,7 +3,7 @@ from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA +from . import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA DEPENDENCIES = ["uart"] diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor.py similarity index 99% rename from esphome/components/pipsolar/sensor/__init__.py rename to esphome/components/pipsolar/sensor.py index 02f9e239c7c..b7044e65a4e 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor.py @@ -30,7 +30,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA +from . import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA DEPENDENCIES = ["uart"] diff --git a/esphome/components/pipsolar/switch/__init__.py b/esphome/components/pipsolar/switch.py similarity index 97% rename from esphome/components/pipsolar/switch/__init__.py rename to esphome/components/pipsolar/switch.py index cf7c4383699..ef26e0b8a88 100644 --- a/esphome/components/pipsolar/switch/__init__.py +++ b/esphome/components/pipsolar/switch.py @@ -8,7 +8,7 @@ from esphome.const import ICON_POWER from esphome.cpp_generator import MockObj from esphome.types import ConfigType -from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA +from . import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA DEPENDENCIES = ["uart"] diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor.py similarity index 94% rename from esphome/components/pipsolar/text_sensor/__init__.py rename to esphome/components/pipsolar/text_sensor.py index 0986120dcfc..5415e1d858d 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor.py @@ -3,7 +3,7 @@ from esphome.components import text_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA +from . import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA DEPENDENCIES = ["uart"] diff --git a/esphome/components/restart/button/__init__.py b/esphome/components/restart/button.py similarity index 100% rename from esphome/components/restart/button/__init__.py rename to esphome/components/restart/button.py diff --git a/esphome/components/restart/switch/__init__.py b/esphome/components/restart/switch.py similarity index 100% rename from esphome/components/restart/switch/__init__.py rename to esphome/components/restart/switch.py diff --git a/esphome/components/safe_mode/button/__init__.py b/esphome/components/safe_mode/button.py similarity index 95% rename from esphome/components/safe_mode/button/__init__.py rename to esphome/components/safe_mode/button.py index 104e02a05c1..56c9d619563 100644 --- a/esphome/components/safe_mode/button/__init__.py +++ b/esphome/components/safe_mode/button.py @@ -8,7 +8,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import SafeModeComponent +from . import SafeModeComponent DEPENDENCIES = ["safe_mode"] diff --git a/esphome/components/safe_mode/switch/__init__.py b/esphome/components/safe_mode/switch.py similarity index 95% rename from esphome/components/safe_mode/switch/__init__.py rename to esphome/components/safe_mode/switch.py index 71003d18afa..327849245d6 100644 --- a/esphome/components/safe_mode/switch/__init__.py +++ b/esphome/components/safe_mode/switch.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_SAFE_MODE, ENTITY_CATEGORY_CONFIG, ICON_RESTART_ALERT from esphome.types import ConfigType -from .. import SafeModeComponent +from . import SafeModeComponent DEPENDENCIES = ["safe_mode"] diff --git a/esphome/components/seeed_mr24hpc1/button/__init__.py b/esphome/components/seeed_mr24hpc1/button.py similarity index 95% rename from esphome/components/seeed_mr24hpc1/button/__init__.py rename to esphome/components/seeed_mr24hpc1/button.py index c25e8381693..21b03623cff 100644 --- a/esphome/components/seeed_mr24hpc1/button/__init__.py +++ b/esphome/components/seeed_mr24hpc1/button.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_MR24HPC1_ID, MR24HPC1Component +from . import CONF_MR24HPC1_ID, MR24HPC1Component CONF_CUSTOM_SET_END = "custom_set_end" diff --git a/esphome/components/seeed_mr24hpc1/number/__init__.py b/esphome/components/seeed_mr24hpc1/number.py similarity index 98% rename from esphome/components/seeed_mr24hpc1/number/__init__.py rename to esphome/components/seeed_mr24hpc1/number.py index 9e0a961bf96..1329151c292 100644 --- a/esphome/components/seeed_mr24hpc1/number/__init__.py +++ b/esphome/components/seeed_mr24hpc1/number.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import CONF_MR24HPC1_ID, MR24HPC1Component +from . import CONF_MR24HPC1_ID, MR24HPC1Component CONF_CUSTOM_MODE = "custom_mode" CONF_EXISTENCE_THRESHOLD = "existence_threshold" diff --git a/esphome/components/seeed_mr24hpc1/select/__init__.py b/esphome/components/seeed_mr24hpc1/select.py similarity index 98% rename from esphome/components/seeed_mr24hpc1/select/__init__.py rename to esphome/components/seeed_mr24hpc1/select.py index 9f9dc270a5a..2c2dd9238ac 100644 --- a/esphome/components/seeed_mr24hpc1/select/__init__.py +++ b/esphome/components/seeed_mr24hpc1/select.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import CONF_MR24HPC1_ID, MR24HPC1Component +from . import CONF_MR24HPC1_ID, MR24HPC1Component CONF_SCENE_MODE = "scene_mode" CONF_UNMAN_TIME = "unman_time" diff --git a/esphome/components/seeed_mr24hpc1/switch/__init__.py b/esphome/components/seeed_mr24hpc1/switch.py similarity index 94% rename from esphome/components/seeed_mr24hpc1/switch/__init__.py rename to esphome/components/seeed_mr24hpc1/switch.py index 9fdbf4a6d8a..b181c2201db 100644 --- a/esphome/components/seeed_mr24hpc1/switch/__init__.py +++ b/esphome/components/seeed_mr24hpc1/switch.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType -from .. import CONF_MR24HPC1_ID, MR24HPC1Component +from . import CONF_MR24HPC1_ID, MR24HPC1Component CONF_UNDERLYING_OPEN_FUNCTION = "underlying_open_function" diff --git a/esphome/components/seeed_mr60fda2/button/__init__.py b/esphome/components/seeed_mr60fda2/button.py similarity index 96% rename from esphome/components/seeed_mr60fda2/button/__init__.py rename to esphome/components/seeed_mr60fda2/button.py index 758f2696103..c11d1726727 100644 --- a/esphome/components/seeed_mr60fda2/button/__init__.py +++ b/esphome/components/seeed_mr60fda2/button.py @@ -10,7 +10,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_MR60FDA2_ID, MR60FDA2Component +from . import CONF_MR60FDA2_ID, MR60FDA2Component DEPENDENCIES = ["seeed_mr60fda2"] diff --git a/esphome/components/seeed_mr60fda2/select/__init__.py b/esphome/components/seeed_mr60fda2/select.py similarity index 97% rename from esphome/components/seeed_mr60fda2/select/__init__.py rename to esphome/components/seeed_mr60fda2/select.py index b7fe5c41a79..e27c18e3e0d 100644 --- a/esphome/components/seeed_mr60fda2/select/__init__.py +++ b/esphome/components/seeed_mr60fda2/select.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_SENSITIVITY, ENTITY_CATEGORY_CONFIG, ICON_ACCELERATION_Z from esphome.types import ConfigType -from .. import CONF_MR60FDA2_ID, MR60FDA2Component +from . import CONF_MR60FDA2_ID, MR60FDA2Component DEPENDENCIES = ["seeed_mr60fda2"] diff --git a/esphome/components/seeed_multi_channel_relay/switch/__init__.py b/esphome/components/seeed_multi_channel_relay/switch.py similarity index 93% rename from esphome/components/seeed_multi_channel_relay/switch/__init__.py rename to esphome/components/seeed_multi_channel_relay/switch.py index 3da2f829837..80101ba7e3e 100644 --- a/esphome/components/seeed_multi_channel_relay/switch/__init__.py +++ b/esphome/components/seeed_multi_channel_relay/switch.py @@ -5,7 +5,7 @@ from esphome.const import CONF_CHANNEL import esphome.final_validate as fv from esphome.types import ConfigType -from .. import CONF_SEEED_MULTI_CHANNEL_RELAY_ID, DOMAIN, SeeedMultiChannelRelay +from . import CONF_SEEED_MULTI_CHANNEL_RELAY_ID, DOMAIN, SeeedMultiChannelRelay DEPENDENCIES = ["seeed_multi_channel_relay"] diff --git a/esphome/components/shutdown/button/__init__.py b/esphome/components/shutdown/button.py similarity index 100% rename from esphome/components/shutdown/button/__init__.py rename to esphome/components/shutdown/button.py diff --git a/esphome/components/shutdown/switch/__init__.py b/esphome/components/shutdown/switch.py similarity index 100% rename from esphome/components/shutdown/switch/__init__.py rename to esphome/components/shutdown/switch.py diff --git a/esphome/components/tas58xx/select/__init__.py b/esphome/components/tas58xx/select.py similarity index 94% rename from esphome/components/tas58xx/select/__init__.py rename to esphome/components/tas58xx/select.py index f4acb168873..45245c65b01 100644 --- a/esphome/components/tas58xx/select/__init__.py +++ b/esphome/components/tas58xx/select.py @@ -6,8 +6,8 @@ from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType -from .. import DOMAIN -from ..audio_dac import ( +from . import DOMAIN, FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from .audio_dac import ( CONF_DAC_MODE, CONF_MIXER_MODE, CONF_TAS58XX_ID, diff --git a/esphome/components/tas58xx/switch/__init__.py b/esphome/components/tas58xx/switch.py similarity index 91% rename from esphome/components/tas58xx/switch/__init__.py rename to esphome/components/tas58xx/switch.py index 80d50335e93..798e7a4a813 100644 --- a/esphome/components/tas58xx/switch/__init__.py +++ b/esphome/components/tas58xx/switch.py @@ -10,7 +10,8 @@ from esphome.const import ( ) from esphome.types import ConfigType -from ..audio_dac import CONF_TAS58XX_ID, TAS58xx +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from .audio_dac import CONF_TAS58XX_ID, TAS58xx CONF_ACTIVATE = "activate" diff --git a/esphome/components/text/text_sensor/__init__.py b/esphome/components/text/text_sensor.py similarity index 96% rename from esphome/components/text/text_sensor/__init__.py rename to esphome/components/text/text_sensor.py index 94981030dda..114de7053ab 100644 --- a/esphome/components/text/text_sensor/__init__.py +++ b/esphome/components/text/text_sensor.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_SOURCE_ID from esphome.types import ConfigType -from .. import Text +from . import Text CONFIG_SCHEMA = text_sensor.text_sensor_schema(text_sensor.TextSensor).extend( { diff --git a/esphome/components/tm1638/output/__init__.py b/esphome/components/tm1638/output.py similarity index 91% rename from esphome/components/tm1638/output/__init__.py rename to esphome/components/tm1638/output.py index 8d09eb6601b..c60573df13e 100644 --- a/esphome/components/tm1638/output/__init__.py +++ b/esphome/components/tm1638/output.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_LED from esphome.types import ConfigType -from ..display import CONF_TM1638_ID, TM1638Component +from .display import CONF_TM1638_ID, TM1638Component CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( { diff --git a/esphome/components/tm1638/switch/__init__.py b/esphome/components/tm1638/switch.py similarity index 91% rename from esphome/components/tm1638/switch/__init__.py rename to esphome/components/tm1638/switch.py index 3b0fc05cb9c..d1430a73dee 100644 --- a/esphome/components/tm1638/switch/__init__.py +++ b/esphome/components/tm1638/switch.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_LED from esphome.types import ConfigType -from ..display import CONF_TM1638_ID, TM1638Component +from .display import CONF_TM1638_ID, TM1638Component CONFIG_SCHEMA = switch.switch_schema(switch.ApplySwitch).extend( { diff --git a/esphome/components/waveshare_io_ch32v003/output/__init__.py b/esphome/components/waveshare_io_ch32v003/output.py similarity index 96% rename from esphome/components/waveshare_io_ch32v003/output/__init__.py rename to esphome/components/waveshare_io_ch32v003/output.py index c3e223378a3..73209b93001 100644 --- a/esphome/components/waveshare_io_ch32v003/output/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/output.py @@ -3,7 +3,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MAX_VALUE, CONF_MIN_VALUE from esphome.types import ConfigType -from .. import CONF_WAVESHARE_IO_CH32V003_ID, WaveshareIOCH32V003Component +from . import CONF_WAVESHARE_IO_CH32V003_ID, WaveshareIOCH32V003Component DEPENDENCIES = ["waveshare_io_ch32v003"] From 195749fb02ddf906c7689102bdd98e9d82da8bc8 Mon Sep 17 00:00:00 2001 From: Jakub Sztandera Date: Sun, 1 Nov 2026 00:54:32 +0100 Subject: [PATCH 029/170] [ledc] Add hardware duty dithering for supported ESP32 variants (#21320) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/ledc/ledc_output.cpp | 46 +++++++++++++++++++++---- esphome/components/ledc/ledc_output.h | 7 ++++ esphome/components/ledc/output.py | 31 ++++++++++++++++- esphome/core/defines.h | 1 + tests/components/ledc/common.yaml | 6 ++++ 5 files changed, 83 insertions(+), 8 deletions(-) diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 7ac11390c25..fbd06100dd8 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -36,6 +36,29 @@ inline ledc_mode_t get_speed_mode(uint8_t channel) { return channel < 8 ? LEDC_H inline ledc_mode_t get_speed_mode(uint8_t) { return LEDC_LOW_SPEED_MODE; } #endif +#if defined(USE_LEDC_DITHERING) || !defined(SOC_LEDC_SUPPORT_FADE_STOP) +static inline ledc_dev_t *ledc_get_hw() { +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) + return LEDC_LL_GET_HW(0); +#else + return LEDC_LL_GET_HW(); +#endif +} +#endif + +#ifdef USE_LEDC_DITHERING +static void ledc_set_duty_fixed_point(ledc_mode_t speed_mode, ledc_channel_t chan_num, uint32_t duty) { + auto *hw = ledc_get_hw(); + // Match each target's ledc_ll_set_duty_int_part(), but retain the four fractional bits. +#if defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S31) + hw->channel_group[speed_mode].channel[chan_num].duty_init.duty = duty; +#else + hw->channel_group[speed_mode].channel[chan_num].duty.duty = duty; +#endif +} +#endif + #if !defined(SOC_LEDC_SUPPORT_FADE_STOP) // Classic ESP32 (currently the only target without SOC_LEDC_SUPPORT_FADE_STOP) can block in // ledc_ll_set_duty_start() while duty_start is set. We check the same conf1.duty_start bit here @@ -53,11 +76,7 @@ static_assert( "re-evaluate for this target"); static bool ledc_duty_update_pending(ledc_mode_t speed_mode, ledc_channel_t chan_num) { -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 1, 0) - auto *hw = LEDC_LL_GET_HW(0); -#else - auto *hw = LEDC_LL_GET_HW(); -#endif + auto *hw = ledc_get_hw(); return hw->channel_group[speed_mode].channel[chan_num].conf1.duty_start != 0; } #endif @@ -138,7 +157,11 @@ void LEDCOutput::write_state(float state) { void LEDCOutput::loop() { this->write_duty_(); } void LEDCOutput::write_duty_() { - const uint32_t max_duty = (uint32_t(1) << this->bit_depth_) - 1; + uint8_t fractional_bits = 0; +#ifdef USE_LEDC_DITHERING + fractional_bits = this->dithering_ ? 4 : 0; +#endif + const uint32_t max_duty = ((uint32_t(1) << this->bit_depth_) - 1) << fractional_bits; const float duty_rounded = roundf(this->duty_ * max_duty); auto duty = static_cast(duty_rounded); if (duty == this->last_duty_) { @@ -164,7 +187,13 @@ void LEDCOutput::write_duty_() { #endif // Zero takes this path too, not ledc_stop(): the update applies at the next PWM cycle, // so the last servo pulse is not truncated into what the servo reads as a new position. - err = ledc_set_duty_with_hpoint(speed_mode, chan_num, duty, hpoint); + err = ledc_set_duty_with_hpoint(speed_mode, chan_num, duty >> fractional_bits, hpoint); +#ifdef USE_LEDC_DITHERING + if (err == ESP_OK && this->dithering_) { + // IDF's integer setter clears the fractional bits. Restore them before latching the update. + ledc_set_duty_fixed_point(speed_mode, chan_num, duty); + } +#endif if (err == ESP_OK) err = ledc_update_duty(speed_mode, chan_num); } @@ -245,6 +274,9 @@ void LEDCOutput::dump_config() { " Phase angle: %.1f°\n" " Bit depth: %u", this->channel_, this->frequency_, this->phase_angle_, this->bit_depth_); +#ifdef USE_LEDC_DITHERING + ESP_LOGCONFIG(TAG, " Hardware dithering: %u fractional bits", this->dithering_ ? 4 : 0); +#endif LOG_PIN(" Pin ", this->pin_); ESP_LOGV(TAG, " Max frequency for bit depth: %f\n" diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index 3510c6fcb89..76477079480 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -19,6 +19,9 @@ class LEDCOutput final : public output::FloatOutput, public Component { void set_channel(uint8_t channel) { this->channel_ = channel; } void set_frequency(float frequency) { this->frequency_ = frequency; } void set_phase_angle(float angle) { this->phase_angle_ = angle; } +#ifdef USE_LEDC_DITHERING + void set_dithering(bool dithering) { this->dithering_ = dithering; } +#endif /// Dynamically change frequency at runtime void update_frequency(float frequency) override; @@ -39,9 +42,13 @@ class LEDCOutput final : public output::FloatOutput, public Component { uint8_t channel_{}; uint8_t bit_depth_{}; bool initialized_ = false; +#ifdef USE_LEDC_DITHERING + bool dithering_{false}; +#endif float phase_angle_{0.0f}; float frequency_{}; float duty_{0.0f}; + // Duty in sixteenths of a timer tick when dithering is enabled, otherwise whole ticks. uint32_t last_duty_{UINT32_MAX}; }; diff --git a/esphome/components/ledc/output.py b/esphome/components/ledc/output.py index a606982a9b8..7415ae5e13a 100644 --- a/esphome/components/ledc/output.py +++ b/esphome/components/ledc/output.py @@ -2,7 +2,7 @@ from typing import Any from esphome import automation, pins import esphome.codegen as cg -from esphome.components import output +from esphome.components import esp32, output from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv from esphome.const import ( @@ -16,6 +16,8 @@ from esphome.types import ConfigType DEPENDENCIES = ["esp32"] +CONF_DITHERING = "dithering" + def calc_max_frequency(bit_depth: int) -> float: return 80e6 / (2**bit_depth) @@ -41,6 +43,29 @@ def validate_frequency(value: Any) -> float: return value +def validate_dithering(value: Any) -> bool: + enabled = cv.boolean(value) + if not enabled: + return False + + return esp32.only_on_variant( + supported=[ + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32C2, + esp32.VARIANT_ESP32C3, # confirmed on hardware + esp32.VARIANT_ESP32C5, + esp32.VARIANT_ESP32C6, + esp32.VARIANT_ESP32C61, + esp32.VARIANT_ESP32H2, + esp32.VARIANT_ESP32P4, + esp32.VARIANT_ESP32S2, + esp32.VARIANT_ESP32S3, + esp32.VARIANT_ESP32S31, + ], + msg_prefix="LEDC hardware dithering", + )(enabled) + + ledc_ns = cg.esphome_ns.namespace("ledc") LEDCOutput = ledc_ns.class_("LEDCOutput", output.FloatOutput, cg.Component) @@ -52,6 +77,7 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( cv.frequency, cv.float_range(min=0, min_included=False) ), cv.Optional(CONF_CHANNEL): cv.int_range(min=0, max=15), + cv.Optional(CONF_DITHERING): validate_dithering, cv.Optional(CONF_PHASE_ANGLE): cv.All( cv.angle, cv.float_range(min=0.0, max=360.0) ), @@ -70,6 +96,9 @@ async def to_code(config: ConfigType) -> None: cg.add_optional(var.set_channel, config.get(CONF_CHANNEL)) cg.add(var.set_frequency(config[CONF_FREQUENCY])) cg.add_optional(var.set_phase_angle, config.get(CONF_PHASE_ANGLE)) + if config.get(CONF_DITHERING): + cg.add_define("USE_LEDC_DITHERING") + cg.add(var.set_dithering(True)) automation.register_apply_action( diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 5611be594db..8050bd16830 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -130,6 +130,7 @@ #define USE_LD2410_BG_CORRECTION #define USE_LD2410S_PARAMETERS #define USE_LD2410S_VERSION +#define USE_LEDC_DITHERING #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT diff --git a/tests/components/ledc/common.yaml b/tests/components/ledc/common.yaml index ad29a3a6dd2..21814618a6f 100644 --- a/tests/components/ledc/common.yaml +++ b/tests/components/ledc/common.yaml @@ -58,3 +58,9 @@ output: number: ${ledc_both_inverted_pin} inverted: true inverted: true + - platform: ledc + id: test_ledc_dithered + pin: 21 + channel: 4 + frequency: 19531Hz + dithering: true From 76912c3a9b4541c1498504f693d39c265594e45e Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:58:16 +1100 Subject: [PATCH 030/170] [rtttl] Build speaker samples from a sine table (#22073) --- esphome/components/rtttl/rtttl.cpp | 44 ++++++++++++++------ esphome/components/rtttl/rtttl.h | 6 ++- tests/components/rtttl/test_rtttl_parser.cpp | 22 ++++++++++ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index 2fe34833ba1..db503c3b8be 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -1,9 +1,7 @@ #include "rtttl.h" #include -#include #include #include -#include #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -29,10 +27,18 @@ static constexpr uint8_t NOTES_COUNT = static_cast(sizeof(NOTES) / size static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10; +// The first quarter of a sine wave at full scale: round(32767 * sin(i * pi / 128)) +static constexpr uint8_t QUARTER_SINE_BITS = 6; +static constexpr uint16_t QUARTER_SINE[(1 << QUARTER_SINE_BITS) + 1] PROGMEM = { + 0, 804, 1608, 2410, 3212, 4011, 4808, 5602, 6393, 7179, 7962, 8739, 9512, + 10278, 11039, 11793, 12539, 13279, 14010, 14732, 15446, 16151, 16846, 17530, 18204, 18868, + 19519, 20159, 20787, 21403, 22005, 22594, 23170, 23731, 24279, 24811, 25329, 25832, 26319, + 26790, 27245, 27683, 28105, 28510, 28898, 29268, 29621, 29956, 30273, 30571, 30852, 31113, + 31356, 31580, 31785, 31971, 32137, 32285, 32412, 32521, 32609, 32678, 32728, 32757, 32767}; + #ifdef USE_SPEAKER static constexpr uint16_t SAMPLE_BUFFER_SIZE = 2048; static constexpr uint16_t SAMPLE_RATE = 16000; -static constexpr float RADIANS_PER_TURN = 2.0f * std::numbers::pi_v; #endif // USE_SPEAKER #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE @@ -120,14 +126,12 @@ void Rtttl::loop() { if (this->samples_sent_ != this->samples_count_) { int16_t sample[SAMPLE_BUFFER_SIZE]; uint16_t sample_index = 0; - // Single precision: ESP32 FPUs have no double support, and a software sin() per sample blocks the loop - const float radians_per_step = this->samples_per_wave_ != 0 ? RADIANS_PER_TURN / this->samples_per_wave_ : 0.0f; while (sample_index < SAMPLE_BUFFER_SIZE && this->samples_sent_ < this->samples_count_) { // Try and send out the remainder of the existing note, one per `loop()` - if (this->samples_per_wave_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note - const uint32_t step = (this->samples_sent_ << 10) % this->samples_per_wave_; - sample[sample_index] = static_cast(INT16_MAX * sinf(step * radians_per_step)); + if (this->phase_step_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note + // The multiplication wraps at exactly one full cycle of the phase + sample[sample_index] = sine_sample(this->samples_sent_ * this->phase_step_); } else { sample[sample_index] = 0; } @@ -241,7 +245,7 @@ void Rtttl::loop() { if (this->speaker_ != nullptr) { this->samples_sent_ = 0; this->samples_gap_ = 0; - this->samples_per_wave_ = 0; + this->phase_step_ = 0; this->samples_count_ = (SAMPLE_RATE * this->note_duration_) / 1000; if (need_note_gap) { this->samples_gap_ = (SAMPLE_RATE * REPEATING_NOTE_GAP_MS) / 1000; @@ -249,13 +253,14 @@ void Rtttl::loop() { if (this->output_freq_ != 0) { // Make sure there is enough samples to add a full last sinus. uint32_t samples_wish = this->samples_count_; - this->samples_per_wave_ = (SAMPLE_RATE << 10) / this->output_freq_; + const uint32_t samples_per_wave = (SAMPLE_RATE << 10) / this->output_freq_; - uint16_t division = ((this->samples_count_ << 10) / this->samples_per_wave_) + 1; + uint16_t division = ((this->samples_count_ << 10) / samples_per_wave) + 1; - this->samples_count_ = (division * this->samples_per_wave_) >> 10; + this->samples_count_ = (division * samples_per_wave) >> 10; + this->phase_step_ = (static_cast(this->output_freq_) << 32) / SAMPLE_RATE; ESP_LOGVV(TAG, "Calc play time: wish: %" PRIu32 " gets: %" PRIu32 " (div: %d spw: %" PRIu32 ")", samples_wish, - this->samples_count_, division, this->samples_per_wave_); + this->samples_count_, division, samples_per_wave); } // Convert from frequency in Hz to high and low samples in fixed point } @@ -264,6 +269,19 @@ void Rtttl::loop() { this->last_note_start_time_ = millis(); } +int16_t Rtttl::sine_sample(uint32_t phase) { + static constexpr uint8_t QUARTER_BITS = 30; + static constexpr uint32_t QUARTER_MASK = (1UL << QUARTER_BITS) - 1; + // The second and fourth quarters of the wave read the table backwards + const uint32_t position = ((phase >> QUARTER_BITS) & 1) != 0 ? ~phase & QUARTER_MASK : phase & QUARTER_MASK; + const uint8_t index = position >> (QUARTER_BITS - QUARTER_SINE_BITS); + const int32_t fraction = (position >> (QUARTER_BITS - QUARTER_SINE_BITS - 16)) & 0xFFFF; + const int32_t low = progmem_read_uint16(&QUARTER_SINE[index]); + const int32_t high = progmem_read_uint16(&QUARTER_SINE[index + 1]); + const int32_t value = low + (((high - low) * fraction) >> 16); + return static_cast((phase >> 31) != 0 ? -value : value); +} + void Rtttl::play(std::string rtttl) { if (this->is_busy_()) { return; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index ade2dcd3205..7a8ab898fc5 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -100,6 +100,8 @@ class Rtttl final : public Component { */ void finish_(); void set_state_(State state); + /// The value of a full scale sine wave at phase, where the full uint32_t range is one cycle. + static int16_t sine_sample(uint32_t phase); /// The song being played; points at owned_song_ or at static storage. const char *song_{nullptr}; @@ -134,8 +136,8 @@ class Rtttl final : public Component { #ifdef USE_SPEAKER /// The speaker to write the sound to. speaker::Speaker *speaker_{nullptr}; - /// The number of samples for one full cycle of a note's waveform, in Q10 fixed-point format. - uint32_t samples_per_wave_{0}; + /// How far the phase moves per sample, where the full uint32_t range is one cycle; 0 for a rest. + uint32_t phase_step_{0}; /// The number of samples sent. uint32_t samples_sent_{0}; /// The total number of samples to send. diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp index 3c706f29877..e3ee18af27e 100644 --- a/tests/components/rtttl/test_rtttl_parser.cpp +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -1,5 +1,8 @@ #include +#include +#include +#include #include #include "esphome/components/rtttl/rtttl.h" @@ -17,12 +20,31 @@ class RtttlParserAccess { static size_t position(const Rtttl &p) { return p.position_; } static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } + static int16_t sine(uint32_t phase) { return Rtttl::sine_sample(phase); } }; using Access = RtttlParserAccess; static const char SONG[] = "test:d=8,o=5,b=100:c"; +// The straight line between table entries strays by up to 2.5 counts; rounding and truncation add up to 1.5 more. +TEST(RtttlSine, MatchesSineWithinFourCounts) { + constexpr uint32_t step = 0x00FFF1C7; // An arbitrary step that visits every part of the table + uint32_t phase = 0; + for (uint32_t i = 0; i != 100000; i++, phase += step) { + const double expected = INT16_MAX * std::sin(phase * (2.0 * std::numbers::pi / 4294967296.0)); + ASSERT_NEAR(Access::sine(phase), expected, 4.0) << "phase " << phase; + } +} + +TEST(RtttlSine, HitsTheQuarterPointsOfTheWave) { + EXPECT_EQ(Access::sine(0), 0); + EXPECT_NEAR(Access::sine(0x40000000), INT16_MAX, 1); + EXPECT_EQ(Access::sine(0x80000000), 0); + EXPECT_NEAR(Access::sine(0xC0000000), -INT16_MAX, 1); + EXPECT_NEAR(Access::sine(0xFFFFFFFF), 0, 1); +} + TEST(RtttlParser, StaticSongParsesTheHeader) { Rtttl player; player.play(StaticSong{SONG}); From fea23e7decae0ed87dcf171ce8a53ad7ada8cb74 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 31 Oct 2026 16:59:23 -0700 Subject: [PATCH 031/170] [light] Refactor ESPColorView and related types (#21577) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../addressable_light_display.cpp | 12 +- .../beken_spi_led_strip/led_strip.cpp | 22 +- .../beken_spi_led_strip/led_strip.h | 21 +- .../esp32_rmt_led_strip/led_strip.cpp | 23 +- .../esp32_rmt_led_strip/led_strip.h | 22 +- .../components/fastled_base/fastled_light.cpp | 4 +- .../components/fastled_base/fastled_light.h | 13 +- .../i2s_clockless_led_strip.cpp | 20 +- .../i2s_clockless_led_strip.h | 16 +- esphome/components/light/__init__.py | 15 +- .../components/light/addressable_light.cpp | 26 +- esphome/components/light/addressable_light.h | 18 +- .../light/addressable_light_wrapper.h | 15 +- esphome/components/light/automation.h | 13 +- esphome/components/light/channel_colors.h | 84 +++- .../components/light/esp_color_correction.cpp | 11 +- .../components/light/esp_color_correction.h | 49 ++- esphome/components/light/esp_color_view.cpp | 7 + esphome/components/light/esp_color_view.h | 309 ++++++++++---- esphome/components/light/esp_range_view.cpp | 42 +- esphome/components/light/esp_range_view.h | 60 +-- .../ln882x_spi_led_strip/led_strip.cpp | 24 +- .../ln882x_spi_led_strip/led_strip.h | 15 +- .../light/m5stack_8angle_light.cpp | 29 +- .../light/m5stack_8angle_light.h | 16 +- .../neopixelbus/neopixelbus_light.h | 47 +-- esphome/components/partition/light.py | 2 + .../components/partition/light_partition.cpp | 28 ++ .../components/partition/light_partition.h | 93 ++-- .../rp2040_pio_led_strip/led_strip.cpp | 32 +- .../rp2040_pio_led_strip/led_strip.h | 21 +- .../spi_led_strip/spi_led_strip.cpp | 34 +- .../components/spi_led_strip/spi_led_strip.h | 8 +- esphome/core/color.h | 23 +- esphome/core/defines.h | 1 + esphome/core/log_const_en.h | 1 + tests/components/light/test_color_view.cpp | 399 ++++++++++++++++++ .../light/test_led_strip_helpers.cpp | 112 +++-- tests/components/light/test_power_limit.cpp | 57 +-- ...> test-power-estimation.esp32-c3-idf.yaml} | 10 +- .../partition/test.esp32-c3-idf.yaml | 5 - .../components/partition/test.esp32-idf.yaml | 26 +- .../mock_addressable_light.h | 15 +- .../components/light/test_channel_colors.py | 14 +- 44 files changed, 1202 insertions(+), 612 deletions(-) create mode 100644 esphome/components/light/esp_color_view.cpp create mode 100644 tests/components/light/test_color_view.cpp rename tests/components/partition/{common-idf.yaml => test-power-estimation.esp32-c3-idf.yaml} (64%) delete mode 100644 tests/components/partition/test.esp32-c3-idf.yaml diff --git a/esphome/components/addressable_light/addressable_light_display.cpp b/esphome/components/addressable_light/addressable_light_display.cpp index 6a3df025e62..607d4a73752 100644 --- a/esphome/components/addressable_light/addressable_light_display.cpp +++ b/esphome/components/addressable_light/addressable_light_display.cpp @@ -22,7 +22,6 @@ void AddressableLightDisplay::update() { void AddressableLightDisplay::display() { bool dirty = false; - uint8_t old_r, old_g, old_b, old_w; Color *c; for (uint32_t offset = 0; offset < this->addressable_light_buffer_.size(); offset++) { @@ -32,16 +31,11 @@ void AddressableLightDisplay::display() { // Track the original values for the pixel view. If it has changed updating, then // we trigger a redraw. Avoiding redraws == avoiding flicker! - old_r = pixel.get_red(); - old_g = pixel.get_green(); - old_b = pixel.get_blue(); - old_w = pixel.get_white(); - - pixel.set_rgbw(c->r, c->g, c->b, c->w); + const Color old_raw_color = pixel.get_raw(); + pixel.set(Color{c->r, c->g, c->b, c->w}); // If the actual value of the pixel changed, then schedule a redraw. - if (pixel.get_red() != old_r || pixel.get_green() != old_g || pixel.get_blue() != old_b || - pixel.get_white() != old_w) { + if (pixel.get_raw() != old_raw_color) { dirty = true; } } diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 58aaab1e21a..4647a294b5f 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -163,17 +163,8 @@ void BekenSPILEDStripLightOutput::setup() { size_t dma_buffer_size = (buffer_size * 8) + (2 * 64); RAMAllocator allocator; - this->buf_ = allocator.allocate(buffer_size); - if (this->buf_ == nullptr) { - ESP_LOGE(TAG, "Cannot allocate LED buffer!"); - this->mark_failed(); - return; - } - - this->effect_data_ = allocator.allocate(this->num_leds_); - if (this->effect_data_ == nullptr) { - ESP_LOGE(TAG, "Cannot allocate effect data!"); - this->mark_failed(); + if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } @@ -184,8 +175,6 @@ void BekenSPILEDStripLightOutput::setup() { return; } - memset(this->buf_, 0, buffer_size); - memset(this->effect_data_, 0, this->num_leds_); memset(this->dma_buf_, 0, dma_buffer_size); uint32_t value = PCLK_POSI_SPI; @@ -291,6 +280,8 @@ void BekenSPILEDStripLightOutput::setup() { // T-like variants: No transfer-length register. The SPI controller shifts out // everything the DMA writes into SPI_DAT. #endif + + this->accessor_ = &this->internal_accessor_; } void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency) { @@ -351,7 +342,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) { size_t buffer_size = this->get_buffer_size_(); size_t size = 0; - uint8_t *psrc = this->buf_; + uint8_t *psrc = this->internal_accessor_.color_data; uint8_t *pdest = this->dma_buf_ + 64; // The 64 byte padding is a workaround for a SPI DMA bug where the // output doesn't exactly start at the beginning of dma_buf_ @@ -381,7 +372,8 @@ void BekenSPILEDStripLightOutput::dump_config() { " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_, this->num_leds_); + this->internal_accessor_.channel_colors.to_string(channel_colors), this->max_refresh_rate_, + this->num_leds_); } float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h index 3b06ad2d18f..b8741d4a342 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.h +++ b/esphome/components/beken_spi_led_strip/led_strip.h @@ -18,33 +18,27 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { float get_setup_priority() const override; int32_t size() const override { return this->num_leds_; } - light::LightTraits get_traits() override { return this->channel_colors_.traits(); } + light::LightTraits get_traits() override { return this->internal_accessor_.channel_colors.traits(); } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } + void set_channel_colors(light::ChannelColors channel_colors) { + this->internal_accessor_.channel_colors = channel_colors; + } /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } void set_led_params(uint8_t bit0, uint8_t bit1, uint32_t spi_frequency); - void clear_effect_data() override { - if (this->effect_data_ != nullptr) - memset(this->effect_data_, 0, this->num_leds_); - } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } void dump_config() override; protected: - light::ESPColorView get_view_internal(int32_t index) const override { - return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_); - } + size_t get_buffer_size_() const { return this->num_leds_ * this->internal_accessor_.channel_colors.bytes_per_led(); } - size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } - - uint8_t *buf_{nullptr}; - uint8_t *effect_data_{nullptr}; + light::CorrectedBufferColorAccessor internal_accessor_{nullptr, nullptr, {0, 1, 2}, &this->correction_}; uint8_t *dma_buf_{nullptr}; uint8_t pin_; @@ -53,7 +47,6 @@ class BekenSPILEDStripLightOutput final : public light::AddressableLight { uint32_t spi_frequency_{6666666}; uint8_t bit0_{0xE0}; uint8_t bit1_{0xFC}; - light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; uint32_t last_refresh_{0}; uint32_t max_refresh_rate_{0}; diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 79d22c9325a..5ad9511cbaf 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -69,18 +69,8 @@ void ESP32RMTLEDStripLightOutput::setup() { size_t buffer_size = this->get_buffer_size_(); RAMAllocator allocator(this->use_psram_ ? 0 : RAMAllocator::ALLOC_INTERNAL); - this->buf_ = allocator.allocate(buffer_size); - if (this->buf_ == nullptr) { - ESP_LOGE(TAG, "Cannot allocate LED buffer!"); - this->mark_failed(); - return; - } - memset(this->buf_, 0, buffer_size); - - this->effect_data_ = allocator.allocate(this->num_leds_); - if (this->effect_data_ == nullptr) { - ESP_LOGE(TAG, "Cannot allocate effect data!"); - this->mark_failed(); + if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } @@ -146,6 +136,8 @@ void ESP32RMTLEDStripLightOutput::setup() { this->mark_failed(); return; } + + this->accessor_ = &this->internal_accessor_; } void ESP32RMTLEDStripLightOutput::set_led_params(uint32_t bit0_high, uint32_t bit0_low, uint32_t bit1_high, @@ -186,13 +178,13 @@ void ESP32RMTLEDStripLightOutput::write_state(light::LightState *state) { ESP_LOGVV(TAG, "Writing RGB values to bus"); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) - memcpy(this->rmt_buf_, this->buf_, this->get_buffer_size_()); + memcpy(this->rmt_buf_, this->internal_accessor_.color_data, this->get_buffer_size_()); #else size_t buffer_size = this->get_buffer_size_(); size_t size = 0; size_t len = 0; - uint8_t *psrc = this->buf_; + uint8_t *psrc = this->internal_accessor_.color_data; rmt_symbol_word_t *pdest = this->rmt_buf_; while (size < buffer_size) { uint8_t b = *psrc; @@ -241,7 +233,8 @@ void ESP32RMTLEDStripLightOutput::dump_config() { " Channel colors: %s\n" " Max refresh rate: %" PRIu32 "\n" " Number of LEDs: %u", - this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_, this->num_leds_); + this->internal_accessor_.channel_colors.to_string(channel_colors), this->max_refresh_rate_, + this->num_leds_); } float ESP32RMTLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 0ec018de44e..f387bb8e3fc 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -29,12 +29,14 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { float get_setup_priority() const override; int32_t size() const override { return this->num_leds_; } - light::LightTraits get_traits() override { return this->channel_colors_.traits(); } + light::LightTraits get_traits() override { return this->internal_accessor_.channel_colors.traits(); } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_inverted(bool inverted) { this->invert_out_ = inverted; } void set_num_leds(uint16_t num_leds) { this->num_leds_ = num_leds; } - void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } + void set_channel_colors(light::ChannelColors channel_colors) { + this->internal_accessor_.channel_colors = channel_colors; + } void set_use_dma(bool use_dma) { this->use_dma_ = use_dma; } void set_use_psram(bool use_psram) { this->use_psram_ = use_psram; } @@ -46,19 +48,12 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { void set_rmt_symbols(uint32_t rmt_symbols) { this->rmt_symbols_ = rmt_symbols; } - void clear_effect_data() override { - if (this->effect_data_ != nullptr) - memset(this->effect_data_, 0, this->num_leds_); - } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } void dump_config() override; protected: - light::ESPColorView get_view_internal(int32_t index) const override { - return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_); - } - - size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } + size_t get_buffer_size_() const { return this->num_leds_ * this->internal_accessor_.channel_colors.bytes_per_led(); } // Small members first so they fill the tail padding of AddressableLight uint16_t num_leds_; @@ -66,10 +61,7 @@ class ESP32RMTLEDStripLightOutput final : public light::AddressableLight { bool use_dma_{false}; bool use_psram_{false}; bool invert_out_{false}; - light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; - - uint8_t *buf_{nullptr}; - uint8_t *effect_data_{nullptr}; + light::CorrectedBufferColorAccessor internal_accessor_{nullptr, nullptr, {0, 1, 2}, &this->correction_}; rmt_channel_handle_t channel_{nullptr}; rmt_encoder_handle_t encoder_{nullptr}; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index 38c594a7c7b..ba4e184ad38 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -10,7 +10,9 @@ ESPHOME_LOG_TAG(TAG, "fastled"); void FastLEDLightOutput::setup() { this->controller_->init(); this->controller_->setLeds(this->leds_, this->num_leds_); - this->effect_data_ = new uint8_t[this->num_leds_]; // NOLINT + this->internal_accessor_.color_data = this->leds_[0].raw; + this->internal_accessor_.effect_data = new uint8_t[this->num_leds_]; // NOLINT + this->accessor_ = &this->internal_accessor_; if (!this->max_refresh_rate_.has_value()) { this->set_max_refresh_rate(this->controller_->getMaxRefreshRate()); } diff --git a/esphome/components/fastled_base/fastled_light.h b/esphome/components/fastled_base/fastled_light.h index 9f903b4530f..f2b204dbcfd 100644 --- a/esphome/components/fastled_base/fastled_light.h +++ b/esphome/components/fastled_base/fastled_light.h @@ -215,20 +215,13 @@ class FastLEDLightOutput final : public light::AddressableLight { void write_state(light::LightState *state) override; float get_setup_priority() const override { return setup_priority::HARDWARE; } - void clear_effect_data() override { - for (int i = 0; i < this->size(); i++) - this->effect_data_[i] = 0; - } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->size()); } protected: - light::ESPColorView get_view_internal(int32_t index) const override { - return {&this->leds_[index].r, &this->leds_[index].g, &this->leds_[index].b, nullptr, - &this->effect_data_[index], &this->correction_}; - } - + light::CorrectedBufferColorAccessor internal_accessor_{ + nullptr, nullptr, {offsetof(CRGB, r), offsetof(CRGB, g), offsetof(CRGB, b)}, &this->correction_}; CLEDController *controller_{nullptr}; CRGB *leds_{nullptr}; - uint8_t *effect_data_{nullptr}; int num_leds_{0}; uint32_t last_refresh_{0}; optional max_refresh_rate_{}; diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp index 9846a74cfeb..ff2fbde18f7 100644 --- a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -14,7 +14,6 @@ namespace esphome::i2s_clockless_led_strip { ESPHOME_LOG_TAG(TAG, "i2s_clockless_led_strip"); -constexpr const char *const ERROR_ALLOCATION = "Allocation error"; constexpr const char *const ERROR_I2S = "I2S error"; // These values are hardcoded for an LED strip at 800 kbps. @@ -47,8 +46,8 @@ constexpr size_t I2S_BUFFER_SAMPLES = (num_i2s_samples_from_duration_us(I2S_BUFF constexpr uint32_t I2S_BUFFER_DURATION_US_ACTUAL = num_i2s_samples_to_duration_us(I2S_BUFFER_SAMPLES); I2SClocklessLedStrip::I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors) - : color_data_bytes_(num_leds * channel_colors.bytes_per_led()), - channel_colors_(channel_colors), + : internal_accessor_{nullptr, nullptr, channel_colors, &this->correction_}, + color_data_bytes_(num_leds * channel_colors.bytes_per_led()), num_leds_(num_leds), pin_(pin) {} @@ -62,7 +61,7 @@ void I2SClocklessLedStrip::dump_config() { " Channel colors: %s\n" " Number of LEDs: %" PRIu16 "\n" " DMA buffer: %" PRIuPTR " samples (%" PRIu32 " us)", - this->channel_colors_.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES, + this->internal_accessor_.channel_colors.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES, I2S_BUFFER_DURATION_US_ACTUAL); } @@ -70,7 +69,7 @@ float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority:: void I2SClocklessLedStrip::setup() { if (!this->allocate_buffers_()) { - this->mark_failed(LOG_STR(ERROR_ALLOCATION)); + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } @@ -132,8 +131,10 @@ void I2SClocklessLedStrip::setup() { ESP_LOGE(TAG, "Error in i2s_channel_register_event_callback: %s", esp_err_to_name(err)); } else { err = i2s_channel_enable(this->tx_handle_); - if (err == ESP_OK) + if (err == ESP_OK) { + this->accessor_ = &this->internal_accessor_; return; // Success! + } ESP_LOGE(TAG, "Error in i2s_channel_enable: %s", esp_err_to_name(err)); } } @@ -149,11 +150,8 @@ bool I2SClocklessLedStrip::allocate_buffers_() { RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); this->i2s_data_ = allocator.allocate(i2s_data_bytes); if (this->i2s_data_ != nullptr) { - // The effect data follows the color data - this->color_data_ = allocator.allocate(this->color_data_bytes_ + this->num_leds_); - if (this->color_data_ != nullptr) { + if (this->internal_accessor_.allocate(allocator, this->num_leds_)) { memset(this->i2s_data_, 0, i2s_data_bytes); - memset(this->color_data_, 0, this->color_data_bytes_ + this->num_leds_); return true; } allocator.deallocate(this->i2s_data_, i2s_data_bytes); @@ -173,7 +171,7 @@ void I2SClocklessLedStrip::write_state(light::LightState *state) { } const size_t color_data_bytes = this->color_data_bytes_; - uint8_t *color_data = this->color_data_; + uint8_t *color_data = this->internal_accessor_.color_data; uint8_t *i2s_data = this->i2s_data_; for (size_t i = 0; i < color_data_bytes; i++) { const uint8_t color_byte = *(color_data++); diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h index 60aa4c5c3f5..ed662553216 100644 --- a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h @@ -28,32 +28,22 @@ class I2SClocklessLedStrip final : public light::AddressableLight { float get_setup_priority() const override; void setup() override; - light::LightTraits get_traits() override { return this->channel_colors_.traits(); } + light::LightTraits get_traits() override { return this->internal_accessor_.channel_colors.traits(); } int32_t size() const override { return this->num_leds_; } - void clear_effect_data() override { - if (uint8_t *effect = this->effect_data_()) - memset(effect, 0, this->num_leds_); - } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } void write_state(light::LightState *state) override; protected: static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); bool allocate_buffers_(); - uint8_t *effect_data_() const { - return this->color_data_ == nullptr ? nullptr : this->color_data_ + this->color_data_bytes_; - } - light::ESPColorView get_view_internal(int32_t index) const override { - return this->channel_colors_.view(this->color_data_, this->effect_data_(), index, &this->correction_); - } + light::CorrectedBufferColorAccessor internal_accessor_; const size_t color_data_bytes_; - uint8_t *color_data_{nullptr}; uint8_t *i2s_data_{nullptr}; size_t i2s_data_sent_{0}; i2s_chan_handle_t tx_handle_{}; - const light::ChannelColors channel_colors_; const uint16_t num_leds_; const uint8_t pin_; std::atomic i2s_data_ready_{false}; diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index dac9e729b95..2ad4f3efdd6 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -249,19 +249,10 @@ def validate_channel_colors(value: str) -> str: return value -def channel_colors_struct(value: str) -> cg.StructInitializer: +def channel_colors_struct(value: str) -> cg.MockObj: """Build the C++ `light::ChannelColors` for a validated channel order string.""" - return cg.StructInitializer( - ChannelColors, - ("r", value.index("R")), - ("g", value.index("G")), - ("b", value.index("B")), - ( - "w", - value.index("W") - if "W" in value - else cg.RawExpression(f"{ChannelColors}::NO_WHITE"), - ), + return ChannelColors( + *(value.index(channel) for channel in "RGBW" if channel in value) ) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 6b73417b6f5..1f064f659c5 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -30,8 +30,8 @@ float AddressableLight::frame_ma_(float *slack_ma) const { uint32_t lit[4]{}; const int32_t count = this->size(); for (int32_t i = 0; i < count; i++) { - const auto view = this->get_view_internal(i); - const uint8_t levels[4] = {view.get_red_raw(), view.get_green_raw(), view.get_blue_raw(), view.get_white_raw()}; + const Color raw_color = this->get(i).get_raw(); + const uint8_t levels[4] = {raw_color.r, raw_color.g, raw_color.b, raw_color.w}; for (size_t channel = 0; channel < 4; channel++) { sums[channel] += levels[channel]; lit[channel] += levels[channel] != 0; @@ -110,9 +110,9 @@ void AddressableLight::limit_power_() { map[level] = rescale_level(level, scale, previous); uint32_t sums[4]{}; for (auto view : *this) { - const uint8_t r = map[view.get_red_raw()], g = map[view.get_green_raw()], b = map[view.get_blue_raw()], - w = map[view.get_white_raw()]; - view.set_raw(r, g, b, w); + const Color raw_color = view.get_raw(); + const uint8_t r = map[raw_color.r], g = map[raw_color.g], b = map[raw_color.b], w = map[raw_color.w]; + view.set_raw(Color{r, g, b, w}); sums[0] += r; sums[1] += g; sums[2] += b; @@ -135,9 +135,8 @@ void AddressableLight::call_setup() { const char *name = this->state_parent_ == nullptr ? "" : LOG_STR_ARG(this->state_parent_->get_log_name()); ESP_LOGVV(TAG, "Addressable Light '%s' (effect_active=%s)", name, YESNO(this->effect_active_)); for (int i = 0; i < this->size(); i++) { - auto color = this->get(i); - ESP_LOGVV(TAG, " [%2d] Color: R=%3u G=%3u B=%3u W=%3u", i, color.get_red_raw(), color.get_green_raw(), - color.get_blue_raw(), color.get_white_raw()); + const Color raw_color = this->get(i).get_raw(); + ESP_LOGVV(TAG, " [%2d] Color: R=%3u G=%3u B=%3u W=%3u", i, raw_color.r, raw_color.g, raw_color.b, raw_color.w); } ESP_LOGVV(TAG, " "); }); @@ -265,16 +264,17 @@ optional AddressableLightTransformer::apply() { uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining); uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining); for (auto led : this->light_) { - led.set_rgbw(r, g, b, w); + led.set(Color{r, g, b, w}); } } else { int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f)); for (auto led : this->light_) { - led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale), - subtract_scaled_difference(this->target_color_.green, led.get_green(), scale), - subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale), - subtract_scaled_difference(this->target_color_.white, led.get_white(), scale)); + const Color color = led.get(); + led.set(Color{subtract_scaled_difference(this->target_color_.red, color.red, scale), + subtract_scaled_difference(this->target_color_.green, color.green, scale), + subtract_scaled_difference(this->target_color_.blue, color.blue, scale), + subtract_scaled_difference(this->target_color_.white, color.white, scale)}); } } this->last_transition_progress_ = smoothed_progress; diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 97fb1419d59..d15ffe0f8d5 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -50,15 +50,13 @@ struct PowerEstimation { class AddressableLight : public LightOutput, public Component { public: virtual int32_t size() const = 0; - ESPColorView operator[](int32_t index) const { return this->get_view_internal(interpret_index(index, this->size())); } - ESPColorView get(int32_t index) { return this->get_view_internal(interpret_index(index, this->size())); } + ESPColorView operator[](int32_t index) const { return ESPColorView(this->accessor_, this->interpret_index_(index)); } + inline ESPColorView get(int32_t index) const { return (*this)[index]; } virtual void clear_effect_data() = 0; ESPRangeView range(int32_t from, int32_t to) { - from = interpret_index(from, this->size()); - to = interpret_index(to, this->size()); - return ESPRangeView(this, from, to); + return ESPRangeView(this->accessor_, this->interpret_index_(from), this->interpret_index_(to)); } - ESPRangeView all() { return ESPRangeView(this, 0, this->size()); } + ESPRangeView all() { return ESPRangeView(this->accessor_, 0, this->size()); } ESPRangeIterator begin() { return this->all().begin(); } ESPRangeIterator end() { return this->all().end(); } void shift_left(int32_t amnt) { @@ -122,10 +120,12 @@ class AddressableLight : public LightOutput, public Component { protected: friend class AddressableLightTransformer; + inline int32_t interpret_index_(int32_t index) const { return index < 0 ? this->size() + index : index; } + void mark_shown_() { #ifdef USE_POWER_SUPPLY - for (const auto &c : *this) { - if (c.get_red_raw() > 0 || c.get_green_raw() > 0 || c.get_blue_raw() > 0 || c.get_white_raw() > 0) { + for (const auto &view : *this) { + if (view.get_raw().is_on()) { this->power_.request(); return; } @@ -133,7 +133,6 @@ class AddressableLight : public LightOutput, public Component { this->power_.unrequest(); #endif } - virtual ESPColorView get_view_internal(int32_t index) const = 0; // Queues the frame for the next loop and returns true until interval_us has passed since last_us bool throttle_show_(uint32_t now_us, uint32_t last_us, uint32_t interval_us) { if (now_us - last_us >= interval_us) @@ -153,6 +152,7 @@ class AddressableLight : public LightOutput, public Component { float draw_ma_(const uint32_t sums[4]) const; #endif + ESPColorAccessor *accessor_{&NullColorAccessor::instance}; ESPColorCorrection correction_{}; LightState *state_parent_{nullptr}; #ifdef USE_LIGHT_POWER_ESTIMATION diff --git a/esphome/components/light/addressable_light_wrapper.h b/esphome/components/light/addressable_light_wrapper.h index cc6aa57905f..6a2f48a2d54 100644 --- a/esphome/components/light/addressable_light_wrapper.h +++ b/esphome/components/light/addressable_light_wrapper.h @@ -7,7 +7,12 @@ namespace esphome::light { class AddressableLightWrapper : public light::AddressableLight { public: - explicit AddressableLightWrapper(light::LightState *light_state) : light_state_(light_state) {} + explicit AddressableLightWrapper(light::LightState *light_state) + : light_state_(light_state), + wrapper_accessor_(this->wrapper_state_, &this->wrapper_state_[4], ChannelColors{0, 1, 2, 3}, + &this->correction_) { + this->accessor_ = &this->wrapper_accessor_; + } int32_t size() const override { return 1; } @@ -109,13 +114,9 @@ class AddressableLightWrapper : public light::AddressableLight { } protected: - light::ESPColorView get_view_internal(int32_t index) const override { - return {&this->wrapper_state_[0], &this->wrapper_state_[1], &this->wrapper_state_[2], - &this->wrapper_state_[3], &this->wrapper_state_[4], &this->correction_}; - } - light::LightState *light_state_; - mutable uint8_t wrapper_state_[5]{}; + CorrectedBufferColorAccessor wrapper_accessor_; + uint8_t wrapper_state_[5]{}; ColorMode color_mode_{ColorMode::UNKNOWN}; }; diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 2a1a2f3cc74..371fcdaef9e 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -165,13 +165,14 @@ template class AddressableSet final : public Action { void play(const Ts &...x) override { auto *out = (AddressableLight *) this->parent_->get_output(); - int32_t range_from = interpret_index(this->range_from_.value_or(x..., 0), out->size()); - if (range_from < 0 || range_from >= out->size()) + const int32_t size = out->size(); + int32_t range_from = interpret_index(this->range_from_.value_or(x..., 0), size); + if (range_from < 0 || range_from >= size) range_from = 0; - int32_t range_to = interpret_index(this->range_to_.value_or(x..., out->size() - 1) + 1, out->size()); - if (range_to < 0 || range_to >= out->size()) - range_to = out->size(); + int32_t range_to = interpret_index(this->range_to_.value_or(x..., size - 1) + 1, size); + if (range_to < 0 || range_to >= size) + range_to = size; uint8_t color_brightness = to_uint8_scale(this->color_brightness_.value_or(x..., this->parent_->remote_values.get_color_brightness())); @@ -190,6 +191,8 @@ template class AddressableSet final : public Action { protected: LightState *parent_; + static inline int32_t interpret_index(int32_t index, int32_t size) { return index < 0 ? index + size : index; } + // Historically, this action required uint8_t (0-255) for RGBW values from lambdas. Keep compatibility. static inline uint8_t to_uint8_compat(float value, const char *field) { if (value > 1.0f) { diff --git a/esphome/components/light/channel_colors.h b/esphome/components/light/channel_colors.h index 8bdbce17419..3b769c82f7b 100644 --- a/esphome/components/light/channel_colors.h +++ b/esphome/components/light/channel_colors.h @@ -1,9 +1,12 @@ #pragma once +#include #include +#include -#include "esp_color_view.h" #include "light_traits.h" +#include "esphome/core/color.h" +#include "esphome/core/helpers.h" namespace esphome::light { @@ -13,23 +16,69 @@ namespace esphome::light { /// position that colour occupies in the bytes the strip expects. `w` is NO_WHITE when /// the strip has no separate white channel. struct ChannelColors { - /// Value of `w` for a strip that only has red, green and blue channels. + /// Value of `w` for an RGB LED strip that uses 3 bytes per led. static constexpr uint8_t NO_WHITE = 0xFF; + /// Value of `w` for an RGB LED strip that uses 4 bytes per led where the 4th byte is reserved. + static constexpr uint8_t RESERVED_WHITE = 0xFE; - uint8_t r; - uint8_t g; - uint8_t b; - uint8_t w; + constexpr ChannelColors() : r(0), g(0), b(0), w(NO_WHITE) {} + constexpr ChannelColors(uint8_t r_offset, uint8_t g_offset, uint8_t b_offset) + : r(r_offset), g(g_offset), b(b_offset), w(NO_WHITE) {} + constexpr ChannelColors(uint8_t r_offset, uint8_t g_offset, uint8_t b_offset, uint8_t w_offset) + : r(r_offset), g(g_offset), b(b_offset), w(w_offset) {} - bool has_white() const { return this->w != NO_WHITE; } + union { + struct { + uint8_t r; + uint8_t g; + uint8_t b; + uint8_t w; + }; + uint8_t raw[4]; + }; - uint8_t bytes_per_led() const { return this->has_white() ? 4 : 3; } + inline bool has_white() const { return (this->w & 0x80) == 0; } + + inline size_t bytes_per_led() const { return this->w == NO_WHITE ? 3 : 4; } + + inline Color get(const uint8_t *color_data, int32_t index) const ESPHOME_ALWAYS_INLINE { + if (this->w == NO_WHITE) + return Color(color_data[index * 3 + this->r], color_data[index * 3 + this->g], color_data[index * 3 + this->b]); + return Color(color_data[index * 4 + this->r], color_data[index * 4 + this->g], color_data[index * 4 + this->b], + this->has_white() ? color_data[index * 4 + this->w] : 0); + } + + uint8_t get_channel(const uint8_t *color_data, int32_t index, uint8_t channel) const { + const uint8_t offset = this->raw[channel]; + return (offset & 0x80) == 0 ? color_data[index * this->bytes_per_led() + offset] : 0; + } + + void set(uint8_t *color_data, int32_t index, Color color) const { + if (this->w == NO_WHITE) { + color_data[index * 3 + this->r] = color.red; + color_data[index * 3 + this->g] = color.green; + color_data[index * 3 + this->b] = color.blue; + } else { + color_data[index * 4 + this->r] = color.red; + color_data[index * 4 + this->g] = color.green; + color_data[index * 4 + this->b] = color.blue; + if (this->has_white()) + color_data[index * 4 + this->w] = color.white; + } + } + + void set_channel(uint8_t *color_data, int32_t index, uint8_t channel, uint8_t value) const { + const uint8_t offset = this->raw[channel]; + if ((offset & 0x80) == 0) + color_data[index * this->bytes_per_led() + offset] = value; + } /// Write the order back out as text, e.g. "GRBW". /// /// `buf` must have room for at least 5 characters. Returns `buf` so the result can be /// passed straight to a log call. const char *to_string(char *buf) const { + memset(buf, 'X', 4); buf[this->r] = 'R'; buf[this->g] = 'G'; buf[this->b] = 'B'; @@ -50,19 +99,12 @@ struct ChannelColors { } return traits; } - - /// View of LED `index` in `buf`; a scratch pixel while `buf` or `effect_data` is unallocated (failed setup()). - ESPColorView view(uint8_t *buf, uint8_t *effect_data, int32_t index, const ESPColorCorrection *correction) const { - static uint8_t scratch[4]; - if (buf == nullptr || effect_data == nullptr) { - buf = scratch; - effect_data = nullptr; - index = 0; - } - uint8_t *led = buf + index * this->bytes_per_led(); - return {led + this->r, led + this->g, led + this->b, this->has_white() ? led + this->w : nullptr, - effect_data + index, correction}; - } }; +static_assert(offsetof(ChannelColors, raw) == COLOR_CHANNEL_R); +static_assert(offsetof(ChannelColors, r) == COLOR_CHANNEL_R); +static_assert(offsetof(ChannelColors, g) == COLOR_CHANNEL_G); +static_assert(offsetof(ChannelColors, b) == COLOR_CHANNEL_B); +static_assert(offsetof(ChannelColors, w) == COLOR_CHANNEL_W); + } // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 567d1c3c0b3..84b6b2fb962 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -26,12 +26,6 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const { return (target - a <= b - target) ? lo : lo + 1; } -Color ESPColorCorrection::color_uncorrect(Color color) const { - // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) - return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green), - this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white)); -} - // Under the limit, gamma and the limit are undone together: the largest level that encodes to at most the // stored one, so a read, change and write of a pixel re-encodes to what was stored uint8_t ESPColorCorrection::uncorrect_level_(uint8_t value) const { @@ -54,12 +48,13 @@ uint8_t ESPColorCorrection::uncorrect_level_(uint8_t value) const { #endif } -uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const { +uint8_t ESPColorCorrection::color_uncorrect_channel(uint8_t channel, uint8_t raw_value) const { + const uint8_t max_brightness = this->max_brightness_[channel]; if (max_brightness == 0 || this->local_brightness_ == 0) return 0; // Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero, // (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs. - uint32_t uncorrected = this->uncorrect_level_(value) * 255UL; + uint32_t uncorrected = this->uncorrect_level_(raw_value) * 255UL; uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_; return static_cast(std::min(res, uint32_t(255))); } diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 46b8e9db5da..4c564ee4416 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -39,41 +39,49 @@ class ESPColorCorrection { uint8_t get_power_brightness() const { return this->power_brightness_; } #endif void set_gamma_table(const uint16_t *table) { this->gamma_table_ = table; } + inline Color color_correct(Color color) const ESPHOME_ALWAYS_INLINE { // corrected = (uncorrected * max_brightness * local_brightness) ^ gamma return Color(this->color_correct_red(color.red), this->color_correct_green(color.green), this->color_correct_blue(color.blue), this->color_correct_white(color.white)); } inline uint8_t color_correct_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - return this->color_correct_channel_(red, this->max_brightness_.red); + return this->color_correct_channel(COLOR_CHANNEL_R, red); } inline uint8_t color_correct_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - return this->color_correct_channel_(green, this->max_brightness_.green); + return this->color_correct_channel(COLOR_CHANNEL_G, green); } inline uint8_t color_correct_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - return this->color_correct_channel_(blue, this->max_brightness_.blue); + return this->color_correct_channel(COLOR_CHANNEL_B, blue); } inline uint8_t color_correct_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - return this->color_correct_channel_(white, this->max_brightness_.white); + return this->color_correct_channel(COLOR_CHANNEL_W, white); } - Color color_uncorrect(Color color) const; - inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE { - return this->color_uncorrect_channel_(red, this->max_brightness_.red); - } - inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE { - return this->color_uncorrect_channel_(green, this->max_brightness_.green); - } - inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE { - return this->color_uncorrect_channel_(blue, this->max_brightness_.blue); - } - inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE { - return this->color_uncorrect_channel_(white, this->max_brightness_.white); + inline uint8_t color_correct_channel(uint8_t channel, uint8_t value) const ESPHOME_ALWAYS_INLINE { + return this->limit_( + this->gamma_correct_(esp_scale8_twice(value, this->max_brightness_[channel], this->local_brightness_))); } - protected: - inline uint8_t color_correct_channel_(uint8_t value, uint8_t max_brightness) const ESPHOME_ALWAYS_INLINE { - return this->limit_(this->gamma_correct_(esp_scale8_twice(value, max_brightness, this->local_brightness_))); + inline Color color_uncorrect(Color raw_color) const { + // uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness) + return Color(this->color_uncorrect_red(raw_color.red), this->color_uncorrect_green(raw_color.green), + this->color_uncorrect_blue(raw_color.blue), this->color_uncorrect_white(raw_color.white)); } + inline uint8_t color_uncorrect_red(uint8_t raw_red) const ESPHOME_ALWAYS_INLINE { + return this->color_uncorrect_channel(COLOR_CHANNEL_R, raw_red); + } + inline uint8_t color_uncorrect_green(uint8_t raw_green) const ESPHOME_ALWAYS_INLINE { + return this->color_uncorrect_channel(COLOR_CHANNEL_G, raw_green); + } + inline uint8_t color_uncorrect_blue(uint8_t raw_blue) const ESPHOME_ALWAYS_INLINE { + return this->color_uncorrect_channel(COLOR_CHANNEL_B, raw_blue); + } + inline uint8_t color_uncorrect_white(uint8_t raw_white) const ESPHOME_ALWAYS_INLINE { + return this->color_uncorrect_channel(COLOR_CHANNEL_W, raw_white); + } + uint8_t color_uncorrect_channel(uint8_t channel, uint8_t raw_value) const; + + protected: // The limit on a gamma-corrected level; rounded up, so a lit channel stays lit inline uint8_t limit_(uint8_t res) const ESPHOME_ALWAYS_INLINE { #ifdef USE_LIGHT_POWER_ESTIMATION @@ -88,9 +96,6 @@ class ESPColorCorrection { uint8_t uncorrect_level_(uint8_t value) const; /// Reverse gamma: binary search the forward PROGMEM table uint8_t gamma_uncorrect_(uint8_t value) const; - /// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line - /// to avoid duplicating two 16-bit divides at every call site. - uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const; const uint16_t *gamma_table_{nullptr}; Color max_brightness_{255, 255, 255, 255}; diff --git a/esphome/components/light/esp_color_view.cpp b/esphome/components/light/esp_color_view.cpp new file mode 100644 index 00000000000..01f2b98543d --- /dev/null +++ b/esphome/components/light/esp_color_view.cpp @@ -0,0 +1,7 @@ +#include "esp_color_view.h" + +namespace esphome::light { + +NullColorAccessor NullColorAccessor::instance{}; + +} // namespace esphome::light diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index f764f7667da..3602feb4b0d 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -1,114 +1,271 @@ #pragma once +#include +#include + #include "esphome/core/color.h" +#include "esphome/core/helpers.h" #include "esp_hsv_color.h" #include "esp_color_correction.h" +#include "channel_colors.h" namespace esphome::light { +/// Abstract interface for accessing color and effect data for an addressable light by index. +/// +/// This interface does not guarantee bit-for-bit round-trip fidelity for setting and getting color and effect data +/// within the underlying buffer. Implementations may transform color data on-the-fly such as to apply brightness +/// scaling, color correction, and quantization. They may also discard unused color channels and effect data +/// altogether and the corresponding getters will return zero. +/// +/// Raw colors are mainly used internally for power supply control, power estimation, and unit tests. +class ESPColorAccessor { + public: + virtual Color get(int32_t index) const = 0; + virtual Color get_raw(int32_t index) const = 0; + virtual uint8_t get_channel(int32_t index, uint8_t channel) const = 0; + virtual uint8_t get_channel_raw(int32_t index, uint8_t channel) const = 0; + virtual uint8_t get_effect_data(int32_t index) const = 0; + + virtual void set(int32_t index, Color color) = 0; + virtual void set_raw(int32_t index, Color raw_color) = 0; + virtual void set_channel(int32_t index, uint8_t channel, uint8_t value) = 0; + virtual void set_channel_raw(int32_t index, uint8_t channel, uint8_t raw_value) = 0; + virtual void set_effect_data(int32_t index, uint8_t value) = 0; + +#if defined(USE_LIGHT_PARTITION) && defined(USE_LIGHT_POWER_ESTIMATION) + virtual const ESPColorCorrection *get_color_correction_internal(int32_t index) const { return nullptr; } +#endif +}; + +/// Color accessor that is completely at peace with the void of nothingness. +class NullColorAccessor final : public ESPColorAccessor { + public: + /// Singleton instance of this accessor. + static NullColorAccessor instance; + + Color get(int32_t index) const override { return {}; } + Color get_raw(int32_t index) const override { return {}; } + uint8_t get_channel(int32_t index, uint8_t channel) const override { return 0; } + uint8_t get_channel_raw(int32_t index, uint8_t channel) const override { return 0; } + uint8_t get_effect_data(int32_t index) const override { return 0; } + + void set(int32_t index, Color color) override {} + void set_raw(int32_t index, Color raw_color) override {} + void set_channel(int32_t index, uint8_t channel, uint8_t value) override {} + void set_channel_raw(int32_t index, uint8_t channel, uint8_t raw_value) override {} + void set_effect_data(int32_t index, uint8_t value) override {} +}; + +/// Base class for color accessors backed by a buffer whose layout is described by ChannelColors. +class BufferColorAccessor : public ESPColorAccessor { + public: + BufferColorAccessor() = default; + BufferColorAccessor(uint8_t *color_data, uint8_t *effect_data, ChannelColors channel_colors) + : color_data(color_data), effect_data(effect_data), channel_colors(channel_colors) {} + + Color get_raw(int32_t index) const override { return this->channel_colors.get(this->color_data, index); } + uint8_t get_channel_raw(int32_t index, uint8_t channel) const override { + return this->channel_colors.get_channel(this->color_data, index, channel); + } + uint8_t get_effect_data(int32_t index) const override { return this->effect_data[index]; } + + void set_raw(int32_t index, Color raw_color) override { + this->channel_colors.set(this->color_data, index, raw_color); + } + void set_channel_raw(int32_t index, uint8_t channel, uint8_t raw_value) override { + this->channel_colors.set_channel(this->color_data, index, channel, raw_value); + } + void set_effect_data(int32_t index, uint8_t value) override { this->effect_data[index] = value; } + + /// Allocate the color and effect data for `num_leds` LEDs as one zeroed block, followed by `extra_bytes` more + /// for the caller, which start at `effect_data + num_leds`. Returns false if it failed. + bool allocate(RAMAllocator &allocator, size_t num_leds, size_t extra_bytes = 0) { + const size_t color_bytes = num_leds * this->channel_colors.bytes_per_led(); + const size_t total_bytes = color_bytes + num_leds + extra_bytes; + uint8_t *buf = allocator.allocate(total_bytes); + if (buf == nullptr) + return false; + memset(buf, 0, total_bytes); + this->color_data = buf; + this->effect_data = buf + color_bytes; + return true; + } + + /// Zero the effect data of the first `num_leds` LEDs; does nothing before the effect data is allocated. + void clear_effect_data(size_t num_leds) { + if (this->effect_data != nullptr) + memset(this->effect_data, 0, num_leds); + } + + // These fields must all be initialized to valid (non-null) values prior to using the accessor. + uint8_t *color_data{nullptr}; + uint8_t *effect_data{nullptr}; + ChannelColors channel_colors{}; +}; + +/// Color accessor backed by a buffer whose contents have no color correction applied. +class RawBufferColorAccessor final : public BufferColorAccessor { + public: + RawBufferColorAccessor() = default; + RawBufferColorAccessor(uint8_t *color_data, uint8_t *effect_data, ChannelColors channel_colors) + : BufferColorAccessor(color_data, effect_data, channel_colors) {} + + Color get(int32_t index) const override { return this->get_raw(index); } + uint8_t get_channel(int32_t index, uint8_t channel) const override { return this->get_channel_raw(index, channel); } + + void set(int32_t index, Color color) override { this->set_raw(index, color); } + void set_channel(int32_t index, uint8_t channel, uint8_t raw_value) override { + this->set_channel_raw(index, channel, raw_value); + } +}; + +/// Color accessor backed by a buffer whose contents have color correction applied. +class CorrectedBufferColorAccessor final : public BufferColorAccessor { + public: + CorrectedBufferColorAccessor() = default; + CorrectedBufferColorAccessor(uint8_t *color_data, uint8_t *effect_data, ChannelColors channel_colors, + const ESPColorCorrection *color_correction) + : BufferColorAccessor(color_data, effect_data, channel_colors), color_correction(color_correction) {} + + Color get(int32_t index) const override { + const Color raw_color = this->channel_colors.get(this->color_data, index); + const ESPColorCorrection *correction = this->color_correction; + // Skip white when the strip has none; uncorrecting its zero is wasted work on every read + return Color(correction->color_uncorrect_red(raw_color.red), correction->color_uncorrect_green(raw_color.green), + correction->color_uncorrect_blue(raw_color.blue), + this->channel_colors.has_white() ? correction->color_uncorrect_white(raw_color.white) : 0); + } + uint8_t get_channel(int32_t index, uint8_t channel) const override { + return this->color_correction->color_uncorrect_channel(channel, this->get_channel_raw(index, channel)); + } + + void set(int32_t index, Color color) override { this->set_raw(index, this->color_correction->color_correct(color)); } + void set_channel(int32_t index, uint8_t channel, uint8_t value) override { + this->set_channel_raw(index, channel, this->color_correction->color_correct_channel(channel, value)); + } + +#if defined(USE_LIGHT_PARTITION) && defined(USE_LIGHT_POWER_ESTIMATION) + const ESPColorCorrection *get_color_correction_internal(int32_t index) const override { + return this->color_correction; + } +#endif + + // Must be initialized to a valid non-null value prior to using the accessor. + const ESPColorCorrection *color_correction{nullptr}; +}; + +/// Abstract interface that supports setting colors and effect data. +/// May set zero, one, or more pixels according to the implementation. class ESPColorSettable { public: - virtual void set(const Color &color) = 0; - virtual void set_red(uint8_t red) = 0; - virtual void set_green(uint8_t green) = 0; - virtual void set_blue(uint8_t blue) = 0; - virtual void set_white(uint8_t white) = 0; - virtual void set_effect_data(uint8_t effect_data) = 0; + virtual void set(Color color) = 0; + virtual void set_channel(uint8_t channel, uint8_t value) = 0; + virtual void set_effect_data(uint8_t value) = 0; + virtual void fade_to_white(uint8_t amnt) = 0; virtual void fade_to_black(uint8_t amnt) = 0; virtual void lighten(uint8_t delta) = 0; virtual void darken(uint8_t delta) = 0; - void set(const ESPHSVColor &color) { this->set_hsv(color); } - void set_hsv(const ESPHSVColor &color) { this->set(color.to_rgb()); } + + inline void set_red(uint8_t red) ESPHOME_ALWAYS_INLINE { this->set_channel(COLOR_CHANNEL_R, red); } + inline void set_green(uint8_t green) ESPHOME_ALWAYS_INLINE { this->set_channel(COLOR_CHANNEL_G, green); } + inline void set_blue(uint8_t blue) ESPHOME_ALWAYS_INLINE { this->set_channel(COLOR_CHANNEL_B, blue); } + inline void set_white(uint8_t white) ESPHOME_ALWAYS_INLINE { this->set_channel(COLOR_CHANNEL_W, white); } + void set_rgb(uint8_t red, uint8_t green, uint8_t blue) { + // This method should perhaps be deprecated because it's slower than `set(Color)` + // in the common case where the intent is to set all color channels of the pixel at once + // and there just happens to not be any white. this->set_red(red); this->set_green(green); this->set_blue(blue); } - void set_rgbw(uint8_t red, uint8_t green, uint8_t blue, uint8_t white) { - this->set_rgb(red, green, blue); - this->set_white(white); - } + void set_rgbw(uint8_t red, uint8_t green, uint8_t blue, uint8_t white) { this->set(Color(red, green, blue, white)); } + + ESPDEPRECATED("Use set_hsv(hsv_color) instead. Removed in 2027.5.0.", "2026.11.0") + void set(ESPHSVColor hsv_color) { this->set(hsv_color.to_rgb()); } + void set_hsv(ESPHSVColor hsv_color) { this->set(hsv_color.to_rgb()); } }; -class ESPColorView : public ESPColorSettable { +/// Gets or sets the color or effect data at a specific index within an color accessor. +/// +/// To improve performance, prefer calling `get()` and `set(Color)` when you intend to access all color +/// channel at once instead of calling the getters and setters for each color channel individually. +class ESPColorView final : public ESPColorSettable { public: - ESPColorView(uint8_t *red, uint8_t *green, uint8_t *blue, uint8_t *white, uint8_t *effect_data, - const ESPColorCorrection *color_correction) - : red_(red), - green_(green), - blue_(blue), - white_(white), - effect_data_(effect_data), - color_correction_(color_correction) {} - ESPColorView &operator=(const Color &rhs) { - this->set(rhs); + ESPColorView(ESPColorAccessor *accessor, int32_t index) : accessor_(accessor), index_(index) {} + ESPColorView(const ESPColorView &) = default; + + inline ESPColorView &operator=(Color color) { + this->set(color); return *this; } - ESPColorView &operator=(const ESPHSVColor &rhs) { - this->set_hsv(rhs); + inline ESPColorView &operator=(ESPHSVColor hsv_color) { + this->set_hsv(hsv_color); return *this; } - void set(const Color &color) override { this->set_rgbw(color.r, color.g, color.b, color.w); } - void set_red(uint8_t red) override { *this->red_ = this->color_correction_->color_correct_red(red); } - void set_green(uint8_t green) override { *this->green_ = this->color_correction_->color_correct_green(green); } - void set_blue(uint8_t blue) override { *this->blue_ = this->color_correction_->color_correct_blue(blue); } - void set_white(uint8_t white) override { - if (this->white_ == nullptr) - return; - *this->white_ = this->color_correction_->color_correct_white(white); + ESPColorView &operator=(const ESPColorView &other) { // NOLINT + const Color color = other.get(); + this->set(color); + return *this; } - void set_effect_data(uint8_t effect_data) override { - if (this->effect_data_ == nullptr) - return; - *this->effect_data_ = effect_data; + + void set(Color color) override { this->accessor_->set(this->index_, color); } + void set_channel(uint8_t channel, uint8_t value) override { + this->accessor_->set_channel(this->index_, channel, value); } + + void set_raw(Color raw_color) { this->accessor_->set_raw(this->index_, raw_color); } + void set_channel_raw(uint8_t channel, uint8_t raw_value) { + this->accessor_->set_channel_raw(this->index_, channel, raw_value); + } + + inline void set_red_raw(uint8_t raw_red) ESPHOME_ALWAYS_INLINE { this->set_channel_raw(COLOR_CHANNEL_R, raw_red); } + inline void set_green_raw(uint8_t raw_green) ESPHOME_ALWAYS_INLINE { + this->set_channel_raw(COLOR_CHANNEL_G, raw_green); + } + inline void set_blue_raw(uint8_t raw_blue) ESPHOME_ALWAYS_INLINE { this->set_channel_raw(COLOR_CHANNEL_B, raw_blue); } + inline void set_white_raw(uint8_t raw_white) ESPHOME_ALWAYS_INLINE { + this->set_channel_raw(COLOR_CHANNEL_W, raw_white); + } + + void set_effect_data(uint8_t value) override { this->accessor_->set_effect_data(this->index_, value); } + void fade_to_white(uint8_t amnt) override { this->set(this->get().fade_to_white(amnt)); } void fade_to_black(uint8_t amnt) override { this->set(this->get().fade_to_black(amnt)); } void lighten(uint8_t delta) override { this->set(this->get().lighten(delta)); } void darken(uint8_t delta) override { this->set(this->get().darken(delta)); } - Color get() const { return Color(this->get_red(), this->get_green(), this->get_blue(), this->get_white()); } - uint8_t get_red() const { return this->color_correction_->color_uncorrect_red(*this->red_); } - uint8_t get_red_raw() const { return *this->red_; } - uint8_t get_green() const { return this->color_correction_->color_uncorrect_green(*this->green_); } - uint8_t get_green_raw() const { return *this->green_; } - uint8_t get_blue() const { return this->color_correction_->color_uncorrect_blue(*this->blue_); } - uint8_t get_blue_raw() const { return *this->blue_; } - uint8_t get_white() const { - if (this->white_ == nullptr) - return 0; - return this->color_correction_->color_uncorrect_white(*this->white_); - } - uint8_t get_white_raw() const { - if (this->white_ == nullptr) - return 0; - return *this->white_; - } - uint8_t get_effect_data() const { - if (this->effect_data_ == nullptr) - return 0; - return *this->effect_data_; - } - const ESPColorCorrection *get_color_correction() const { return this->color_correction_; } - void raw_set_color_correction(const ESPColorCorrection *color_correction) { - this->color_correction_ = color_correction; - } - /// Store hardware levels as they are, without the correction - void set_raw(uint8_t red, uint8_t green, uint8_t blue, uint8_t white) { - *this->red_ = red; - *this->green_ = green; - *this->blue_ = blue; - if (this->white_ != nullptr) - *this->white_ = white; + + Color get() const { return this->accessor_->get(this->index_); } + uint8_t get_channel(uint8_t channel) const { return this->accessor_->get_channel(this->index_, channel); } + + inline uint8_t get_red() const ESPHOME_ALWAYS_INLINE { return this->get_channel(COLOR_CHANNEL_R); } + inline uint8_t get_green() const ESPHOME_ALWAYS_INLINE { return this->get_channel(COLOR_CHANNEL_G); } + inline uint8_t get_blue() const ESPHOME_ALWAYS_INLINE { return this->get_channel(COLOR_CHANNEL_B); } + inline uint8_t get_white() const ESPHOME_ALWAYS_INLINE { return this->get_channel(COLOR_CHANNEL_W); } + + Color get_raw() const { return this->accessor_->get_raw(this->index_); } + uint8_t get_channel_raw(uint8_t channel) const { return this->accessor_->get_channel_raw(this->index_, channel); } + + inline uint8_t get_red_raw() const ESPHOME_ALWAYS_INLINE { return this->get_channel_raw(COLOR_CHANNEL_R); } + inline uint8_t get_green_raw() const ESPHOME_ALWAYS_INLINE { return this->get_channel_raw(COLOR_CHANNEL_G); } + inline uint8_t get_blue_raw() const ESPHOME_ALWAYS_INLINE { return this->get_channel_raw(COLOR_CHANNEL_B); } + inline uint8_t get_white_raw() const ESPHOME_ALWAYS_INLINE { return this->get_channel_raw(COLOR_CHANNEL_W); } + + uint8_t get_effect_data() const { return this->accessor_->get_effect_data(this->index_); } + +#if defined(USE_LIGHT_PARTITION) && defined(USE_LIGHT_POWER_ESTIMATION) + // This method is for internal use only. + // Get the color correction applied by the underlying accessor or nullptr if there is none. + const ESPColorCorrection *get_color_correction_internal() const { + return this->accessor_->get_color_correction_internal(this->index_); } +#endif protected: - uint8_t *const red_; - uint8_t *const green_; - uint8_t *const blue_; - uint8_t *const white_; - uint8_t *const effect_data_; - const ESPColorCorrection *color_correction_; + ESPColorAccessor *const accessor_; + const int32_t index_; }; } // namespace esphome::light diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index fe7c4a086eb..90e5e9dc3a4 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -1,40 +1,14 @@ #include "esp_range_view.h" -#include "addressable_light.h" namespace esphome::light { -int32_t HOT interpret_index(int32_t index, int32_t size) { - if (index < 0) - return size + index; - return index; +void ESPRangeView::set(Color color) { + for (int32_t i = this->begin_; i < this->end_; i++) + this->accessor_->set(i, color); } - -ESPColorView ESPRangeView::operator[](int32_t index) const { - index = interpret_index(index, this->size()) + this->begin_; - return (*this->parent_)[index]; -} - -void ESPRangeView::set(const Color &color) { - for (int32_t i = this->begin_; i < this->end_; i++) { - (*this->parent_)[i] = color; - } -} - -void ESPRangeView::set_red(uint8_t red) { - for (auto c : *this) - c.set_red(red); -} -void ESPRangeView::set_green(uint8_t green) { - for (auto c : *this) - c.set_green(green); -} -void ESPRangeView::set_blue(uint8_t blue) { - for (auto c : *this) - c.set_blue(blue); -} -void ESPRangeView::set_white(uint8_t white) { - for (auto c : *this) - c.set_white(white); +void ESPRangeView::set_channel(uint8_t channel, uint8_t value) { + for (int32_t i = this->begin_; i < this->end_; i++) + this->accessor_->set_channel(i, channel, value); } void ESPRangeView::set_effect_data(uint8_t effect_data) { for (auto c : *this) @@ -62,7 +36,7 @@ ESPRangeView &ESPRangeView::operator=(const ESPRangeView &rhs) { // NOLINT if (rhs.size() != this->size()) return *this; - if (this->parent_ != rhs.parent_) { + if (this->accessor_ != rhs.accessor_) { for (int32_t i = 0; i < this->size(); i++) (*this)[i].set(rhs[i].get()); return *this; @@ -90,6 +64,4 @@ ESPRangeView &ESPRangeView::operator=(const ESPRangeView &rhs) { // NOLINT return *this; } -ESPColorView ESPRangeIterator::operator*() const { return this->range_.parent_->get(this->i_); } - } // namespace esphome::light diff --git a/esphome/components/light/esp_range_view.h b/esphome/components/light/esp_range_view.h index ec129bdf705..5114ffe149b 100644 --- a/esphome/components/light/esp_range_view.h +++ b/esphome/components/light/esp_range_view.h @@ -5,31 +5,28 @@ namespace esphome::light { -int32_t interpret_index(int32_t index, int32_t size); - -class AddressableLight; class ESPRangeIterator; /** * A half-open range of LEDs, inclusive of the begin index and exclusive of the end index, using zero-based numbering. */ -class ESPRangeView : public ESPColorSettable { +class ESPRangeView final : public ESPColorSettable { public: - ESPRangeView(AddressableLight *parent, int32_t begin, int32_t end) - : parent_(parent), begin_(begin), end_(end < begin ? begin : end) {} + ESPRangeView(ESPColorAccessor *accessor, int32_t begin, int32_t end) + : accessor_(accessor), begin_(begin), end_(end < begin ? begin : end) {} ESPRangeView(const ESPRangeView &) = default; int32_t size() const { return this->end_ - this->begin_; } - ESPColorView operator[](int32_t index) const; + ESPColorView operator[](int32_t index) const { + return ESPColorView(this->accessor_, this->begin_ + this->interpret_index_(index)); + } ESPRangeIterator begin(); ESPRangeIterator end(); - void set(const Color &color) override; - void set(const ESPHSVColor &color) { this->set(color.to_rgb()); } - void set_red(uint8_t red) override; - void set_green(uint8_t green) override; - void set_blue(uint8_t blue) override; - void set_white(uint8_t white) override; + using ESPColorSettable::set; // ESPHSVColor overload + void set(Color color) override; + void set_channel(uint8_t channel, uint8_t value) override; + void set_effect_data(uint8_t effect_data) override; void fade_to_white(uint8_t amnt) override; @@ -37,16 +34,17 @@ class ESPRangeView : public ESPColorSettable { void lighten(uint8_t delta) override; void darken(uint8_t delta) override; - ESPRangeView &operator=(const Color &rhs) { - this->set(rhs); + ESPRangeView &operator=(Color color) { + this->set(color); return *this; } - ESPRangeView &operator=(const ESPColorView &rhs) { - this->set(rhs.get()); + ESPRangeView &operator=(ESPHSVColor hsv_color) { + this->set_hsv(hsv_color); return *this; } - ESPRangeView &operator=(const ESPHSVColor &rhs) { - this->set_hsv(rhs); + ESPRangeView &operator=(const ESPColorView &color_view) { + const Color color = color_view.get(); + this->set(color); return *this; } ESPRangeView &operator=(const ESPRangeView &rhs); @@ -54,28 +52,30 @@ class ESPRangeView : public ESPColorSettable { protected: friend ESPRangeIterator; - AddressableLight *parent_; - int32_t begin_; - int32_t end_; + inline int32_t interpret_index_(int32_t index) const { return index < 0 ? this->size() + index : index; } + + ESPColorAccessor *const accessor_; + const int32_t begin_; + const int32_t end_; }; class ESPRangeIterator { public: - ESPRangeIterator(const ESPRangeView &range, int32_t i) : range_(range), i_(i) {} + ESPRangeIterator(ESPColorAccessor *accessor, int32_t index) : accessor_(accessor), index_(index) {} ESPRangeIterator(const ESPRangeIterator &) = default; ESPRangeIterator operator++() { - this->i_++; + this->index_++; return *this; } - bool operator!=(const ESPRangeIterator &other) const { return this->i_ != other.i_; } - ESPColorView operator*() const; + bool operator!=(const ESPRangeIterator &other) const { return this->index_ != other.index_; } + ESPColorView operator*() const { return ESPColorView(this->accessor_, this->index_); } protected: - ESPRangeView range_; - int32_t i_; + ESPColorAccessor *const accessor_; + int32_t index_; }; -inline ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; } -inline ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; } +inline ESPRangeIterator ESPRangeView::begin() { return {this->accessor_, this->begin_}; } +inline ESPRangeIterator ESPRangeView::end() { return {this->accessor_, this->end_}; } } // namespace esphome::light diff --git a/esphome/components/ln882x_spi_led_strip/led_strip.cpp b/esphome/components/ln882x_spi_led_strip/led_strip.cpp index 5825466948c..42d4d831a3e 100644 --- a/esphome/components/ln882x_spi_led_strip/led_strip.cpp +++ b/esphome/components/ln882x_spi_led_strip/led_strip.cpp @@ -90,10 +90,10 @@ LN882xSPILEDStripLightOutput::LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t light::ChannelColors channel_colors, uint8_t prescaler, uint8_t bit0_high_bits, uint8_t bit1_high_bits, uint16_t reset_bytes) - : color_data_bytes_(num_leds * channel_colors.bytes_per_led()), + : internal_accessor_(nullptr, nullptr, channel_colors, &this->correction_), + color_data_bytes_(num_leds * channel_colors.bytes_per_led()), // The tail of the DMA buffer stays zero: it is the reset gap. dma_data_bytes_(color_data_bytes_ * SPI_BITS_PER_LED_BIT + reset_bytes), - channel_colors_(channel_colors), num_leds_(num_leds), pin_(pin), prescaler_(prescaler), @@ -110,17 +110,15 @@ void LN882xSPILEDStripLightOutput::setup() { // One block for the color, effect and DMA data. RAMAllocator allocator; - uint8_t *buffer = allocator.allocate(this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_); - if (buffer == nullptr) { - this->mark_failed(LOG_STR("Allocation error")); + if (!this->internal_accessor_.allocate(allocator, this->num_leds_, this->dma_data_bytes_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } - memset(buffer, 0, this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_); - this->color_data_ = buffer; - this->effect_data_ = buffer + this->color_data_bytes_; - this->dma_data_ = this->effect_data_ + this->num_leds_; + this->dma_data_ = this->internal_accessor_.effect_data + this->num_leds_; spi_dma_init(this->pin_, this->prescaler_); + + this->accessor_ = &this->internal_accessor_; } void LN882xSPILEDStripLightOutput::dump_config() { @@ -134,9 +132,9 @@ void LN882xSPILEDStripLightOutput::dump_config() { " Max refresh rate: %" PRIu32 " us\n" " SPI frequency: %" PRIu32 " Hz\n" " Bit 0 / bit 1 high: %" PRIu32 " / %" PRIu32 " ns of %" PRIu32 " ns", - this->pin_, this->channel_colors_.to_string(channel_colors), this->num_leds_, this->max_refresh_rate_, - APB0_HZ >> this->prescaler_, this->bit0_high_bits_ * spi_bit_ns, this->bit1_high_bits_ * spi_bit_ns, - SPI_BITS_PER_LED_BIT * spi_bit_ns); + this->pin_, this->internal_accessor_.channel_colors.to_string(channel_colors), this->num_leds_, + this->max_refresh_rate_, APB0_HZ >> this->prescaler_, this->bit0_high_bits_ * spi_bit_ns, + this->bit1_high_bits_ * spi_bit_ns, SPI_BITS_PER_LED_BIT * spi_bit_ns); } float LN882xSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } @@ -160,7 +158,7 @@ void LN882xSPILEDStripLightOutput::write_state(light::LightState *state) { const uint8_t bit0 = spi_pattern(this->bit0_high_bits_); const uint8_t bit1 = spi_pattern(this->bit1_high_bits_); - const uint8_t *color_data = this->color_data_; + const uint8_t *color_data = this->internal_accessor_.color_data; uint8_t *dma_data = this->dma_data_; for (size_t i = 0; i < this->color_data_bytes_; i++) { const uint8_t color_byte = *(color_data++); diff --git a/esphome/components/ln882x_spi_led_strip/led_strip.h b/esphome/components/ln882x_spi_led_strip/led_strip.h index 21fa40c5d07..639d1a51db9 100644 --- a/esphome/components/ln882x_spi_led_strip/led_strip.h +++ b/esphome/components/ln882x_spi_led_strip/led_strip.h @@ -23,31 +23,22 @@ class LN882xSPILEDStripLightOutput final : public light::AddressableLight { void dump_config() override; float get_setup_priority() const override; - light::LightTraits get_traits() override { return this->channel_colors_.traits(); } + light::LightTraits get_traits() override { return this->internal_accessor_.channel_colors.traits(); } int32_t size() const override { return this->num_leds_; } - void clear_effect_data() override { - if (this->effect_data_ != nullptr) - memset(this->effect_data_, 0, this->num_leds_); - } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } void write_state(light::LightState *state) override; /// Set a maximum refresh rate in µs as some lights do not like being updated too often. void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; } protected: - light::ESPColorView get_view_internal(int32_t index) const override { - return this->channel_colors_.view(this->color_data_, this->effect_data_, index, &this->correction_); - } - + light::CorrectedBufferColorAccessor internal_accessor_; const size_t color_data_bytes_; const size_t dma_data_bytes_; - uint8_t *color_data_{nullptr}; - uint8_t *effect_data_{nullptr}; uint8_t *dma_data_{nullptr}; uint32_t last_refresh_{0}; uint32_t max_refresh_rate_{0}; - const light::ChannelColors channel_colors_; const uint16_t num_leds_; const uint8_t pin_; const uint8_t prescaler_; diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp index 65b5816e46e..1f2f5fd8f38 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp @@ -6,38 +6,13 @@ namespace esphome::m5stack_8angle { ESPHOME_LOG_TAG(TAG, "m5stack_8angle.light"); -void M5Stack8AngleLightOutput::setup() { - RAMAllocator allocator; - this->buf_ = allocator.allocate(M5STACK_8ANGLE_NUM_LEDS * M5STACK_8ANGLE_BYTES_PER_LED); - if (this->buf_ == nullptr) { - ESP_LOGE(TAG, "Failed to allocate buffer of size %u", M5STACK_8ANGLE_NUM_LEDS * M5STACK_8ANGLE_BYTES_PER_LED); - this->mark_failed(); - return; - }; - memset(this->buf_, 0xFF, M5STACK_8ANGLE_NUM_LEDS * M5STACK_8ANGLE_BYTES_PER_LED); - - this->effect_data_ = allocator.allocate(M5STACK_8ANGLE_NUM_LEDS); - if (this->effect_data_ == nullptr) { - ESP_LOGE(TAG, "Failed to allocate effect data of size %u", M5STACK_8ANGLE_NUM_LEDS); - this->mark_failed(); - return; - }; - memset(this->effect_data_, 0x00, M5STACK_8ANGLE_NUM_LEDS); -} - void M5Stack8AngleLightOutput::write_state(light::LightState *state) { for (int i = 0; i < M5STACK_8ANGLE_NUM_LEDS; i++) { // write one LED at a time, otherwise the message will be truncated this->parent_->write_register(M5STACK_8ANGLE_REGISTER_RGB_24B + i * M5STACK_8ANGLE_BYTES_PER_LED, - this->buf_ + i * M5STACK_8ANGLE_BYTES_PER_LED, M5STACK_8ANGLE_BYTES_PER_LED); + this->internal_accessor_.color_data + i * M5STACK_8ANGLE_BYTES_PER_LED, + M5STACK_8ANGLE_BYTES_PER_LED); } } -light::ESPColorView M5Stack8AngleLightOutput::get_view_internal(int32_t index) const { - size_t pos = index * M5STACK_8ANGLE_BYTES_PER_LED; - // red, green, blue, white, effect_data, color_correction - return {this->buf_ + pos, this->buf_ + pos + 1, this->buf_ + pos + 2, - nullptr, this->effect_data_ + index, &this->correction_}; -} - } // namespace esphome::m5stack_8angle diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h index ee204c239b4..3756e735191 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.h @@ -12,7 +12,12 @@ static const uint8_t M5STACK_8ANGLE_BYTES_PER_LED = 4; class M5Stack8AngleLightOutput final : public light::AddressableLight, public Parented { public: - void setup() override; + M5Stack8AngleLightOutput() + : internal_accessor_(this->color_data_, this->effect_data_, {0, 1, 2, light::ChannelColors::RESERVED_WHITE}, + &this->correction_) { + memset(this->color_data_, 0xFF, M5STACK_8ANGLE_NUM_LEDS * M5STACK_8ANGLE_BYTES_PER_LED); + this->accessor_ = &this->internal_accessor_; + } void write_state(light::LightState *state) override; @@ -23,13 +28,12 @@ class M5Stack8AngleLightOutput final : public light::AddressableLight, public Pa return traits; }; - void clear_effect_data() override { memset(this->effect_data_, 0x00, M5STACK_8ANGLE_NUM_LEDS); }; + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(M5STACK_8ANGLE_NUM_LEDS); } protected: - light::ESPColorView get_view_internal(int32_t index) const override; - - uint8_t *buf_{nullptr}; - uint8_t *effect_data_{nullptr}; + uint8_t color_data_[M5STACK_8ANGLE_NUM_LEDS * M5STACK_8ANGLE_BYTES_PER_LED]; + uint8_t effect_data_[M5STACK_8ANGLE_NUM_LEDS] = {}; + light::CorrectedBufferColorAccessor internal_accessor_; }; } // namespace esphome::m5stack_8angle diff --git a/esphome/components/neopixelbus/neopixelbus_light.h b/esphome/components/neopixelbus/neopixelbus_light.h index 1cbe889acf9..b9ff70d246a 100644 --- a/esphome/components/neopixelbus/neopixelbus_light.h +++ b/esphome/components/neopixelbus/neopixelbus_light.h @@ -46,15 +46,12 @@ enum class ESPNeoPixelOrder { RWBG = 0b00111001, }; -template +template class NeoPixelBusLightOutputBase : public light::AddressableLight { public: NeoPixelBus *get_controller() const { return this->controller_; } - void clear_effect_data() override { - for (int i = 0; i < this->size(); i++) - this->effect_data_[i] = 0; - } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->size()); } /// Add some LEDS, can only be called once. void add_leds(uint16_t count_pixels, uint8_t pin) { @@ -72,19 +69,17 @@ class NeoPixelBusLightOutputBase : public light::AddressableLight { // ========== INTERNAL METHODS ========== void setup() override { - for (int i = 0; i < this->size(); i++) { - (*this)[i] = Color(0, 0, 0, 0); - } - - this->effect_data_ = new uint8_t[this->size()]; // NOLINT + this->internal_accessor_.effect_data = new uint8_t[this->size()]; // NOLINT this->controller_->Begin(); + this->internal_accessor_.color_data = this->controller_->Pixels(); + this->accessor_ = &this->internal_accessor_; } void write_state(light::LightState *state) override { this->mark_shown_(); this->controller_->Dirty(); - this->controller_->Show(); + this->internal_accessor_.color_data = this->controller_->Pixels(); } float get_setup_priority() const override { return setup_priority::HARDWARE; } @@ -93,50 +88,36 @@ class NeoPixelBusLightOutputBase : public light::AddressableLight { void set_pixel_order(ESPNeoPixelOrder order) { uint8_t u_order = static_cast(order); - this->rgb_offsets_[0] = (u_order >> 6) & 0b11; - this->rgb_offsets_[1] = (u_order >> 4) & 0b11; - this->rgb_offsets_[2] = (u_order >> 2) & 0b11; - this->rgb_offsets_[3] = (u_order >> 0) & 0b11; + this->internal_accessor_.channel_colors.r = (u_order >> 6) & 0b11; + this->internal_accessor_.channel_colors.g = (u_order >> 4) & 0b11; + this->internal_accessor_.channel_colors.b = (u_order >> 2) & 0b11; + this->internal_accessor_.channel_colors.w = has_white ? (u_order >> 0) & 0b11 : light::ChannelColors::NO_WHITE; } protected: + light::CorrectedBufferColorAccessor internal_accessor_{ + nullptr, nullptr, {0, 0, 0, has_white ? 0 : light::ChannelColors::NO_WHITE}, &this->correction_}; NeoPixelBus *controller_{nullptr}; - uint8_t *effect_data_{nullptr}; - uint8_t rgb_offsets_[4]{0, 1, 2, 3}; }; template -class NeoPixelRGBLightOutput : public NeoPixelBusLightOutputBase { +class NeoPixelRGBLightOutput : public NeoPixelBusLightOutputBase { public: light::LightTraits get_traits() override { auto traits = light::LightTraits(); traits.set_supported_color_modes({light::ColorMode::RGB}); return traits; } - - protected: - light::ESPColorView get_view_internal(int32_t index) const override { // NOLINT - uint8_t *base = this->controller_->Pixels() + 3ULL * index; - return light::ESPColorView(base + this->rgb_offsets_[0], base + this->rgb_offsets_[1], base + this->rgb_offsets_[2], - nullptr, this->effect_data_ + index, &this->correction_); - } }; template -class NeoPixelRGBWLightOutput : public NeoPixelBusLightOutputBase { +class NeoPixelRGBWLightOutput : public NeoPixelBusLightOutputBase { public: light::LightTraits get_traits() override { auto traits = light::LightTraits(); traits.set_supported_color_modes({light::ColorMode::RGB_WHITE}); return traits; } - - protected: - light::ESPColorView get_view_internal(int32_t index) const override { // NOLINT - uint8_t *base = this->controller_->Pixels() + 4ULL * index; - return light::ESPColorView(base + this->rgb_offsets_[0], base + this->rgb_offsets_[1], base + this->rgb_offsets_[2], - base + this->rgb_offsets_[3], this->effect_data_ + index, &this->correction_); - } }; } // namespace esphome::neopixelbus diff --git a/esphome/components/partition/light.py b/esphome/components/partition/light.py index bd2dc4ea4df..fb05583f9b1 100644 --- a/esphome/components/partition/light.py +++ b/esphome/components/partition/light.py @@ -97,6 +97,8 @@ FINAL_VALIDATE_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: + cg.add_define("USE_LIGHT_PARTITION") + segments = [] for conf in config[CONF_SEGMENTS]: if CONF_SINGLE_LIGHT_ID in conf: diff --git a/esphome/components/partition/light_partition.cpp b/esphome/components/partition/light_partition.cpp index 6f2645b3658..5ed582adc9b 100644 --- a/esphome/components/partition/light_partition.cpp +++ b/esphome/components/partition/light_partition.cpp @@ -5,4 +5,32 @@ namespace esphome::partition { ESPHOME_LOG_TAG(TAG, "partition.light"); +light::ESPColorView PartitionLightOutput::get_color_view_(int32_t index) const { + uint32_t lo = 0; + uint32_t hi = this->segments_.size() - 1; + while (lo < hi) { + uint32_t mid = (lo + hi) / 2; + int32_t begin = this->segments_[mid].get_dst_offset(); + int32_t end = begin + this->segments_[mid].get_size(); + if (index < begin) { + hi = mid - 1; + } else if (index >= end) { + lo = mid + 1; + } else { + lo = hi = mid; + } + } + auto &seg = this->segments_[lo]; + // offset within the segment + int32_t seg_off = index - seg.get_dst_offset(); + // offset within the src + int32_t src_off; + if (seg.is_reversed()) { + src_off = seg.get_src_offset() + seg.get_size() - seg_off - 1; + } else { + src_off = seg.get_src_offset() + seg_off; + } + return (*seg.get_src())[src_off]; +} + } // namespace esphome::partition diff --git a/esphome/components/partition/light_partition.h b/esphome/components/partition/light_partition.h index c843474ab4c..9a80e067bf3 100644 --- a/esphome/components/partition/light_partition.h +++ b/esphome/components/partition/light_partition.h @@ -33,12 +33,14 @@ class AddressableSegment { class PartitionLightOutput final : public light::AddressableLight { public: - explicit PartitionLightOutput(std::vector segments) : segments_(std::move(segments)) { + explicit PartitionLightOutput(std::vector segments) + : segments_(std::move(segments)), internal_accessor_(this) { int32_t off = 0; for (auto &seg : this->segments_) { seg.set_dst_offset(off); off += seg.get_size(); } + this->accessor_ = &this->internal_accessor_; } int32_t size() const override { auto &last_seg = this->segments_[this->segments_.size() - 1]; @@ -58,44 +60,73 @@ class PartitionLightOutput final : public light::AddressableLight { } protected: - light::ESPColorView get_view_internal(int32_t index) const override { - uint32_t lo = 0; - uint32_t hi = this->segments_.size() - 1; - while (lo < hi) { - uint32_t mid = (lo + hi) / 2; - int32_t begin = this->segments_[mid].get_dst_offset(); - int32_t end = begin + this->segments_[mid].get_size(); - if (index < begin) { - hi = mid - 1; - } else if (index >= end) { - lo = mid + 1; - } else { - lo = hi = mid; - } + class PartitionColorAccessor final : public light::ESPColorAccessor { + public: + PartitionColorAccessor(const PartitionLightOutput *parent) : parent_(parent) {} + + Color get(int32_t index) const override { + light::ESPColorView color_view = this->parent_->get_color_view_(index); + return this->parent_->get_color_correction_(color_view)->color_uncorrect(color_view.get_raw()); } - auto &seg = this->segments_[lo]; - // offset within the segment - int32_t seg_off = index - seg.get_dst_offset(); - // offset within the src - int32_t src_off; - if (seg.is_reversed()) { - src_off = seg.get_src_offset() + seg.get_size() - seg_off - 1; - } else { - src_off = seg.get_src_offset() + seg_off; + uint8_t get_channel(int32_t index, uint8_t channel) const override { + light::ESPColorView color_view = this->parent_->get_color_view_(index); + return this->parent_->get_color_correction_(color_view) + ->color_uncorrect_channel(channel, color_view.get_channel_raw(channel)); + } + Color get_raw(int32_t index) const override { return this->parent_->get_color_view_(index).get_raw(); } + uint8_t get_channel_raw(int32_t index, uint8_t channel) const override { + return this->parent_->get_color_view_(index).get_channel_raw(channel); + } + uint8_t get_effect_data(int32_t index) const override { + return this->parent_->get_color_view_(index).get_effect_data(); + } + + void set(int32_t index, Color color) override { + light::ESPColorView color_view = this->parent_->get_color_view_(index); + color_view.set_raw(this->parent_->get_color_correction_(color_view)->color_correct(color)); + } + void set_channel(int32_t index, uint8_t channel, uint8_t value) override { + light::ESPColorView color_view = this->parent_->get_color_view_(index); + color_view.set_channel_raw( + channel, this->parent_->get_color_correction_(color_view)->color_correct_channel(channel, value)); + } + void set_raw(int32_t index, Color raw_color) override { this->parent_->get_color_view_(index).set_raw(raw_color); } + void set_channel_raw(int32_t index, uint8_t channel, uint8_t raw_value) override { + this->parent_->get_color_view_(index).set_channel_raw(channel, raw_value); + } + void set_effect_data(int32_t index, uint8_t value) override { + this->parent_->get_color_view_(index).set_effect_data(value); } - auto view = (*seg.get_src())[src_off]; #ifdef USE_LIGHT_POWER_ESTIMATION - // Writes go through the strip's power factor; a partition has no limit of its own. A view kept alive - // across two strips with different limits carries the factor of the strip fetched last. - const_cast(this->correction_) - .set_power_brightness(view.get_color_correction()->get_power_brightness()); + const light::ESPColorCorrection *get_color_correction_internal(int32_t index) const override { // NOLINT + return this->parent_->get_color_correction_(this->parent_->get_color_view_(index)); + } #endif - view.raw_set_color_correction(&this->correction_); - return view; + + protected: + const PartitionLightOutput *const parent_; + }; + + light::ESPColorView get_color_view_(int32_t index) const; + + // Gets the color correction associated with the specified color view. + // The correction will only be valid until the next call to this method. + const light::ESPColorCorrection *get_color_correction_(const light::ESPColorView &color_view) const { +#ifdef USE_LIGHT_POWER_ESTIMATION + // A partition has no power limit of its own so it adopts the power brightness of the underlying segment. + // PartitionColorAccessor calls this method to access one LED, applies color correction if needed, then + // discards the view. This method won't be called again until the next access thus the power brightness + // that is set here is guaranteed to be valid for the entire lifetime of the view that is returned. + const light::ESPColorCorrection *color_view_correction = color_view.get_color_correction_internal(); // NOLINT + const_cast(this->correction_) + .set_power_brightness(color_view_correction ? color_view_correction->get_power_brightness() : 255); +#endif + return &this->correction_; } std::vector segments_; + PartitionColorAccessor internal_accessor_; }; } // namespace esphome::partition diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 9d75c6ebc62..1bc82656de2 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -28,20 +28,9 @@ void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { } void RP2040PIOLEDStripLightOutput::setup() { - size_t buffer_size = this->get_buffer_size_(); - RAMAllocator allocator; - this->buf_ = allocator.allocate(buffer_size); - if (this->buf_ == nullptr) { - ESP_LOGE(TAG, "Failed to allocate buffer of size %u", buffer_size); - this->mark_failed(); - return; - } - - this->effect_data_ = allocator.allocate(this->num_leds_); - if (this->effect_data_ == nullptr) { - ESP_LOGE(TAG, "Failed to allocate effect data of size %u", this->num_leds_); - this->mark_failed(); + if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } @@ -107,10 +96,10 @@ void RP2040PIOLEDStripLightOutput::setup() { pio_get_dreq(this->pio_, this->sm_, true)); // set the DREQ to the state machine's TX FIFO dma_channel_configure(this->dma_chan_, &this->dma_config_, - &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO - this->buf_, // read from memory - this->get_buffer_size_(), // number of bytes to transfer - false // don't start yet + &this->pio_->txf[this->sm_], // write to the state machine's TX FIFO + this->internal_accessor_.color_data, // read from memory + this->get_buffer_size_(), // number of bytes to transfer + false // don't start yet ); // Initialize the semaphore for this DMA channel @@ -121,6 +110,8 @@ void RP2040PIOLEDStripLightOutput::setup() { irq_set_enabled(DMA_IRQ_0, true); // enable interrupt this->init_(this->pio_, this->sm_, offset, this->pin_, this->max_refresh_rate_); + + this->accessor_ = &this->internal_accessor_; } void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { @@ -131,14 +122,14 @@ void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { return; } - if (this->buf_ == nullptr) { + if (this->internal_accessor_.color_data == nullptr) { ESP_LOGW(TAG, "Buffer is null, not writing state."); return; } // the bits are already in the correct order for the pio program so we can just copy the buffer using DMA sem_acquire_blocking(&RP2040PIOLEDStripLightOutput::dma_write_complete_sem[this->dma_chan_]); - dma_channel_transfer_from_buffer_now(this->dma_chan_, this->buf_, this->get_buffer_size_()); + dma_channel_transfer_from_buffer_now(this->dma_chan_, this->internal_accessor_.color_data, this->get_buffer_size_()); } void RP2040PIOLEDStripLightOutput::dump_config() { @@ -149,7 +140,8 @@ void RP2040PIOLEDStripLightOutput::dump_config() { " Number of LEDs: %d\n" " Channel colors: %s\n" " Max Refresh Rate: %f Hz", - this->pin_, this->num_leds_, this->channel_colors_.to_string(channel_colors), this->max_refresh_rate_); + this->pin_, this->num_leds_, this->internal_accessor_.channel_colors.to_string(channel_colors), + this->max_refresh_rate_); } float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h index d320761973f..ff291da0048 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.h +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -37,10 +37,12 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { float get_setup_priority() const override; int32_t size() const override { return this->num_leds_; } - light::LightTraits get_traits() override { return this->channel_colors_.traits(); } + light::LightTraits get_traits() override { return this->internal_accessor_.channel_colors.traits(); } void set_pin(uint8_t pin) { this->pin_ = pin; } void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } - void set_channel_colors(light::ChannelColors channel_colors) { this->channel_colors_ = channel_colors; } + void set_channel_colors(light::ChannelColors channel_colors) { + this->internal_accessor_.channel_colors = channel_colors; + } void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } @@ -49,24 +51,16 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { void set_init_function(init_fn init) { this->init_ = init; } void set_chipset(Chipset chipset) { this->chipset_ = chipset; }; - void clear_effect_data() override { - if (this->effect_data_ != nullptr) - memset(this->effect_data_, 0, this->num_leds_); - } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } void dump_config() override; protected: - light::ESPColorView get_view_internal(int32_t index) const override { - return this->channel_colors_.view(this->buf_, this->effect_data_, index, &this->correction_); - } - - size_t get_buffer_size_() const { return this->num_leds_ * this->channel_colors_.bytes_per_led(); } + size_t get_buffer_size_() const { return this->num_leds_ * this->internal_accessor_.channel_colors.bytes_per_led(); } static void dma_write_complete_handler(); - uint8_t *buf_{nullptr}; - uint8_t *effect_data_{nullptr}; + light::CorrectedBufferColorAccessor internal_accessor_{nullptr, nullptr, {0, 1, 2}, &this->correction_}; uint8_t pin_; uint32_t num_leds_; @@ -76,7 +70,6 @@ class RP2040PIOLEDStripLightOutput final : public light::AddressableLight { uint dma_chan_; dma_channel_config dma_config_; - light::ChannelColors channel_colors_{0, 1, 2, light::ChannelColors::NO_WHITE}; Chipset chipset_{CHIPSET_CUSTOM}; float max_refresh_rate_; diff --git a/esphome/components/spi_led_strip/spi_led_strip.cpp b/esphome/components/spi_led_strip/spi_led_strip.cpp index f41952cac99..115b4e22e19 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.cpp +++ b/esphome/components/spi_led_strip/spi_led_strip.cpp @@ -3,26 +3,26 @@ namespace esphome::spi_led_strip { +constexpr size_t COLOR_DATA_OFFSET = 5; + SpiLedStrip::SpiLedStrip(uint16_t num_leds) { this->num_leds_ = num_leds; RAMAllocator allocator; this->buffer_size_ = num_leds * 4 + 8; - this->buf_ = allocator.allocate(this->buffer_size_); - if (this->buf_ == nullptr) { - ESP_LOGE(TAG, "Failed to allocate buffer of size %u", this->buffer_size_); + uint8_t *buf = allocator.allocate(this->buffer_size_ + num_leds); + if (buf == nullptr) { + ESP_LOGE(TAG, "Failed to allocate buffer of size %u", this->buffer_size_ + num_leds); return; } - - this->effect_data_ = allocator.allocate(num_leds); - if (this->effect_data_ == nullptr) { - ESP_LOGE(TAG, "Failed to allocate effect data of size %u", num_leds); - return; - } - memset(this->buf_, 0xFF, this->buffer_size_); - memset(this->buf_, 0, 4); + memset(buf, 0xFF, this->buffer_size_); + memset(buf, 0, 4); + this->internal_accessor_.color_data = buf + COLOR_DATA_OFFSET; + this->internal_accessor_.effect_data = buf + this->buffer_size_; + memset(this->internal_accessor_.effect_data, 0, num_leds); + this->accessor_ = &this->internal_accessor_; } void SpiLedStrip::setup() { - if (this->effect_data_ == nullptr || this->buf_ == nullptr) { + if (this->internal_accessor_.effect_data == nullptr || this->internal_accessor_.color_data == nullptr) { this->mark_failed(); return; } @@ -47,21 +47,17 @@ void SpiLedStrip::dump_config() { void SpiLedStrip::write_state(light::LightState *state) { if (this->is_failed()) return; + uint8_t *buffer = this->internal_accessor_.color_data - COLOR_DATA_OFFSET; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE { char strbuf[49]; // format_hex_pretty_size(16) = 48, fits 16 bytes size_t len = std::min(this->buffer_size_, (size_t) 16); - format_hex_pretty_to(strbuf, sizeof(strbuf), this->buf_, len, ' '); + format_hex_pretty_to(strbuf, sizeof(strbuf), buffer, len, ' '); esph_log_v(TAG, "write_state: buf = %s", strbuf); } #endif this->enable(); - this->write_array(this->buf_, this->buffer_size_); + this->write_array(buffer, this->buffer_size_); this->disable(); } -light::ESPColorView SpiLedStrip::get_view_internal(int32_t index) const { - size_t pos = index * 4 + 5; - return {this->buf_ + pos + 2, this->buf_ + pos + 1, this->buf_ + pos + 0, nullptr, - this->effect_data_ + index, &this->correction_}; -} } // namespace esphome::spi_led_strip diff --git a/esphome/components/spi_led_strip/spi_led_strip.h b/esphome/components/spi_led_strip/spi_led_strip.h index a5b10a0e9a6..7f84de23f50 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.h +++ b/esphome/components/spi_led_strip/spi_led_strip.h @@ -24,14 +24,12 @@ class SpiLedStrip final : public light::AddressableLight, void write_state(light::LightState *state) override; - void clear_effect_data() override { memset(this->effect_data_, 0, this->num_leds_ * sizeof(this->effect_data_[0])); } + void clear_effect_data() override { this->internal_accessor_.clear_effect_data(this->num_leds_); } protected: - light::ESPColorView get_view_internal(int32_t index) const override; - + light::CorrectedBufferColorAccessor internal_accessor_{ + nullptr, nullptr, {2, 1, 0, light::ChannelColors::RESERVED_WHITE}, &this->correction_}; size_t buffer_size_{}; - uint8_t *effect_data_{nullptr}; - uint8_t *buf_{nullptr}; uint16_t num_leds_; }; diff --git a/esphome/core/color.h b/esphome/core/color.h index b21e450af9e..abcecf7defd 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -23,6 +23,12 @@ inline static constexpr uint8_t esp_scale8_twice(uint8_t i, uint8_t scale1, uint return (uint32_t(i) * (1 + uint32_t(scale1)) * (1 + uint32_t(scale2))) >> 16; } +// Index of each channel into the raw color value array. +static constexpr uint8_t COLOR_CHANNEL_R = 0; +static constexpr uint8_t COLOR_CHANNEL_G = 1; +static constexpr uint8_t COLOR_CHANNEL_B = 2; +static constexpr uint8_t COLOR_CHANNEL_W = 3; + struct Color { union { struct { @@ -67,21 +73,22 @@ struct Color { b((colorcode >> 0) & 0xFF), w((colorcode >> 24) & 0xFF) {} - inline bool is_on() ESPHOME_ALWAYS_INLINE { return this->raw_32 != 0; } + inline bool is_on() const ESPHOME_ALWAYS_INLINE { return this->raw_32 != 0; } - inline bool operator==(const Color &rhs) { // NOLINT + inline bool operator==(const Color &rhs) const { // NOLINT return this->raw_32 == rhs.raw_32; } - inline bool operator==(uint32_t colorcode) { // NOLINT + inline bool operator==(uint32_t colorcode) const { // NOLINT return this->raw_32 == colorcode; } - inline bool operator!=(const Color &rhs) { // NOLINT + inline bool operator!=(const Color &rhs) const { // NOLINT return this->raw_32 != rhs.raw_32; } - inline bool operator!=(uint32_t colorcode) { // NOLINT + inline bool operator!=(uint32_t colorcode) const { // NOLINT return this->raw_32 != colorcode; } inline uint8_t &operator[](uint8_t x) ESPHOME_ALWAYS_INLINE { return this->raw[x]; } + inline const uint8_t &operator[](uint8_t x) const ESPHOME_ALWAYS_INLINE { return this->raw[x]; } inline Color operator*(uint8_t scale) const ESPHOME_ALWAYS_INLINE { return Color(esp_scale8(this->red, scale), esp_scale8(this->green, scale), esp_scale8(this->blue, scale), esp_scale8(this->white, scale)); @@ -191,4 +198,10 @@ struct Color { static const Color WHITE; }; +static_assert(offsetof(Color, raw) == COLOR_CHANNEL_R); +static_assert(offsetof(Color, r) == COLOR_CHANNEL_R); +static_assert(offsetof(Color, g) == COLOR_CHANNEL_G); +static_assert(offsetof(Color, b) == COLOR_CHANNEL_B); +static_assert(offsetof(Color, w) == COLOR_CHANNEL_W); + } // namespace esphome diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8050bd16830..01ffcdab11e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -134,6 +134,7 @@ #define USE_LIGHT #define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_PARTITION #define USE_LIGHT_POWER_ESTIMATION #define USE_LIGHT_RESUME_EFFECT #define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL diff --git a/esphome/core/log_const_en.h b/esphome/core/log_const_en.h index ccb1f446e4e..d6f054072e5 100644 --- a/esphome/core/log_const_en.h +++ b/esphome/core/log_const_en.h @@ -1,4 +1,5 @@ #pragma once +#define ESP_LOG_MSG_ALLOC_FAIL "Allocation failed" #define ESP_LOG_MSG_COMM_FAIL "Communication failed" #define ESP_LOG_MSG_COMM_FAIL_FOR "Communication failed for '%s'" diff --git a/tests/components/light/test_color_view.cpp b/tests/components/light/test_color_view.cpp new file mode 100644 index 00000000000..6dfe9831fdd --- /dev/null +++ b/tests/components/light/test_color_view.cpp @@ -0,0 +1,399 @@ +#include + +#include +#include + +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/esp_color_view.h" +#include "esphome/components/light/esp_range_view.h" + +namespace esphome::light::testing { +namespace { +constexpr ChannelColors WRGB{1, 2, 3, 0}; +} // namespace + +TEST(ColorAccessors, NullColorAccessor) { + auto &accessor = NullColorAccessor::instance; + + accessor.set(1, Color{10, 20, 30, 40}); + EXPECT_EQ((Color{}), accessor.get(1)); + + accessor.set_channel(1, COLOR_CHANNEL_R, 10); + EXPECT_EQ(0, accessor.get_channel(1, COLOR_CHANNEL_R)); + + accessor.set_channel(1, COLOR_CHANNEL_G, 20); + EXPECT_EQ(0, accessor.get_channel(1, COLOR_CHANNEL_G)); + + accessor.set_channel(1, COLOR_CHANNEL_B, 30); + EXPECT_EQ(0, accessor.get_channel(1, COLOR_CHANNEL_B)); + + accessor.set_channel(1, COLOR_CHANNEL_W, 40); + EXPECT_EQ(0, accessor.get_channel(1, COLOR_CHANNEL_W)); + + accessor.set_raw(1, Color{15, 25, 35, 45}); + EXPECT_EQ((Color{}), accessor.get_raw(1)); + + accessor.set_channel_raw(1, COLOR_CHANNEL_R, 15); + EXPECT_EQ(0, accessor.get_channel_raw(1, COLOR_CHANNEL_R)); + + accessor.set_channel_raw(1, COLOR_CHANNEL_G, 25); + EXPECT_EQ(0, accessor.get_channel_raw(1, COLOR_CHANNEL_G)); + + accessor.set_channel_raw(1, COLOR_CHANNEL_B, 35); + EXPECT_EQ(0, accessor.get_channel_raw(1, COLOR_CHANNEL_B)); + + accessor.set_channel_raw(1, COLOR_CHANNEL_W, 45); + EXPECT_EQ(0, accessor.get_channel_raw(1, COLOR_CHANNEL_W)); + + accessor.set_effect_data(1, 50); + EXPECT_EQ(0, accessor.get_effect_data(1)); +} + +TEST(ColorAccessors, RawBufferColorAccessor) { + std::array color_data{}; + std::array effect_data{}; + RawBufferColorAccessor accessor(color_data.data(), effect_data.data(), WRGB); + + accessor.set(1, Color{10, 20, 30, 40}); + EXPECT_EQ((Color{10, 20, 30, 40}), accessor.get(1)); + EXPECT_EQ((Color{10, 20, 30, 40}), accessor.get_raw(1)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 40, 10, 20, 30, 0, 0, 0, 0})); + + accessor.set_raw(1, Color{15, 25, 35, 45}); + EXPECT_EQ((Color{15, 25, 35, 45}), accessor.get(1)); + EXPECT_EQ((Color{15, 25, 35, 45}), accessor.get_raw(1)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 15, 25, 35, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_R, 10); + EXPECT_EQ(10, accessor.get_channel(1, COLOR_CHANNEL_R)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 10, 25, 35, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_G, 20); + EXPECT_EQ(20, accessor.get_channel(1, COLOR_CHANNEL_G)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 10, 20, 35, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_B, 30); + EXPECT_EQ(30, accessor.get_channel(1, COLOR_CHANNEL_B)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 10, 20, 30, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_W, 40); + EXPECT_EQ(40, accessor.get_channel(1, COLOR_CHANNEL_W)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 40, 10, 20, 30, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_R, 15); + EXPECT_EQ(15, accessor.get_channel_raw(1, COLOR_CHANNEL_R)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 40, 15, 20, 30, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_G, 25); + EXPECT_EQ(25, accessor.get_channel_raw(1, COLOR_CHANNEL_G)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 40, 15, 25, 30, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_B, 35); + EXPECT_EQ(35, accessor.get_channel_raw(1, COLOR_CHANNEL_B)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 40, 15, 25, 35, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_W, 45); + EXPECT_EQ(45, accessor.get_channel_raw(1, COLOR_CHANNEL_W)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 15, 25, 35, 0, 0, 0, 0})); + + accessor.set_effect_data(1, 50); + EXPECT_EQ(50, accessor.get_effect_data(1)); + EXPECT_EQ(effect_data, (std::array{0, 50, 0})); +} + +TEST(ColorAccessors, CorrectedBufferColorAccessor) { + std::array color_data{}; + std::array effect_data{}; + ESPColorCorrection correction{}; + correction.set_local_brightness(0x7f); // scale by 50% + CorrectedBufferColorAccessor accessor(color_data.data(), effect_data.data(), WRGB, &correction); + + accessor.set(1, Color{10, 20, 30, 40}); + EXPECT_EQ((Color{10, 20, 30, 40}), accessor.get(1)); + EXPECT_EQ((Color{5, 10, 15, 20}), accessor.get_raw(1)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 5, 10, 15, 0, 0, 0, 0})); + + accessor.set_raw(1, Color{15, 25, 35, 45}); + EXPECT_EQ((Color{30, 50, 70, 90}), accessor.get(1)); + EXPECT_EQ((Color{15, 25, 35, 45}), accessor.get_raw(1)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 15, 25, 35, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_R, 10); + EXPECT_EQ(10, accessor.get_channel(1, COLOR_CHANNEL_R)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 5, 25, 35, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_G, 20); + EXPECT_EQ(20, accessor.get_channel(1, COLOR_CHANNEL_G)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 5, 10, 35, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_B, 30); + EXPECT_EQ(30, accessor.get_channel(1, COLOR_CHANNEL_B)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 5, 10, 15, 0, 0, 0, 0})); + + accessor.set_channel(1, COLOR_CHANNEL_W, 40); + EXPECT_EQ(40, accessor.get_channel(1, COLOR_CHANNEL_W)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 5, 10, 15, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_R, 15); + EXPECT_EQ(15, accessor.get_channel_raw(1, COLOR_CHANNEL_R)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 15, 10, 15, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_G, 25); + EXPECT_EQ(25, accessor.get_channel_raw(1, COLOR_CHANNEL_G)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 15, 25, 15, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_B, 35); + EXPECT_EQ(35, accessor.get_channel_raw(1, COLOR_CHANNEL_B)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 15, 25, 35, 0, 0, 0, 0})); + + accessor.set_channel_raw(1, COLOR_CHANNEL_W, 45); + EXPECT_EQ(45, accessor.get_channel_raw(1, COLOR_CHANNEL_W)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 15, 25, 35, 0, 0, 0, 0})); + + accessor.set_effect_data(1, 50); + EXPECT_EQ(50, accessor.get_effect_data(1)); + EXPECT_EQ(effect_data, (std::array{0, 50, 0})); +} + +Color round_trip(const ESPColorCorrection &correction, Color color) { + return correction.color_uncorrect(correction.color_correct(color)); +} + +TEST(ColorAccessors, ESPColorView) { + std::array color_data{}; + std::array effect_data{}; + ESPColorCorrection correction{}; + correction.set_local_brightness(0x7f); // scale by 50% + CorrectedBufferColorAccessor accessor(color_data.data(), effect_data.data(), WRGB, &correction); + ESPColorView view(&accessor, 1); + + view.set(Color{10, 20, 30, 40}); + EXPECT_EQ((Color{10, 20, 30, 40}), view.get()); + EXPECT_EQ((Color{5, 10, 15, 20}), view.get_raw()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 5, 10, 15, 0, 0, 0, 0})); + + view.set_hsv(ESPHSVColor(120, 170, 220)); + EXPECT_EQ((round_trip(correction, ESPHSVColor(120, 170, 220).to_rgb())), view.get()); + + view.set_rgbw(10, 20, 30, 40); + EXPECT_EQ((Color{10, 20, 30, 40}), view.get()); + + view.set_rgb(12, 24, 36); // W should be unchanged + EXPECT_EQ((Color{12, 24, 36, 40}), view.get()); + + view.set_channel(COLOR_CHANNEL_B, 38); + EXPECT_EQ(38, view.get_channel(COLOR_CHANNEL_B)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 6, 12, 19, 0, 0, 0, 0})); + + view = Color{10, 20, 30, 40}; + EXPECT_EQ((Color{10, 20, 30, 40}), view.get()); + + view = ESPHSVColor(100, 150, 200); + EXPECT_EQ((round_trip(correction, ESPHSVColor(100, 150, 200).to_rgb())), view.get()); + + view.set_raw(Color{11, 21, 31, 41}); + EXPECT_EQ((Color{22, 42, 62, 82}), view.get()); + EXPECT_EQ((Color{11, 21, 31, 41}), view.get_raw()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 41, 11, 21, 31, 0, 0, 0, 0})); + + view.set_channel_raw(COLOR_CHANNEL_G, 22); + EXPECT_EQ(22, view.get_channel_raw(COLOR_CHANNEL_G)); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 41, 11, 22, 31, 0, 0, 0, 0})); + + view.set_red_raw(15); + EXPECT_EQ(15, view.get_red_raw()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 41, 15, 22, 31, 0, 0, 0, 0})); + + view.set_green_raw(25); + EXPECT_EQ(25, view.get_green_raw()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 41, 15, 25, 31, 0, 0, 0, 0})); + + view.set_blue_raw(35); + EXPECT_EQ(35, view.get_blue_raw()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 41, 15, 25, 35, 0, 0, 0, 0})); + + view.set_white_raw(45); + EXPECT_EQ(45, view.get_white_raw()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 15, 25, 35, 0, 0, 0, 0})); + + view.set_red(10); + EXPECT_EQ(10, view.get_red()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 5, 25, 35, 0, 0, 0, 0})); + + view.set_green(20); + EXPECT_EQ(20, view.get_green()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 5, 10, 35, 0, 0, 0, 0})); + + view.set_blue(30); + EXPECT_EQ(30, view.get_blue()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 45, 5, 10, 15, 0, 0, 0, 0})); + + view.set_white(40); + EXPECT_EQ(40, view.get_white()); + EXPECT_EQ(color_data, (std::array{0, 0, 0, 0, 20, 5, 10, 15, 0, 0, 0, 0})); + + view.set_effect_data(50); + EXPECT_EQ(50, view.get_effect_data()); + EXPECT_EQ(effect_data, (std::array{0, 50, 0})); + + view.set(Color{10, 20, 30, 40}); + view.fade_to_white(31); + EXPECT_EQ((round_trip(correction, Color{10, 20, 30, 40}.fade_to_white(31))), view.get()); + + view.set(Color{10, 20, 30, 40}); + view.fade_to_black(31); + EXPECT_EQ((round_trip(correction, Color{10, 20, 30, 40}.fade_to_black(31))), view.get()); + + view.set(Color{10, 20, 30, 40}); + view.lighten(5); + EXPECT_EQ((round_trip(correction, Color{10, 20, 30, 40}.lighten(5))), view.get()); + + view.set(Color{10, 20, 30, 40}); + view.darken(5); + EXPECT_EQ((round_trip(correction, Color{10, 20, 30, 40}.darken(5))), view.get()); + + ESPColorView view2(&accessor, 2); + view2.set(Color{12, 25, 36, 48}); + view = view2; + EXPECT_EQ((Color{12, 24, 36, 48}), view.get()); + view = ESPColorView(view); + EXPECT_EQ((Color{12, 24, 36, 48}), view.get()); +} + +template constexpr std::array make_wrgb(std::array colors) { + std::array result; + for (size_t i = 0; i < size; i++) { + result[i * 4] = colors[i].w; + result[i * 4 + 1] = colors[i].r; + result[i * 4 + 2] = colors[i].g; + result[i * 4 + 3] = colors[i].b; + } + return result; +} + +TEST(ColorAccessors, ESPRangeView) { + std::array color_data{}; + std::array effect_data{}; + RawBufferColorAccessor accessor(color_data.data(), effect_data.data(), WRGB); + + ESPRangeView range14(&accessor, 1, 4); + ESPRangeView range36(&accessor, 3, 6); + ESPRangeView range47(&accessor, 4, 7); + Color color0{}; + Color color1{10, 20, 30, 40}; + ESPHSVColor hsv_color2{100, 150, 200}; + Color color2 = hsv_color2.to_rgb(); + ESPHSVColor hsv_color3{120, 170, 220}; + Color color3 = hsv_color3.to_rgb(); + Color color4{15, 25, 35, 45}; + Color color; + + range14.set(color1); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color1, color1, color0, color0, color0, color0})); + EXPECT_EQ(color1, range14[0].get()); + EXPECT_EQ(color1, range14[1].get()); + EXPECT_EQ(color1, range14[2].get()); + + range14[0] = color4; + range14[1] = color0; + range14[2] = color3; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color4, color0, color3, color0, color0, color0, color0})); + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + range14.set(hsv_color2); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color2, color2, color2, color0, color0, color0, color0})); +#pragma GCC diagnostic pop + + range47.set_hsv(hsv_color3); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color2, color2, color2, color3, color3, color3, color0})); + EXPECT_EQ(color3, range47[0].get()); + EXPECT_EQ(color3, range47[1].get()); + EXPECT_EQ(color3, range47[2].get()); + + range14.set_rgbw(color1.r, color1.g, color1.b, color1.w); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color1, color1, color3, color3, color3, color0})); + + range14.set_rgb(color2.r, color2.g, color2.b); // W should be unchanged + color = {color2.r, color2.g, color2.b, color1.w}; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14 = hsv_color2; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color2, color2, color2, color3, color3, color3, color0})); + range14 = color1; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color1, color1, color3, color3, color3, color0})); + + range14.set_red(15); + color = Color{15, color1.g, color1.b, color1.w}; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14.set_green(25); + color = Color{15, 25, color1.b, color1.w}; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14.set_blue(35); + color = Color{15, 25, 35, color1.w}; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14.set_white(45); + color = Color{15, 25, 35, 45}; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14.set_effect_data(50); + EXPECT_EQ(effect_data, (std::array{0, 50, 50, 50, 0, 0, 0, 0})); + range47.set_effect_data(55); + EXPECT_EQ(effect_data, (std::array{0, 50, 50, 50, 55, 55, 55, 0})); + + range14.set(color1); + range14.fade_to_white(31); + color = color1.fade_to_white(31); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14.set(color1); + range14.fade_to_black(31); + color = color1.fade_to_black(31); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14.set(color1); + range14.lighten(5); + color = color1.lighten(5); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14.set(color1); + range14.darken(5); + color = color1.darken(5); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color, color, color, color3, color3, color3, color0})); + + range14 = range47; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color3, color3, color3, color3, color3, color3, color0})); + + range36[0] = color1; + range36[1] = color2; + range36[2] = color4; + range14 = range36; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color2, color4, color2, color4, color3, color0})); + + range36[0] = color1; + range36[1] = color2; + range36[2] = color4; + range47 = range36; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color2, color1, color1, color2, color4, color0})); + + range47 = ESPRangeView(range47); + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color2, color1, color1, color2, color4, color0})); + + range47[0] = range47[1]; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color2, color1, color2, color2, color4, color0})); + + std::array other_color_data{}; + std::array other_effect_data{}; + RawBufferColorAccessor other_accessor(other_color_data.data(), other_effect_data.data(), WRGB); + ESPRangeView other_range(&other_accessor, 0, 3); + other_range[0] = color3; + other_range[1] = color2; + other_range[2] = color1; + range36 = other_range; + EXPECT_EQ(color_data, make_wrgb<8>({color0, color1, color2, color3, color2, color1, color4, color0})); +} + +} // namespace esphome::light::testing diff --git a/tests/components/light/test_led_strip_helpers.cpp b/tests/components/light/test_led_strip_helpers.cpp index eda5b30527e..16631bd3598 100644 --- a/tests/components/light/test_led_strip_helpers.cpp +++ b/tests/components/light/test_led_strip_helpers.cpp @@ -12,6 +12,7 @@ namespace esphome::light::testing { namespace { constexpr ChannelColors GRB{1, 0, 2, ChannelColors::NO_WHITE}; +constexpr ChannelColors GRBX{1, 0, 2, ChannelColors::RESERVED_WHITE}; constexpr ChannelColors GRBW{1, 0, 2, 3}; // Exposes the protected strip helpers @@ -22,15 +23,29 @@ class HelperStrip : public AddressableLight { LightTraits get_traits() override { return {}; } void write_state(LightState *state) override {} using AddressableLight::throttle_show_; - - protected: - ESPColorView get_view_internal(int32_t index) const override { - return GRB.view(nullptr, nullptr, index, &this->correction_); - } }; } // namespace +TEST(LedStripHelpers, HasWhite) { + EXPECT_FALSE(GRB.has_white()); + EXPECT_FALSE(GRBX.has_white()); + EXPECT_TRUE(GRBW.has_white()); +} + +TEST(LedStripHelpers, BytesPerLed) { + EXPECT_EQ(3, GRB.bytes_per_led()); + EXPECT_EQ(4, GRBX.bytes_per_led()); + EXPECT_EQ(4, GRBW.bytes_per_led()); +} + +TEST(LedStripHelpers, ToString) { + char buf[5]; + EXPECT_STREQ("GRB", GRB.to_string(buf)); + EXPECT_STREQ("GRBX", GRBX.to_string(buf)); + EXPECT_STREQ("GRBW", GRBW.to_string(buf)); +} + TEST(LedStripHelpers, TraitsFollowTheWhiteChannel) { const auto rgb = GRB.traits().get_supported_color_modes(); EXPECT_TRUE(rgb.count(ColorMode::RGB)); @@ -41,36 +56,67 @@ TEST(LedStripHelpers, TraitsFollowTheWhiteChannel) { EXPECT_EQ(rgbw.size(), 2u); } -TEST(LedStripHelpers, ViewWritesTheChannelOrder) { - ESPColorCorrection correction; - std::array buf{}; - std::array effect{}; - GRBW.view(buf.data(), effect.data(), 1, &correction) = Color(10, 20, 30, 40); - GRBW.view(buf.data(), effect.data(), 1, &correction).set_effect_data(7); - EXPECT_EQ(buf, (std::array{0, 0, 0, 0, 20, 10, 30, 40})); - EXPECT_EQ(effect[1], 7); - - std::array rgb_buf{}; - auto view = GRB.view(rgb_buf.data(), effect.data(), 1, &correction); - view = Color(10, 20, 30, 40); - EXPECT_EQ(rgb_buf, (std::array{0, 0, 0, 20, 10, 30})); - EXPECT_EQ(view.get_white_raw(), 0); +TEST(LedStripHelpers, GetSetRGBColors) { + std::array buf{}; + GRB.set(buf.data(), 1, Color(10, 20, 30, 40)); + EXPECT_EQ(buf, (std::array{0, 0, 0, 20, 10, 30, 0, 0, 0})); + EXPECT_EQ(Color(10, 20, 30, 0).raw_32, GRB.get(buf.data(), 1).raw_32); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_R), 10); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_G), 20); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_B), 30); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_W), 0); + GRB.set_channel(buf.data(), 1, COLOR_CHANNEL_R, 11); + GRB.set_channel(buf.data(), 1, COLOR_CHANNEL_G, 21); + GRB.set_channel(buf.data(), 1, COLOR_CHANNEL_B, 31); + GRB.set_channel(buf.data(), 1, COLOR_CHANNEL_W, 41); + EXPECT_EQ(Color(11, 21, 31, 0).raw_32, GRB.get(buf.data(), 1).raw_32); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_R), 11); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_G), 21); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_B), 31); + EXPECT_EQ(GRB.get_channel(buf.data(), 1, COLOR_CHANNEL_W), 0); + EXPECT_EQ(buf, (std::array{0, 0, 0, 21, 11, 31, 0, 0, 0})); } -TEST(LedStripHelpers, ViewFallsBackToScratchBeforeSetup) { - ESPColorCorrection correction; - std::array buf{}; - // Any index is safe without a buffer, as is a buffer without effect data - GRBW.view(nullptr, nullptr, 1000, &correction) = Color(1, 2, 3, 4); - auto scratch = GRBW.view(nullptr, nullptr, 1000, &correction); - scratch.set_effect_data(5); - EXPECT_EQ(scratch.get_effect_data(), 0); - GRBW.view(buf.data(), nullptr, 0, &correction) = Color(1, 2, 3, 4); - EXPECT_EQ(buf, (std::array{})); - // Effect data without a buffer is left alone too - std::array effect{}; - GRBW.view(nullptr, effect.data(), 3, &correction).set_effect_data(5); - EXPECT_EQ(effect, (std::array{})); +TEST(LedStripHelpers, GetSetRGBXColors) { + std::array buf{}; + GRBX.set(buf.data(), 1, Color(10, 20, 30, 40)); + EXPECT_EQ(buf, (std::array{0, 0, 0, 0, 20, 10, 30, 0, 0, 0, 0, 0})); + EXPECT_EQ(Color(10, 20, 30, 0).raw_32, GRBX.get(buf.data(), 1).raw_32); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_R), 10); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_G), 20); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_B), 30); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_W), 0); + GRBX.set_channel(buf.data(), 1, COLOR_CHANNEL_R, 11); + GRBX.set_channel(buf.data(), 1, COLOR_CHANNEL_G, 21); + GRBX.set_channel(buf.data(), 1, COLOR_CHANNEL_B, 31); + GRBX.set_channel(buf.data(), 1, COLOR_CHANNEL_W, 41); + EXPECT_EQ(Color(11, 21, 31, 0).raw_32, GRBX.get(buf.data(), 1).raw_32); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_R), 11); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_G), 21); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_B), 31); + EXPECT_EQ(GRBX.get_channel(buf.data(), 1, COLOR_CHANNEL_W), 0); + EXPECT_EQ(buf, (std::array{0, 0, 0, 0, 21, 11, 31, 0, 0, 0, 0, 0})); +} + +TEST(LedStripHelpers, GetSetRGBWColors) { + std::array buf{}; + GRBW.set(buf.data(), 1, Color(10, 20, 30, 40)); + EXPECT_EQ(buf, (std::array{0, 0, 0, 0, 20, 10, 30, 40, 0, 0, 0, 0})); + EXPECT_EQ(Color(10, 20, 30, 40).raw_32, GRBW.get(buf.data(), 1).raw_32); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_R), 10); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_G), 20); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_B), 30); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_W), 40); + GRBW.set_channel(buf.data(), 1, COLOR_CHANNEL_R, 11); + GRBW.set_channel(buf.data(), 1, COLOR_CHANNEL_G, 21); + GRBW.set_channel(buf.data(), 1, COLOR_CHANNEL_B, 31); + GRBW.set_channel(buf.data(), 1, COLOR_CHANNEL_W, 41); + EXPECT_EQ(Color(11, 21, 31, 41).raw_32, GRBW.get(buf.data(), 1).raw_32); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_R), 11); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_G), 21); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_B), 31); + EXPECT_EQ(GRBW.get_channel(buf.data(), 1, COLOR_CHANNEL_W), 41); + EXPECT_EQ(buf, (std::array{0, 0, 0, 0, 21, 11, 31, 41, 0, 0, 0, 0})); } TEST(LedStripHelpers, ThrottleShowHoldsTheFrame) { diff --git a/tests/components/light/test_power_limit.cpp b/tests/components/light/test_power_limit.cpp index 62183e5da33..ffcd387ccea 100644 --- a/tests/components/light/test_power_limit.cpp +++ b/tests/components/light/test_power_limit.cpp @@ -26,12 +26,19 @@ std::array build_gamma_table(double gamma) { // A strip of RGBW pixels in memory, with the protected hooks the tests drive class Strip : public AddressableLight { public: - explicit Strip(int32_t count) : buffer_(count * 4) {} + explicit Strip(int32_t count) + : num_leds_(count), + buffer_(count * 5), + internal_accessor_(this->buffer_.data(), this->buffer_.data() + num_leds_ * 4, light::ChannelColors{0, 1, 2, 3}, + &this->correction_) { + this->accessor_ = &this->internal_accessor_; + } + ~Strip() { delete this->power_estimation_; } // NOLINT(cppcoreguidelines-owning-memory) - int32_t size() const override { return this->buffer_.size() / 4; } + int32_t size() const override { return this->num_leds_; } void clear_effect_data() override {} LightTraits get_traits() override { return {}; } - void write_state(LightState *state) override { this->sent_red = (*this)[0].get_red_raw(); } + void write_state(LightState *state) override { this->sent_red = (*this)[0].get_raw().red; } uint8_t sent_red{0}; // Every channel draws ma_per_channel at full level; setup turns the settings into the idle draw and budget void configure_power(float ma_per_channel, float idle_ma_per_led, float max_current_ma) { @@ -44,12 +51,9 @@ class Strip : public AddressableLight { uint8_t power_brightness() const { return this->correction_.get_power_brightness(); } protected: - ESPColorView get_view_internal(int32_t index) const override { - auto *pixel = const_cast(&this->buffer_[index * 4]); - return {pixel, pixel + 1, pixel + 2, pixel + 3, nullptr, &this->correction_}; - } - + const size_t num_leds_; std::vector buffer_; + light::CorrectedBufferColorAccessor internal_accessor_; }; } // namespace @@ -115,7 +119,7 @@ TEST(PowerLimit, APixelWrittenUnderTheLimitLandsLimited) { ASSERT_LT(scale, 255); // A single pixel rewritten by a lambda lands at the limited level straight away strip[1] = Color(128, 0, 0, 0); - EXPECT_NEAR(strip[1].get_red_raw(), (128 * scale) / 255.0f, 2); + EXPECT_NEAR(strip[1].get_raw().red, (128 * scale) / 255.0f, 2); EXPECT_NEAR(strip[1].get_red(), 128, 2); } @@ -123,7 +127,7 @@ TEST(PowerLimit, LevelsDoNotCreepOverALimitCycle) { Strip strip(4); // Levels written once and never touched again, as a lambda would leave them for (int i = 0; i < 4; i++) - strip[i].set_raw(1 + i * 40, 2, 100, 0); + strip[i].set_raw(Color{uint8_t(1 + i * 40), 2, 100, 0}); // 1020 mA at full level, so the budget of 300 mA limits the strip strip.configure_power(255, 0, 300); strip.limit(); @@ -131,7 +135,7 @@ TEST(PowerLimit, LevelsDoNotCreepOverALimitCycle) { ASSERT_LT(limited, 255); // Lit channels stay lit while limited for (int i = 0; i < 4; i++) - EXPECT_GE(strip[i].get_green_raw(), 1) << i; + EXPECT_GE(strip[i].get_raw().green, 1) << i; // A budget the strip cannot reach, and the next frame restores the scale strip.configure_power(255, 0, 100000); strip.limit(); @@ -140,29 +144,30 @@ TEST(PowerLimit, LevelsDoNotCreepOverALimitCycle) { const int step = (255 + limited - 1) / limited; for (int i = 0; i < 4; i++) { const int red = 1 + i * 40; - EXPECT_GE(strip[i].get_red_raw(), red) << i; - EXPECT_LE(strip[i].get_red_raw(), red + step) << i; - EXPECT_GE(strip[i].get_blue_raw(), 100) << i; - EXPECT_LE(strip[i].get_blue_raw(), 100 + step) << i; + Color raw_color = strip[i].get_raw(); + EXPECT_GE(raw_color.red, red) << i; + EXPECT_LE(raw_color.red, red + step) << i; + EXPECT_GE(raw_color.blue, 100) << i; + EXPECT_LE(raw_color.blue, 100 + step) << i; } } TEST(PowerLimit, ADimPixelSettlesWhileDemandAlternates) { Strip strip(2); strip.configure_power(20, 0, 10.04f); // two full red pixels would draw 40 mA - strip[0].set_raw(1, 0, 0, 0); + strip[0].set_raw(Color{1, 0, 0, 0}); uint8_t dim = 0; for (int cycle = 0; cycle < 4; cycle++) { - strip[1].set_raw(255, 0, 0, 0); + strip[1].set_raw(Color{255, 0, 0, 0}); strip.limit(); ASSERT_LT(strip.power_brightness(), 255) << cycle; - strip[1].set_raw(0, 0, 0, 0); + strip[1].set_raw(Color{0, 0, 0, 0}); strip.limit(); ASSERT_EQ(strip.power_brightness(), 255) << cycle; // The first cycle may move the level by one quantisation step; after that it stays put if (cycle > 0) - EXPECT_EQ(strip[0].get_red_raw(), dim) << cycle; - dim = strip[0].get_red_raw(); + EXPECT_EQ(strip[0].get_raw().red, dim) << cycle; + dim = strip[0].get_raw().red; EXPECT_GE(dim, 1) << cycle; EXPECT_LE(dim, 3) << cycle; } @@ -177,11 +182,11 @@ TEST(PowerLimit, TheStateLimitsAFrameBeforeTheDriverSendsIt) { strip.schedule_show(); state.loop(); EXPECT_LT(strip.sent_red, 255); - EXPECT_EQ(strip.sent_red, strip[0].get_red_raw()); + EXPECT_EQ(strip.sent_red, strip[0].get_raw().red); // Nothing pending: the next loop neither limits nor writes - strip[0].set_raw(255, 0, 0, 0); + strip[0].set_raw(Color{255, 0, 0, 0}); state.loop(); - EXPECT_EQ(strip[0].get_red_raw(), 255); + EXPECT_EQ(strip[0].get_raw().red, 255); } TEST(PowerLimit, ALevelPastTheScaleInForceSaturatesWhenTheScaleRises) { @@ -192,11 +197,11 @@ TEST(PowerLimit, ALevelPastTheScaleInForceSaturatesWhenTheScaleRises) { ASSERT_LT(strip.power_brightness(), 255); // A raw write past the scale, as a lambda or a partition over another strip can make; one such pixel // alone fits the budget, so the scale goes back up - strip[0].set_raw(255, 0, 0, 0); - strip[1].set_raw(0, 0, 0, 0); + strip[0].set_raw(Color{255, 0, 0, 0}); + strip[1].set_raw(Color{0, 0, 0, 0}); strip.limit(); ASSERT_EQ(strip.power_brightness(), 255); - EXPECT_EQ(strip[0].get_red_raw(), 255); + EXPECT_EQ(strip[0].get_raw().red, 255); } TEST(PowerLimit, RepeatedPassesOverAnUnchangedFrameHoldTheScale) { diff --git a/tests/components/partition/common-idf.yaml b/tests/components/partition/test-power-estimation.esp32-c3-idf.yaml similarity index 64% rename from tests/components/partition/common-idf.yaml rename to tests/components/partition/test-power-estimation.esp32-c3-idf.yaml index 8d39670e32f..a7ecce6a60c 100644 --- a/tests/components/partition/common-idf.yaml +++ b/tests/components/partition/test-power-estimation.esp32-c3-idf.yaml @@ -1,11 +1,17 @@ light: - - platform: ${light_platform} + - platform: esp32_rmt_led_strip id: part_leds default_transition_length: 500ms chipset: ws2812 num_leds: 256 channel_colors: GRB - pin: ${pin} + pin: GPIO2 + power_estimation: + ma_per_led_red: 16.0 + ma_per_led_green: 11.0 + ma_per_led_blue: 15.0 + idle_ma_per_led: 1.0 + max_current_ma: 100.0 - platform: partition name: Partition Light segments: diff --git a/tests/components/partition/test.esp32-c3-idf.yaml b/tests/components/partition/test.esp32-c3-idf.yaml deleted file mode 100644 index d9dc4f68048..00000000000 --- a/tests/components/partition/test.esp32-c3-idf.yaml +++ /dev/null @@ -1,5 +0,0 @@ -substitutions: - light_platform: esp32_rmt_led_strip - pin: GPIO2 - -<<: !include common-idf.yaml diff --git a/tests/components/partition/test.esp32-idf.yaml b/tests/components/partition/test.esp32-idf.yaml index 7dc7d155535..dee8c12fd6e 100644 --- a/tests/components/partition/test.esp32-idf.yaml +++ b/tests/components/partition/test.esp32-idf.yaml @@ -1,5 +1,21 @@ -substitutions: - light_platform: esp32_rmt_led_strip - pin: GPIO12 - -<<: !include common-idf.yaml +light: + - platform: esp32_rmt_led_strip + id: part_leds + default_transition_length: 500ms + chipset: ws2812 + num_leds: 256 + channel_colors: GRB + pin: GPIO12 + - platform: partition + name: Partition Light + segments: + - id: part_leds + from: 0 + to: 0 + - id: part_leds + from: 1 + to: 10 + - id: part_leds + from: 20 + to: 25 + - single_light_id: part_leds diff --git a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h index c6b0d10601b..196c57c8a02 100644 --- a/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h +++ b/tests/integration/fixtures/external_components/mock_addressable_light/mock_addressable_light.h @@ -15,7 +15,13 @@ namespace esphome::mock_addressable_light { class MockAddressableLight : public light::AddressableLight { public: explicit MockAddressableLight(uint16_t num_leds) - : num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {} + : num_leds_(num_leds), + buf_(new uint8_t[num_leds * 4]()), + effect_data_(new uint8_t[num_leds]()), + internal_accessor_(this->buf_.get(), this->effect_data_.get(), light::ChannelColors{0, 1, 2, 3}, + &this->correction_) { + this->accessor_ = &this->internal_accessor_; + } void setup() override {} void write_state(light::LightState *state) override {} @@ -38,15 +44,10 @@ class MockAddressableLight : public light::AddressableLight { uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; } protected: - light::ESPColorView get_view_internal(int32_t index) const override { - size_t pos = index * 4; - return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2, - this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_}; - } - uint16_t num_leds_; std::unique_ptr buf_; std::unique_ptr effect_data_; + light::CorrectedBufferColorAccessor internal_accessor_; }; } // namespace esphome::mock_addressable_light diff --git a/tests/unit_tests/components/light/test_channel_colors.py b/tests/unit_tests/components/light/test_channel_colors.py index 0c129a8bb20..b3d91f14427 100644 --- a/tests/unit_tests/components/light/test_channel_colors.py +++ b/tests/unit_tests/components/light/test_channel_colors.py @@ -14,8 +14,6 @@ import esphome.config_validation as cv from esphome.const import CONF_IS_RGBW, CONF_RGB_ORDER from esphome.types import ConfigType -NO_WHITE = "light::ChannelColors::NO_WHITE" - @pytest.mark.parametrize( ("value", "expected"), @@ -52,19 +50,19 @@ def test_validate_channel_colors_rejects_invalid(value: str) -> None: @pytest.mark.parametrize( ("value", "expected"), [ - ("RGB", (0, 1, 2, NO_WHITE)), - ("GRB", (1, 0, 2, NO_WHITE)), - ("BRG", (1, 2, 0, NO_WHITE)), + ("RGB", (0, 1, 2)), + ("GRB", (1, 0, 2)), + ("BRG", (1, 2, 0)), ("RGBW", (0, 1, 2, 3)), ("GRBW", (1, 0, 2, 3)), ("WRGB", (1, 2, 3, 0)), ("GWRB", (2, 0, 3, 1)), ], ) -def test_channel_colors_struct(value: str, expected: tuple[int, int, int, int]) -> None: +def test_channel_colors_struct(value: str, expected: tuple[int, ...]) -> None: struct = channel_colors_struct(value) - assert str(struct.base) == "light::ChannelColors" - assert tuple(str(arg) for arg in struct.args.values()) == tuple( + assert struct.base.base == "light::ChannelColors" + assert tuple(str(arg) for arg in struct.base.args) == tuple( str(field) for field in expected ) From 910c42a8bca9c96e6722990e30fbb066f2fc76c1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:06:37 +1100 Subject: [PATCH 032/170] [mipi_dsi] fix `fill()`, minimise DMA underruns (#21638) --- esphome/components/mipi_dsi/mipi_dsi.cpp | 11 +++++++++++ esphome/components/mipi_dsi/models/m5stack.py | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index 18a9c65fd28..68d1108fdeb 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -3,6 +3,8 @@ #include "mipi_dsi.h" #include "esphome/core/helpers.h" +#include + #include #include @@ -10,6 +12,8 @@ namespace esphome::mipi_dsi { // Maximum bytes to log for init commands (truncated if larger) static constexpr size_t MIPI_DSI_MAX_CMD_LOG_BYTES = 64; +// Highest AXI QoS priority (4 bits); all masters start at 0 +static constexpr uint32_t DSI_DMA_READ_PRIORITY = 15; static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel_event_data_t *edata, void *user_ctx) { SemaphoreHandle_t sem = static_cast(user_ctx); @@ -171,6 +175,12 @@ void MipiDsi::setup() { return; } + // The panel reads its frame buffer from PSRAM all the time through DW-GDMA. With all AXI masters at the same + // priority, heavy PSRAM use by the CPU or the PPA can hold those reads up until the panel runs out of data and + // shows a corrupt frame. Raise the read priority on both DW-GDMA master ports; the panel does not write. + axi_icm_ll_set_dw_gdma_qos_arbiter_prio(0, 0, DSI_DMA_READ_PRIORITY); + axi_icm_ll_set_dw_gdma_qos_arbiter_prio(1, 0, DSI_DMA_READ_PRIORITY); + ESP_LOGCONFIG(TAG, "MIPI DSI setup complete"); } @@ -380,6 +390,7 @@ void MipiDsi::fill(Color color) { default: break; } + this->mark_dirty_(0, 0, this->get_width(), this->get_height()); } int MipiDsi::get_width() { diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 4c30ac943b3..828972b3724 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -66,7 +66,8 @@ TAB5_ST7123 = DsiDriverChip( vsync_back_porch=8, vsync_pulse_width=2, vsync_front_porch=220, - pclk_frequency="80MHz", + # 80MHz left the panel's PSRAM reads too little time under load (underruns); the ST7121 also runs at 70MHz + pclk_frequency="70MHz", lane_bit_rate="960Mbps", color_order="RGB", requires={"psram"}, From fc921d0a96d2c9806db26f2f32871750cec12675 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:25:09 +1100 Subject: [PATCH 033/170] [rtttl] Fade speaker notes in and out to stop clicks (#22075) --- esphome/components/rtttl/rtttl.cpp | 14 ++++++++++++- esphome/components/rtttl/rtttl.h | 3 +++ tests/components/rtttl/test_rtttl_parser.cpp | 22 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index db503c3b8be..03a93873f48 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -27,6 +27,9 @@ static constexpr uint8_t NOTES_COUNT = static_cast(sizeof(NOTES) / size static constexpr uint8_t REPEATING_NOTE_GAP_MS = 10; +// Each note fades in and out over 2^5 samples (2 ms at 16 kHz) +static constexpr uint8_t NOTE_FADE_BITS = 5; + // The first quarter of a sine wave at full scale: round(32767 * sin(i * pi / 128)) static constexpr uint8_t QUARTER_SINE_BITS = 6; static constexpr uint16_t QUARTER_SINE[(1 << QUARTER_SINE_BITS) + 1] PROGMEM = { @@ -131,7 +134,9 @@ void Rtttl::loop() { // Try and send out the remainder of the existing note, one per `loop()` if (this->phase_step_ != 0 && this->samples_sent_ >= this->samples_gap_) { // Play note // The multiplication wraps at exactly one full cycle of the phase - sample[sample_index] = sine_sample(this->samples_sent_ * this->phase_step_); + sample[sample_index] = + fade_sample(sine_sample(this->samples_sent_ * this->phase_step_), + this->samples_sent_ - this->samples_gap_, this->samples_count_ - 1 - this->samples_sent_); } else { sample[sample_index] = 0; } @@ -282,6 +287,13 @@ int16_t Rtttl::sine_sample(uint32_t phase) { return static_cast((phase >> 31) != 0 ? -value : value); } +int16_t Rtttl::fade_sample(int16_t value, uint32_t from_start, uint32_t to_end) { + const uint32_t edge = std::min(from_start, to_end); + if (edge >= (1U << NOTE_FADE_BITS)) + return value; + return static_cast((value * static_cast(edge)) >> NOTE_FADE_BITS); +} + void Rtttl::play(std::string rtttl) { if (this->is_busy_()) { return; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 7a8ab898fc5..c2a26bc658e 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -102,6 +102,9 @@ class Rtttl final : public Component { void set_state_(State state); /// The value of a full scale sine wave at phase, where the full uint32_t range is one cycle. static int16_t sine_sample(uint32_t phase); + /// Scales a sample near either end of a note, so the note does not click when the wave starts or stops away from + /// zero. from_start and to_end count the samples between this one and each end of the note. + static int16_t fade_sample(int16_t value, uint32_t from_start, uint32_t to_end); /// The song being played; points at owned_song_ or at static storage. const char *song_{nullptr}; diff --git a/tests/components/rtttl/test_rtttl_parser.cpp b/tests/components/rtttl/test_rtttl_parser.cpp index e3ee18af27e..5b84a5c2b90 100644 --- a/tests/components/rtttl/test_rtttl_parser.cpp +++ b/tests/components/rtttl/test_rtttl_parser.cpp @@ -21,6 +21,9 @@ class RtttlParserAccess { static char at(const Rtttl &p, size_t pos) { return p.song_at_(pos); } static void set_running(Rtttl &p) { p.state_ = State::RUNNING; } static int16_t sine(uint32_t phase) { return Rtttl::sine_sample(phase); } + static int16_t fade(int16_t value, uint32_t from_start, uint32_t to_end) { + return Rtttl::fade_sample(value, from_start, to_end); + } }; using Access = RtttlParserAccess; @@ -45,6 +48,25 @@ TEST(RtttlSine, HitsTheQuarterPointsOfTheWave) { EXPECT_NEAR(Access::sine(0xFFFFFFFF), 0, 1); } +TEST(RtttlFade, FirstAndLastSamplesOfANoteAreSilent) { + EXPECT_EQ(Access::fade(INT16_MAX, 0, 1000), 0); + EXPECT_EQ(Access::fade(INT16_MIN, 1000, 0), 0); +} + +TEST(RtttlFade, RampsAcrossTheFade) { + EXPECT_EQ(Access::fade(3200, 16, 1000), 1600); + EXPECT_EQ(Access::fade(3200, 1000, 16), 1600); + EXPECT_EQ(Access::fade(-3200, 16, 1000), -1600); + EXPECT_EQ(Access::fade(3200, 31, 1000), 3100); +} + +TEST(RtttlFade, LeavesTheMiddleOfANoteUnchanged) { + EXPECT_EQ(Access::fade(INT16_MAX, 32, 32), INT16_MAX); + EXPECT_EQ(Access::fade(INT16_MIN, 1000, 1000), INT16_MIN); +} + +TEST(RtttlFade, UsesTheNearerEndOfAVeryShortNote) { EXPECT_EQ(Access::fade(3200, 16, 8), 800); } + TEST(RtttlParser, StaticSongParsesTheHeader) { Rtttl player; player.play(StaticSong{SONG}); From d4d3df819c8b67006f9abfa174f4c0442f063a0a Mon Sep 17 00:00:00 2001 From: William Howell Date: Sun, 1 Nov 2026 13:25:22 +1300 Subject: [PATCH 034/170] [fujitsu_general] Add buttons for the vane step and economy commands (#14271) Co-authored-by: J. Nick Koston --- .../components/fujitsu_general/__init__.py | 4 + .../fujitsu_general/button/__init__.py | 42 ++++++++ esphome/components/fujitsu_general/climate.py | 4 +- .../fujitsu_general/fujitsu_general.cpp | 18 ++-- .../fujitsu_general/fujitsu_general.h | 11 +- tests/components/fujitsu_general/common.yaml | 11 ++ .../fujitsu_general/fujitsu_general_test.cpp | 101 ++++++++++++++++++ 7 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 esphome/components/fujitsu_general/button/__init__.py diff --git a/esphome/components/fujitsu_general/__init__.py b/esphome/components/fujitsu_general/__init__.py index fdb21576be4..9504203cc77 100644 --- a/esphome/components/fujitsu_general/__init__.py +++ b/esphome/components/fujitsu_general/__init__.py @@ -1 +1,5 @@ +import esphome.codegen as cg + DOMAIN = "fujitsu_general" + +fujitsu_general_ns = cg.esphome_ns.namespace("fujitsu_general") diff --git a/esphome/components/fujitsu_general/button/__init__.py b/esphome/components/fujitsu_general/button/__init__.py new file mode 100644 index 00000000000..a3f4f373b5c --- /dev/null +++ b/esphome/components/fujitsu_general/button/__init__.py @@ -0,0 +1,42 @@ +from esphome.components import button +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import fujitsu_general_ns +from ..climate import FujitsuGeneralClimate + +CONF_FUJITSU_GENERAL_ID = "fujitsu_general_id" +CONF_STEP_VERTICAL = "step_vertical" +CONF_STEP_HORIZONTAL = "step_horizontal" +CONF_ECONOMY = "economy" + +# Config key, the command byte the button sends, icon +BUTTONS = ( + ( + CONF_STEP_VERTICAL, + fujitsu_general_ns.FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_VERTICAL, + "mdi:arrow-up-down-bold", + ), + ( + CONF_STEP_HORIZONTAL, + fujitsu_general_ns.FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_HORIZONTAL, + "mdi:arrow-left-right-bold", + ), + (CONF_ECONOMY, fujitsu_general_ns.FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY, "mdi:leaf"), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_FUJITSU_GENERAL_ID): cv.use_id(FujitsuGeneralClimate), + **{ + cv.Optional(key): button.button_schema(button.ApplyButton, icon=icon) + for key, _, icon in BUTTONS + }, + } +) + + +async def to_code(config: ConfigType) -> None: + buttons = button.sub_buttons(config, parent=config[CONF_FUJITSU_GENERAL_ID]) + for key, command, _ in BUTTONS: + await buttons(key, None, apply=f"transmit_util({command})") diff --git a/esphome/components/fujitsu_general/climate.py b/esphome/components/fujitsu_general/climate.py index c2c41730e3b..5447dcb6d6d 100644 --- a/esphome/components/fujitsu_general/climate.py +++ b/esphome/components/fujitsu_general/climate.py @@ -1,10 +1,10 @@ -import esphome.codegen as cg from esphome.components import climate_ir from esphome.types import ConfigType +from . import fujitsu_general_ns + AUTO_LOAD = ["climate_ir"] -fujitsu_general_ns = cg.esphome_ns.namespace("fujitsu_general") FujitsuGeneralClimate = fujitsu_general_ns.class_( "FujitsuGeneralClimate", climate_ir.ClimateIR ) diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 559e6ae831a..aec74e7c0c8 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -19,9 +19,8 @@ constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_STATE = 0xFE; // Util messages - off & eco etc. constexpr uint8_t FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH = 7; +// Turns the unit off; a util message, but transmit_state() owns it because it changes the climate state constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_OFF = 0x02; -constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; -constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE = 0x6C; // State header constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE0 = 0x09; @@ -76,7 +75,9 @@ constexpr uint32_t FUJITSU_GENERAL_CARRIER_FREQUENCY = 38000; void FujitsuGeneralClimate::transmit_state() { if (this->mode == climate::CLIMATE_MODE_OFF) { - this->transmit_off_(); + ESP_LOGV(TAG, "Transmit off"); + this->transmit_util(FUJITSU_GENERAL_MESSAGE_TYPE_OFF); + this->power_ = false; return; } @@ -175,9 +176,7 @@ void FujitsuGeneralClimate::transmit_state() { this->power_ = true; } -void FujitsuGeneralClimate::transmit_off_() { - ESP_LOGV(TAG, "Transmit off"); - +void FujitsuGeneralClimate::transmit_util(uint8_t command) { uint8_t remote_state[FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH] = {0}; remote_state[0] = FUJITSU_GENERAL_COMMON_BYTE0; @@ -185,12 +184,10 @@ void FujitsuGeneralClimate::transmit_off_() { remote_state[2] = FUJITSU_GENERAL_COMMON_BYTE2; remote_state[3] = FUJITSU_GENERAL_COMMON_BYTE3; remote_state[4] = FUJITSU_GENERAL_COMMON_BYTE4; - remote_state[5] = FUJITSU_GENERAL_MESSAGE_TYPE_OFF; + remote_state[5] = command; remote_state[6] = this->checksum_util_(remote_state); this->transmit_(remote_state, FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH); - - this->power_ = false; } void FujitsuGeneralClimate::transmit_(uint8_t const *message, uint8_t length) { @@ -294,7 +291,8 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { break; case FUJITSU_GENERAL_MESSAGE_TYPE_OFF: case FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY: - case FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE: + case FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_HORIZONTAL: + case FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_VERTICAL: ESP_LOGV(TAG, "Received util message"); recv_message_length = FUJITSU_GENERAL_UTIL_MESSAGE_LENGTH; break; diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index 20bda883b2e..20e0e439af5 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -10,6 +10,12 @@ namespace esphome::fujitsu_general { constexpr uint8_t FUJITSU_GENERAL_TEMP_MIN = 16; // Celsius // TODO 16 for heating, 18 for cooling, unsupported in ESPH constexpr uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius +// Stateless util commands the button platform sends; OFF is a util message too but changes the +// climate state, so it stays with transmit_state() in the .cpp +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY = 0x09; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_VERTICAL = 0x6C; +constexpr uint8_t FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_HORIZONTAL = 0x79; + // clang-format off /** * ``` @@ -82,11 +88,12 @@ class FujitsuGeneralClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} + /// Transmit a util message that does not change the climate state (economy, vane steps) + void transmit_util(uint8_t command); + protected: /// Transmit via IR the state of this climate controller. void transmit_state() override; - /// Transmit via IR power off command. - void transmit_off_(); /// Parse incoming message bool on_receive(remote_base::RemoteReceiveData data) override; diff --git a/tests/components/fujitsu_general/common.yaml b/tests/components/fujitsu_general/common.yaml index 51bd1441c00..980b8047649 100644 --- a/tests/components/fujitsu_general/common.yaml +++ b/tests/components/fujitsu_general/common.yaml @@ -2,3 +2,14 @@ climate: - platform: fujitsu_general name: Fujitsu General Climate transmitter_id: xmitr + id: fujitsu_ac + +button: + - platform: fujitsu_general + fujitsu_general_id: fujitsu_ac + step_horizontal: + name: "Step Horizontal" + step_vertical: + name: "Step Vertical" + economy: + name: "Toggle Economy Mode" diff --git a/tests/components/fujitsu_general/fujitsu_general_test.cpp b/tests/components/fujitsu_general/fujitsu_general_test.cpp index 65b204131a2..d5922280a59 100644 --- a/tests/components/fujitsu_general/fujitsu_general_test.cpp +++ b/tests/components/fujitsu_general/fujitsu_general_test.cpp @@ -1,4 +1,7 @@ #include +#include +#include + #include "esphome/components/fujitsu_general/fujitsu_general.h" namespace esphome::fujitsu_general::testing { @@ -251,4 +254,102 @@ TEST(FujitsuGeneralCaptureTest, DecodesEveryCapturedFrame) { } } +// Transmissions, read back from the timings the component hands the transmitter. + +namespace { + +// Records the last transmission instead of sending it +class CapturingTransmitter : public remote_base::RemoteTransmitterBase { + public: + CapturingTransmitter() : RemoteTransmitterBase(nullptr) {} + int sends{0}; + remote_base::RawTimings last; + + protected: + void send_internal(uint32_t, uint32_t) override { + this->sends++; + this->last = this->temp_.get_data(); + } +}; + +// The off command is a util message that transmit_state() sends; the .cpp keeps its code private. +constexpr uint8_t MESSAGE_TYPE_OFF = 0x02; + +// The bytes of a transmission: a header mark and space, then a mark and a short or long space per +// bit, least significant bit first, then a trailer mark and space. +std::vector bytes_of(const remote_base::RawTimings &timings) { + std::vector bytes((timings.size() - 4) / 16); + for (size_t bit = 0; bit < bytes.size() * 8; bit++) { + if (-timings[3 + 2 * bit] > 800) + bytes[bit / 8] |= 1 << (bit % 8); + } + return bytes; +} + +std::vector util_frame(uint8_t command) { + return {0x14, 0x63, 0x00, 0x10, 0x10, command, static_cast(255 - command)}; +} + +std::vector captured(const char *label) { + for (const auto &frame : CAPTURED_FRAMES) { + if (strcmp(frame.label, label) == 0) + return {std::begin(frame.bytes), std::end(frame.bytes)}; + } + return {}; +} + +struct Fixture { + FujitsuGeneralClimate sut; + CapturingTransmitter transmitter; + + // 16 degrees with everything else at its first value, so the state frames match the captures. + Fixture() { + this->sut.set_transmitter(&this->transmitter); + this->sut.target_temperature = 16; + this->sut.fan_mode = climate::CLIMATE_FAN_AUTO; + this->sut.swing_mode = climate::CLIMATE_SWING_OFF; + } + + // Transmits the state the way a climate call does + void send_mode(climate::ClimateMode mode) { this->sut.make_call().set_mode(mode).perform(); } + + std::vector sent() const { return bytes_of(this->transmitter.last); } +}; + +} // namespace + +TEST(FujitsuGeneralTransmitTest, UtilCommandsSendASevenByteFrameAndLeaveTheStateAlone) { + Fixture f; + f.send_mode(climate::CLIMATE_MODE_HEAT); + ASSERT_EQ(f.sent(), captured("on at 16")); + + for (uint8_t command : {FUJITSU_GENERAL_MESSAGE_TYPE_ECONOMY, FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_VERTICAL, + FUJITSU_GENERAL_MESSAGE_TYPE_NUDGE_HORIZONTAL}) { + SCOPED_TRACE(static_cast(command)); + f.sut.transmit_util(command); + EXPECT_EQ(f.sent(), util_frame(command)); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.sut.target_temperature, 16); + } + + // The unit is still on, so the next state frame carries no power-on flag + f.send_mode(climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(f.sent(), captured("down to 16")); + EXPECT_EQ(f.transmitter.sends, 5); +} + +TEST(FujitsuGeneralTransmitTest, OffIsAUtilFrameAndTheNextStateFrameTurnsTheUnitOn) { + Fixture f; + f.send_mode(climate::CLIMATE_MODE_HEAT); + f.send_mode(climate::CLIMATE_MODE_HEAT); + ASSERT_EQ(f.sent(), captured("down to 16")); + + f.send_mode(climate::CLIMATE_MODE_OFF); + EXPECT_EQ(f.sent(), util_frame(MESSAGE_TYPE_OFF)); + EXPECT_EQ(f.sut.mode, climate::CLIMATE_MODE_OFF); + + f.send_mode(climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(f.sent(), captured("on at 16")); +} + } // namespace esphome::fujitsu_general::testing From e8fe6984b7d910f4392fbbfdd833797af4b15a14 Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 31 Oct 2026 18:38:15 -0700 Subject: [PATCH 035/170] [light] Tidy up addressable light setup (#22078) --- .../beken_spi_led_strip/led_strip.cpp | 16 +++++----------- .../esp32_rmt_led_strip/led_strip.cpp | 12 ++++-------- .../rp2040_pio_led_strip/led_strip.cpp | 12 ++++-------- .../spi_led_strip/spi_led_strip.cpp | 19 +++++++------------ 4 files changed, 20 insertions(+), 39 deletions(-) diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 4647a294b5f..a42166353d4 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -159,19 +159,16 @@ void spi_dma_tx_finish_callback(unsigned int param) { } void BekenSPILEDStripLightOutput::setup() { - size_t buffer_size = this->get_buffer_size_(); - size_t dma_buffer_size = (buffer_size * 8) + (2 * 64); - RAMAllocator allocator; if (!this->internal_accessor_.allocate(allocator, this->num_leds_)) { this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } + const size_t dma_buffer_size = (this->get_buffer_size_() * 8) + (2 * 64); this->dma_buf_ = allocator.allocate(dma_buffer_size); if (this->dma_buf_ == nullptr) { - ESP_LOGE(TAG, "Cannot allocate DMA buffer!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } @@ -184,22 +181,19 @@ void BekenSPILEDStripLightOutput::setup() { sddev_control(ICU_DEV_NAME, CMD_CLK_PWR_UP, &value); if (spi_data != nullptr) { - ESP_LOGE(TAG, "SPI device already initialized!"); - this->mark_failed(); + this->mark_failed(LOG_STR("SPI already initialized")); return; } spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc) if (spi_data == nullptr) { - ESP_LOGE(TAG, "Cannot allocate spi_data!"); - this->mark_failed(); + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } spi_data->dma_tx_semaphore = xSemaphoreCreateBinary(); if (spi_data->dma_tx_semaphore == nullptr) { - ESP_LOGE(TAG, "TX Semaphore init faild!"); - this->mark_failed(); + this->mark_failed(LOG_STR("TX semaphore init failed")); return; } diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 5ad9511cbaf..ac0bce3c603 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -105,8 +105,7 @@ void ESP32RMTLEDStripLightOutput::setup() { channel.flags.with_dma = this->use_dma_; channel.intr_priority = 0; if (rmt_new_tx_channel(&channel, &this->channel_) != ESP_OK) { - ESP_LOGE(TAG, "Channel creation failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Channel creation failed")); return; } @@ -117,23 +116,20 @@ void ESP32RMTLEDStripLightOutput::setup() { encoder.arg = &this->params_; encoder.min_chunk_size = RMT_SYMBOLS_PER_BYTE; if (rmt_new_simple_encoder(&encoder, &this->encoder_) != ESP_OK) { - ESP_LOGE(TAG, "Encoder creation failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Encoder creation failed")); return; } #else rmt_copy_encoder_config_t encoder; memset(&encoder, 0, sizeof(encoder)); if (rmt_new_copy_encoder(&encoder, &this->encoder_) != ESP_OK) { - ESP_LOGE(TAG, "Encoder creation failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Encoder creation failed")); return; } #endif if (rmt_enable(this->channel_) != ESP_OK) { - ESP_LOGE(TAG, "Enabling channel failed"); - this->mark_failed(); + this->mark_failed(LOG_STR("Enabling channel failed")); return; } diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 1bc82656de2..c7d8fa713b1 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -38,8 +38,7 @@ void RP2040PIOLEDStripLightOutput::setup() { // Select PIO instance to use (0 or 1) if (this->pio_ == nullptr) { - ESP_LOGE(TAG, "Failed to claim PIO instance"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to claim PIO instance")); return; } @@ -48,8 +47,7 @@ void RP2040PIOLEDStripLightOutput::setup() { uint offset = 0; if (RP2040PIOLEDStripLightOutput::num_instance[this->pio_ == pio0 ? 0 : 1] >= 4) { - ESP_LOGE(TAG, "Too many instances of PIO program"); - this->mark_failed(); + this->mark_failed(LOG_STR("Too many instances of PIO program")); return; } // keep track of how many instances of the PIO program are running on each PIO @@ -69,8 +67,7 @@ void RP2040PIOLEDStripLightOutput::setup() { this->sm_ = pio_claim_unused_sm(this->pio_, true); if (this->sm_ < 0) { // in theory this code should never be reached - ESP_LOGE(TAG, "Failed to claim PIO state machine"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to claim PIO state machine")); return; } @@ -78,8 +75,7 @@ void RP2040PIOLEDStripLightOutput::setup() { this->dma_chan_ = dma_claim_unused_channel(true); if (this->dma_chan_ < 0) { - ESP_LOGE(TAG, "Failed to claim DMA channel"); - this->mark_failed(); + this->mark_failed(LOG_STR("Failed to claim DMA channel")); return; } diff --git a/esphome/components/spi_led_strip/spi_led_strip.cpp b/esphome/components/spi_led_strip/spi_led_strip.cpp index 115b4e22e19..1a1d749dd28 100644 --- a/esphome/components/spi_led_strip/spi_led_strip.cpp +++ b/esphome/components/spi_led_strip/spi_led_strip.cpp @@ -4,28 +4,23 @@ namespace esphome::spi_led_strip { constexpr size_t COLOR_DATA_OFFSET = 5; +constexpr size_t BUFFER_PADDING = 8; -SpiLedStrip::SpiLedStrip(uint16_t num_leds) { - this->num_leds_ = num_leds; +SpiLedStrip::SpiLedStrip(uint16_t num_leds) : buffer_size_(num_leds * 4 + BUFFER_PADDING), num_leds_(num_leds) {} +void SpiLedStrip::setup() { RAMAllocator allocator; - this->buffer_size_ = num_leds * 4 + 8; - uint8_t *buf = allocator.allocate(this->buffer_size_ + num_leds); + uint8_t *buf = allocator.allocate(this->buffer_size_ + this->num_leds_); if (buf == nullptr) { - ESP_LOGE(TAG, "Failed to allocate buffer of size %u", this->buffer_size_ + num_leds); + this->mark_failed(LOG_STR(ESP_LOG_MSG_ALLOC_FAIL)); return; } memset(buf, 0xFF, this->buffer_size_); memset(buf, 0, 4); this->internal_accessor_.color_data = buf + COLOR_DATA_OFFSET; this->internal_accessor_.effect_data = buf + this->buffer_size_; - memset(this->internal_accessor_.effect_data, 0, num_leds); + this->internal_accessor_.clear_effect_data(this->num_leds_); this->accessor_ = &this->internal_accessor_; -} -void SpiLedStrip::setup() { - if (this->internal_accessor_.effect_data == nullptr || this->internal_accessor_.color_data == nullptr) { - this->mark_failed(); - return; - } + this->spi_setup(); } light::LightTraits SpiLedStrip::get_traits() { From da6ab5ec1e188508dd92a5d83de59621349c860a Mon Sep 17 00:00:00 2001 From: andytrofimov Date: Sun, 1 Nov 2026 06:02:13 +0300 Subject: [PATCH 036/170] [audio] Release partial HTTP audio reads during synthesis gaps (#21090) --- esphome/components/audio/audio_reader.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index ea3c82e637e..8bd22446dea 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include + #include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -19,6 +21,9 @@ static const uint32_t CONNECTION_TIMEOUT_MS = 5000; static const uint8_t MAX_FETCHING_HEADER_ATTEMPTS = 6; static const size_t HTTP_STREAM_BUFFER_SIZE = 2048; +// esp_http_client_read() blocks until it fills the request; small requests deliver a phrase tail before a pause ends +static constexpr size_t HTTP_READ_MAX_BYTES = 1024; +static constexpr int HTTP_BODY_TIMEOUT_MS = 100; static const uint8_t MAX_REDIRECTIONS = 5; @@ -196,10 +201,18 @@ esp_err_t AudioReader::start(const std::string &uri, AudioFileType &file_type) { file_type = this->audio_file_type_; } + // Keep the longer timeout for connection, headers, and redirects. + err = esp_http_client_set_timeout_ms(this->client_, HTTP_BODY_TIMEOUT_MS); + if (err != ESP_OK) { + this->cleanup_connection_(); + return err; + } + this->last_data_read_ms_ = millis(); this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(this->buffer_size_); if (this->output_transfer_buffer_ == nullptr) { + this->cleanup_connection_(); return ESP_ERR_NO_MEM; } @@ -254,8 +267,9 @@ AudioReaderState AudioReader::http_read_() { return AudioReaderState::FINISHED; } } else if (this->output_transfer_buffer_->free() > 0) { - int received_len = esp_http_client_read(this->client_, (char *) this->output_transfer_buffer_->get_buffer_end(), - this->output_transfer_buffer_->free()); + size_t read_size = std::min(this->output_transfer_buffer_->free(), HTTP_READ_MAX_BYTES); + int received_len = + esp_http_client_read(this->client_, (char *) this->output_transfer_buffer_->get_buffer_end(), read_size); if (received_len > 0) { this->output_transfer_buffer_->increase_buffer_length(received_len); @@ -278,6 +292,9 @@ AudioReaderState AudioReader::http_read_() { delay(READ_WRITE_TIMEOUT_MS); } + } else { + // A full buffer means the consumer is behind, not that the server went quiet + this->last_data_read_ms_ = millis(); } return AudioReaderState::READING; From d902d1e84f3fe834996565e529b242fcafe5b7bf Mon Sep 17 00:00:00 2001 From: david-collett Date: Sun, 1 Nov 2026 14:05:56 +1100 Subject: [PATCH 037/170] [esp32_ble_server] Add secure connection support (#17562) --- esphome/components/ble_client/__init__.py | 14 +- .../components/ble_device_base/__init__.py | 3 + .../ble_device_base/server_automation.py | 116 +++++++ esphome/components/const/__init__.py | 3 + esphome/components/esp32_ble/ble_event.h | 14 +- .../components/esp32_ble_server/__init__.py | 283 ++++++++++++++++-- .../esp32_ble_server/ble_characteristic.cpp | 8 +- .../esp32_ble_server/ble_characteristic.h | 4 +- .../esp32_ble_server/ble_descriptor.cpp | 15 +- .../esp32_ble_server/ble_descriptor.h | 3 +- .../esp32_ble_server/ble_server.cpp | 116 ++++++- .../components/esp32_ble_server/ble_server.h | 53 +++- .../esp32_ble_server/ble_service.cpp | 15 +- .../components/esp32_ble_server/ble_service.h | 9 +- .../components/zephyr_ble_server/__init__.py | 6 +- esphome/core/defines.h | 5 + .../config/auth_req_mode_only.yaml | 17 ++ .../esp32_ble_server/config/pairing.yaml | 18 ++ .../config/pairing_action_only.yaml | 19 ++ .../config/reply_actions_only.yaml | 24 ++ .../esp32_ble_server/test_esp32_ble_server.py | 154 +++++++++- tests/components/esp32_ble_server/common.yaml | 37 +++ 22 files changed, 877 insertions(+), 59 deletions(-) create mode 100644 esphome/components/ble_device_base/server_automation.py create mode 100644 tests/component_tests/esp32_ble_server/config/auth_req_mode_only.yaml create mode 100644 tests/component_tests/esp32_ble_server/config/pairing.yaml create mode 100644 tests/component_tests/esp32_ble_server/config/pairing_action_only.yaml create mode 100644 tests/component_tests/esp32_ble_server/config/reply_actions_only.yaml diff --git a/esphome/components/ble_client/__init__.py b/esphome/components/ble_client/__init__.py index 53ecc53ce47..827b369b0a2 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -5,7 +5,14 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import esp32_ble, esp32_ble_client, esp32_ble_tracker -from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST +from esphome.components.ble_device_base import validate_passkey +from esphome.components.const import ( + CONF_ACCEPT, + CONF_ON_NUMERIC_COMPARISON_REQUEST, + CONF_ON_PASSKEY_NOTIFICATION, + CONF_ON_PASSKEY_REQUEST, + CONF_PASSKEY, +) from esphome.components.esp32_ble import BTLoggers import esphome.config_validation as cv from esphome.const import ( @@ -101,9 +108,6 @@ BLERemoveBondAction = ble_client_ns.class_( "BLEClientRemoveBondAction", automation.Action ) -CONF_PASSKEY = "passkey" -CONF_ON_PASSKEY_REQUEST = "on_passkey_request" -CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification" CONF_AUTO_CONNECT = "auto_connect" MULTI_CONF = True @@ -205,7 +209,7 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( BLE_PASSKEY_REPLY_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(BLEClient), - cv.Required(CONF_PASSKEY): cv.templatable(cv.int_range(min=0, max=999999)), + cv.Required(CONF_PASSKEY): cv.templatable(validate_passkey), } ) diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 9f089034143..1e6db30fb1b 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -46,6 +46,9 @@ DOMAIN = "ble_device_base" CONF_BLE_HUB_ID = "ble_hub_id" +# A six digit pairing passkey +validate_passkey = cv.int_range(min=0, max=999999) + # Number of parsed-advertisement listeners registered in this build; read via # cg.get_slot_count() by esp32_ble_tracker's feature coupling. LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT" diff --git a/esphome/components/ble_device_base/server_automation.py b/esphome/components/ble_device_base/server_automation.py new file mode 100644 index 00000000000..9a4d6e61473 --- /dev/null +++ b/esphome/components/ble_device_base/server_automation.py @@ -0,0 +1,116 @@ +"""Actions shared by the BLE server platforms (esp32_ble_server, zephyr_ble_server). + +An action name lives in one registry, so a platform cannot register it on its own without +replacing another platform's entry. Each server instead adds its schema and builder here, +and the action validates and builds with the one for the target platform. +""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import ID +from esphome.cpp_generator import TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType + +ActionBuilder = Callable[ + [ConfigType, ID, cg.TemplateArguments, TemplateArgsType], Awaitable[cg.MockObj] +] + + +@dataclass(frozen=True) +class _PlatformAction: + is_target: Callable[[], bool] + schema: cv.Schema | cv.All + builder: ActionBuilder + + +_ACTIONS: dict[str, list[_PlatformAction]] = {} + + +def _for_target(name: str) -> _PlatformAction: + for platform in _ACTIONS[name]: + if platform.is_target(): + return platform + raise cv.Invalid(f"{name} is not supported on this platform") + + +def _keys_schema(schema: cv.Schema | cv.All) -> cv.Schema: + """The dict schema of ``schema``, or of a ``cv.All`` that adds validators after it.""" + if isinstance(schema, cv.All): + return next(v for v in schema.validators if isinstance(v, cv.Schema)) + return schema + + +def _merged_schema(platforms: list[_PlatformAction]) -> cv.Schema: + """All platforms' keys, with a key that not every platform takes shown as optional.""" + schemas = [_keys_schema(p.schema).schema for p in platforms] + shared = set.intersection(*({str(key) for key in s} for s in schemas)) + merged: dict[Any, Any] = {} + seen: set[str] = set() + for schema in schemas: + for key, validator in schema.items(): + if (text := str(key)) in seen: + continue + seen.add(text) + merged[key if text in shared else cv.Optional(text)] = validator + return cv.Schema(merged) + + +def register_server_action( + name: str, + is_target: Callable[[], bool], + schema: cv.Schema | cv.All, + builder: ActionBuilder, +) -> None: + """Add a server platform's version of the action ``name``. + + ``is_target`` tells whether the build is for this platform, e.g. ``lambda: CORE.is_esp32``. + """ + if (platforms := _ACTIONS.get(name)) is None: + platforms = _ACTIONS[name] = [] + + # The schema depends on the target, so the language schema shows every platform's keys + @schema_extractor("schema") + def validate(value: Any) -> ConfigType: + if value is SCHEMA_EXTRACT: + return _merged_schema(platforms) + return _for_target(name).schema(value) + + async def dispatch( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> cg.MockObj: + return await _for_target(name).builder( + config, action_id, template_arg, args + ) + + automation.register_action( + name, automation.ApplyAction, validate, synchronous=True + )(dispatch) + platforms.append(_PlatformAction(is_target, schema, builder)) + + +def register_server_apply_action( + name: str, + is_target: Callable[[], bool], + schema: cv.Schema | cv.All, + *fields: automation.ApplyField | automation.ApplyCall, +) -> None: + """Add a server platform's version of the apply action ``name``. + + The other arguments are those of ``register_server_action`` and + ``automation.register_apply_action``. + """ + register_server_action( + name, + is_target, + schema, + automation.apply_action_builder(name, schema, *fields), + ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index a83f304be6d..fd1643d8f63 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -61,6 +61,8 @@ CONF_MODULATION = "modulation" CONF_NOX_INDEX = "nox_index" CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request" CONF_ON_PACKET = "on_packet" +CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification" +CONF_ON_PASSKEY_REQUEST = "on_passkey_request" CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" @@ -68,6 +70,7 @@ CONF_ON_STOP = "on_stop" CONF_OUT_PIN = "out_pin" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" +CONF_PASSKEY = "passkey" CONF_PROVIDER = "provider" CONF_PWM_FREQUENCY = "pwm_frequency" CONF_QUERY_PARAMS = "query_params" diff --git a/esphome/components/esp32_ble/ble_event.h b/esphome/components/esp32_ble/ble_event.h index babfa937c7a..cb7557d3502 100644 --- a/esphome/components/esp32_ble/ble_event.h +++ b/esphome/components/esp32_ble/ble_event.h @@ -213,7 +213,7 @@ class BLEEvent { // Used by: ble_client (ble_rssi_sensor component) RSSICompleteData read_rssi_complete; // 8 bytes // Security events - we store the full security union - // Used by: ble_client (automation), bluetooth_proxy, esp32_ble_client + // Used by: ble_client (automation), bluetooth_proxy, esp32_ble_client, esp32_ble_server esp_ble_sec_t security; // Variable size, but fits within scan_result size }; } gap; // 80 bytes total @@ -343,12 +343,12 @@ class BLEEvent { break; // Security events - copy the entire security union - // Used by: ble_client, bluetooth_proxy, esp32_ble_client - case ESP_GAP_BLE_AUTH_CMPL_EVT: // Used by: bluetooth_proxy, esp32_ble_client - case ESP_GAP_BLE_SEC_REQ_EVT: // Used by: esp32_ble_client - case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: // Used by: ble_client automation - case ESP_GAP_BLE_PASSKEY_REQ_EVT: // Used by: ble_client automation - case ESP_GAP_BLE_NC_REQ_EVT: // Used by: ble_client automation + // Used by: ble_client, bluetooth_proxy, esp32_ble_client, esp32_ble_server + case ESP_GAP_BLE_AUTH_CMPL_EVT: // Used by: bluetooth_proxy, esp32_ble_client, esp32_ble_server + case ESP_GAP_BLE_SEC_REQ_EVT: // Used by: esp32_ble_client, esp32_ble_server + case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: // Used by: ble_client, esp32_ble_server automations + case ESP_GAP_BLE_PASSKEY_REQ_EVT: // Used by: ble_client, esp32_ble_server automations + case ESP_GAP_BLE_NC_REQ_EVT: // Used by: ble_client, esp32_ble_server automations memcpy(&this->event_.gap.security, &p->ble_security, sizeof(esp_ble_sec_t)); break; diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 6cc768c3e4d..78a58ae435d 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field import encodings import struct from typing import Any @@ -5,12 +6,25 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble -from esphome.components.const import CONF_DESCRIPTION, CONF_MANUFACTURER +from esphome.components.ble_device_base import validate_passkey +from esphome.components.ble_device_base.server_automation import ( + register_server_apply_action, +) +from esphome.components.const import ( + CONF_ACCEPT, + CONF_DESCRIPTION, + CONF_MANUFACTURER, + CONF_ON_NUMERIC_COMPARISON_REQUEST, + CONF_ON_PASSKEY_NOTIFICATION, + CONF_ON_PASSKEY_REQUEST, + CONF_PASSKEY, +) from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import DOMAIN as ESP32_BLE_DOMAIN, BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED from esphome.const import ( + CONF_ADDRESS, CONF_DATA, CONF_ESPHOME, CONF_ID, @@ -26,8 +40,8 @@ from esphome.const import ( CONF_VALUE, __version__ as ESPHOME_VERSION, ) -from esphome.core import CORE -from esphome.cpp_generator import MockObj +from esphome.core import CORE, ID, MACAddress +from esphome.cpp_generator import MockObj, StaticCastExpression import esphome.final_validate as fv from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.types import ConfigType, TemplateArgsType @@ -48,10 +62,15 @@ CONF_INDICATE = "indicate" CONF_MANUFACTURER_DATA = "manufacturer_data" CONF_MAX_CLIENTS = "max_clients" CONF_ON_WRITE = "on_write" +CONF_PAIRING_ENABLED = "pairing_enabled" CONF_READ = "read" +CONF_READ_ENCRYPT = "read_encrypt" +CONF_READ_ENCRYPT_MITM = "read_encrypt_mitm" CONF_STRING = "string" CONF_STRING_ENCODING = "string_encoding" CONF_WRITE = "write" +CONF_WRITE_ENCRYPT = "write_encrypt" +CONF_WRITE_ENCRYPT_MITM = "write_encrypt_mitm" CONF_WRITE_NO_RESPONSE = "write_no_response" # Internal configuration keys @@ -85,9 +104,20 @@ def uuid_is(uuid: int | str, uuid16: int) -> bool: ) -# Core key to store the global configuration -KEY_NOTIFY_REQUIRED = "notify_required" -KEY_SET_VALUE = "set_value" +@dataclass +class _BLEServerData: + # Characteristic ids used by notify and set_value actions, checked in final validation + notify_required: set[ID] = field(default_factory=set) + set_value: set[ID] = field(default_factory=set) + # Set when a security action is validated, which runs before to_code + security_action_used: bool = False + + +def _get_data() -> _BLEServerData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _BLEServerData() + return CORE.data[DOMAIN] + esp32_ble_server_ns = cg.esphome_ns.namespace("esp32_ble_server") ESPBTUUID_ns = cg.esphome_ns.namespace("esp32_ble").namespace("ESPBTUUID") @@ -126,6 +156,12 @@ PROPERTY_MAP = { CONF_INDICATE: BLECharacteristic_ns.PROPERTY_INDICATE, CONF_WRITE_NO_RESPONSE: BLECharacteristic_ns.PROPERTY_WRITE_NR, } +_ENCRYPT_KEYS = ( + CONF_READ_ENCRYPT, + CONF_WRITE_ENCRYPT, + CONF_READ_ENCRYPT_MITM, + CONF_WRITE_ENCRYPT_MITM, +) class ValueType: @@ -171,6 +207,29 @@ VALUE_TYPES = { } +def validate_char_encrypt(char_config: ConfigType) -> ConfigType: + # Without read, the read level protects only the subscription (the CCCD write) + for key in (CONF_READ_ENCRYPT, CONF_READ_ENCRYPT_MITM): + if char_config[key] and not ( + char_config[CONF_READ] + or char_config[CONF_NOTIFY] + or char_config[CONF_INDICATE] + ): + raise cv.Invalid( + f"{key} requires the {CONF_READ}, {CONF_NOTIFY} or {CONF_INDICATE} property" + ) + for key in (CONF_WRITE_ENCRYPT, CONF_WRITE_ENCRYPT_MITM): + if ( + char_config[key] + and not char_config[CONF_WRITE] + and not char_config[CONF_WRITE_NO_RESPONSE] + ): + raise cv.Invalid( + f"{key} requires the {CONF_WRITE} or {CONF_WRITE_NO_RESPONSE} property" + ) + return char_config + + def validate_char_on_write(char_config): if ( CONF_ON_WRITE in char_config @@ -204,16 +263,12 @@ def validate_descriptor(desc_config): def validate_notify_action(config): - # Store the characteristic ID in the global data for the final validation - data = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_NOTIFY_REQUIRED, set()) - data.add(config[CONF_ID]) + _get_data().notify_required.add(config[CONF_ID]) return config def validate_set_value_action(config): - # Store the characteristic ID in the global data for the final validation - data = CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_SET_VALUE, set()) - data.add(config[CONF_ID]) + _get_data().set_value.add(config[CONF_ID]) return config @@ -332,7 +387,8 @@ def final_validate_config(config) -> None: ) # Check if all characteristics that require notifications have the notify property set - for char_id in CORE.data.get(DOMAIN, {}).get(KEY_NOTIFY_REQUIRED, set()): + data = _get_data() + for char_id in data.notify_required: # Look for the characteristic in the configuration matches = [ char_conf @@ -347,7 +403,7 @@ def final_validate_config(config) -> None: raise cv.Invalid( f"Characteristic {char_config[CONF_UUID]} has notify actions and the {CONF_NOTIFY} property is not set" ) - for char_id in CORE.data.get(DOMAIN, {}).get(KEY_SET_VALUE, set()): + for char_id in data.set_value: # Look for the characteristic in the configuration matches = [ char_conf @@ -450,10 +506,13 @@ CHARACTERISTIC_SCHEMA = cv.Schema( }, extra_schemas=[ validate_char_on_write, + validate_char_encrypt, create_description_cud, create_notify_cccd, ], -).extend({cv.Optional(k, default=False): cv.boolean for k in PROPERTY_MAP}) +).extend( + {cv.Optional(k, default=False): cv.boolean for k in (*PROPERTY_MAP, *_ENCRYPT_KEYS)} +) SERVICE_SCHEMA = cv.Schema( { @@ -477,8 +536,15 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_MANUFACTURER_DATA): cv.Schema([cv.uint8_t]), cv.Optional(CONF_MAX_CLIENTS, default=1): cv.int_range(min=1, max=9), cv.Optional(CONF_SERVICES, default=[]): cv.ensure_list(SERVICE_SCHEMA), + cv.Optional(CONF_PASSKEY): validate_passkey, cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True), + cv.Optional(CONF_ON_PASSKEY_REQUEST): automation.validate_automation({}), + cv.Optional(CONF_ON_PASSKEY_NOTIFICATION): automation.validate_automation({}), + cv.Optional(CONF_ON_NUMERIC_COMPARISON_REQUEST): automation.validate_automation( + {} + ), + cv.Optional(CONF_PAIRING_ENABLED): cv.boolean, }, extra_schemas=[create_device_information_service], ).extend(cv.COMPONENT_SCHEMA) @@ -493,6 +559,68 @@ def parse_properties(char_conf): ) +# Permission per encryption level (none, encrypted, encrypted with an authenticated key), read and write +_READ_PERMS = ( + "ESP_GATT_PERM_READ", + "ESP_GATT_PERM_READ_ENCRYPTED", + "ESP_GATT_PERM_READ_ENC_MITM", +) +_WRITE_PERMS = ( + "ESP_GATT_PERM_WRITE", + "ESP_GATT_PERM_WRITE_ENCRYPTED", + "ESP_GATT_PERM_WRITE_ENC_MITM", +) + + +def _encryption_levels(char_conf: ConfigType) -> tuple[int, int]: + """The read and write encryption level of a characteristic; MITM wins.""" + + def level(encrypt: str, mitm: str) -> int: + return 2 if char_conf[mitm] else int(char_conf[encrypt]) + + return ( + level(CONF_READ_ENCRYPT, CONF_READ_ENCRYPT_MITM), + level(CONF_WRITE_ENCRYPT, CONF_WRITE_ENCRYPT_MITM), + ) + + +def _permissions(read: str | None, write: str | None) -> cg.RawExpression: + return cg.RawExpression(" | ".join(p for p in (read, write) if p) or "0") + + +def characteristic_permissions(char_conf: ConfigType) -> cg.RawExpression: + """Only the configured directions are permitted; the stack checks these, not the properties.""" + read, write = _encryption_levels(char_conf) + writable = char_conf[CONF_WRITE] or char_conf[CONF_WRITE_NO_RESPONSE] + return _permissions( + _READ_PERMS[read] if char_conf[CONF_READ] else None, + _WRITE_PERMS[write] if writable else None, + ) + + +def descriptor_permissions( + desc_conf: ConfigType, char_conf: ConfigType +) -> StaticCastExpression: + """A descriptor takes its characteristic's encryption per direction. + + The description (CUD) is static text clients read while browsing, so it stays open. A + subscription delivers the value, so writing the CCCD needs the read level. + """ + read, write = _encryption_levels(char_conf) + if uuid_is(desc_conf[CONF_UUID], CUD_DESCRIPTOR_UUID): + read = write = 0 + elif uuid_is(desc_conf[CONF_UUID], CCCD_DESCRIPTOR_UUID): + write = read + # Cast so the call does not match the (read, write) bool constructor + return StaticCastExpression( + "esp_gatt_perm_t", + _permissions( + _READ_PERMS[read] if desc_conf[CONF_READ] else None, + _WRITE_PERMS[write] if desc_conf[CONF_WRITE] else None, + ), + ) + + def parse_uuid(uuid): # If the UUID is a int, use from_uint32 if isinstance(uuid, int): @@ -554,14 +682,13 @@ def calculate_num_handles(service_config): return total -async def to_code_descriptor(descriptor_conf, char_var): +async def to_code_descriptor(descriptor_conf, char_conf, char_var): value = await parse_value(descriptor_conf[CONF_VALUE], {}) desc_var = cg.new_Pvariable( descriptor_conf[CONF_ID], parse_uuid(descriptor_conf[CONF_UUID]), descriptor_conf[CONF_MAX_LENGTH], - descriptor_conf[CONF_READ], - descriptor_conf[CONF_WRITE], + descriptor_permissions(descriptor_conf, char_conf), ) cg.add(char_var.add_descriptor(desc_var)) cg.add(desc_var.set_value(value)) @@ -581,6 +708,7 @@ async def to_code_characteristic(service_var, char_conf): service_var.create_characteristic( parse_uuid(char_conf[CONF_UUID]), parse_properties(char_conf), + characteristic_permissions(char_conf), ), ) if CONF_ON_WRITE in char_conf: @@ -612,7 +740,47 @@ async def to_code_characteristic(service_var, char_conf): value = await parse_value(char_conf[CONF_VALUE], {}) cg.add(char_var.set_value(value)) for descriptor_conf in char_conf[CONF_DESCRIPTORS]: - await to_code_descriptor(descriptor_conf, char_var) + await to_code_descriptor(descriptor_conf, char_conf, char_var) + + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_PASSKEY_REQUEST, + "add_on_passkey_request_callback", + [(cg.uint64, "address")], + ), + automation.CallbackAutomation( + CONF_ON_PASSKEY_NOTIFICATION, + "add_on_passkey_notification_callback", + [(cg.uint64, "address"), (cg.uint32, "passkey")], + ), + automation.CallbackAutomation( + CONF_ON_NUMERIC_COMPARISON_REQUEST, + "add_on_numeric_comparison_request_callback", + [(cg.uint64, "address"), (cg.uint32, "passkey")], + ), +) + + +def _uses_security(config: ConfigType) -> bool: + """Whether the server config needs the GAP security handler.""" + if ( + _get_data().security_action_used + or CONF_PASSKEY in config + or CONF_PAIRING_ENABLED in config + ): + return True + # A pairing mode set on esp32_ble asks for pairing, so a client's request needs an answer + if esp32_ble.CONF_AUTH_REQ_MODE in CORE.config.get(ESP32_BLE_DOMAIN, {}): + return True + if any(entry.conf_key in config for entry in _CALLBACK_AUTOMATIONS): + return True + return any( + char_conf[key] + for service_conf in config[CONF_SERVICES] + for char_conf in service_conf[CONF_CHARACTERISTICS] + for key in _ENCRYPT_KEYS + ) async def to_code(config): @@ -630,6 +798,13 @@ async def to_code(config): cg.add(var.set_parent(parent)) cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS])) + if _uses_security(config): + cg.add_define("USE_ESP32_BLE_SERVER_SECURITY") + esp32_ble.register_gap_event_handler(parent, var) + cg.add_optional(var.set_pairing_enabled, config.get(CONF_PAIRING_ENABLED)) + if (passkey := config.get(CONF_PASSKEY)) is not None: + cg.add_define("USE_ESP32_BLE_SERVER_STATIC_PASSKEY") + cg.add(var.set_passkey(passkey)) # Only advertise for the server itself when the configuration gives clients something to # find. A server that is auto-loaded purely to host a runtime service (improv_ble) stays # silent until that service asks for advertising. @@ -674,6 +849,10 @@ async def to_code(config): [(cg.uint16, "id")], config[CONF_ON_DISCONNECT], ) + for entry in _CALLBACK_AUTOMATIONS: + if config.get(entry.conf_key): + cg.add_define(f"USE_ESP32_BLE_SERVER_{entry.conf_key.upper()}") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) cg.add_define("USE_ESP32_BLE_SERVER") cg.add_define("USE_ESP32_BLE_ADVERTISING") request_bluetooth() @@ -737,3 +916,69 @@ async def ble_server_characteristic_notify(config, action_id, template_arg, args paren = await cg.get_variable(config[CONF_ID]) cg.add_define("USE_ESP32_BLE_SERVER_NOTIFY_ACTION") return cg.new_Pvariable(action_id, template_arg, paren) + + +def _mac_address_const(config: ConfigType, value: MACAddress) -> str: + return str(value.as_hex) + + +ADDRESS_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ID): cv.use_id(BLEServer), + cv.Required(CONF_ADDRESS): cv.templatable(cv.mac_address), + } +) +_ADDRESS_ARG = (CONF_ADDRESS, cg.uint64, _mac_address_const) + + +def _mark_security_action(config: ConfigType) -> ConfigType: + _get_data().security_action_used = True + return config + + +register_server_apply_action( + "ble_server.numeric_comparison_reply", + lambda: CORE.is_esp32, + cv.All( + ADDRESS_ACTION_SCHEMA.extend( + {cv.Required(CONF_ACCEPT): cv.templatable(cv.boolean)} + ), + _mark_security_action, + ), + automation.ApplyCall( + "numeric_comparison_reply({}, {})", (_ADDRESS_ARG, (CONF_ACCEPT, cg.bool_)) + ), +) + +automation.register_apply_action( + "ble_server.passkey_reply", + cv.All( + ADDRESS_ACTION_SCHEMA.extend( + {cv.Required(CONF_PASSKEY): cv.templatable(validate_passkey)} + ), + _mark_security_action, + ), + automation.ApplyCall( + "passkey_reply({}, {})", (_ADDRESS_ARG, (CONF_PASSKEY, cg.uint32)) + ), +) + +automation.register_apply_action( + "ble_server.remove_bond", + ADDRESS_ACTION_SCHEMA, + automation.ApplyCall("remove_bond({})", (_ADDRESS_ARG,)), +) + +_PAIRING_ACTION_SCHEMA = cv.All( + cv.Schema({cv.GenerateID(CONF_ID): cv.use_id(BLEServer)}), _mark_security_action +) +automation.register_apply_action( + "ble_server.enable_pairing", + _PAIRING_ACTION_SCHEMA, + automation.ApplyCall("set_pairing_enabled(true)"), +) +automation.register_apply_action( + "ble_server.disable_pairing", + _PAIRING_ACTION_SCHEMA, + automation.ApplyCall("set_pairing_enabled(false)"), +) diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 1571a6077bd..862df2993e0 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -17,7 +17,8 @@ BLECharacteristic::~BLECharacteristic() { } } -BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) : uuid_(uuid) { +BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties, esp_gatt_perm_t permissions) + : uuid_(uuid), permissions_(permissions) { this->properties_ = (esp_gatt_char_prop_t) 0; this->set_broadcast_property((properties & PROPERTY_BROADCAST) != 0); @@ -181,6 +182,11 @@ void BLECharacteristic::set_write_no_response_property(bool value) { void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { switch (event) { + case ESP_GATTS_DISCONNECT_EVT: { + // A later connection can get the same id; it subscribes again with its own security + this->remove_client_from_notify_list_(param->disconnect.conn_id); + break; + } case ESP_GATTS_ADD_CHAR_EVT: { if (this->uuid_ == ESPBTUUID::from_uuid(param->add_char.char_uuid)) { this->handle_ = param->add_char.attr_handle; diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index 905f5b7a0d0..2ee1bb560b4 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -26,7 +26,7 @@ class BLEService; class BLECharacteristic final { public: - BLECharacteristic(ESPBTUUID uuid, uint32_t properties); + BLECharacteristic(ESPBTUUID uuid, uint32_t properties, esp_gatt_perm_t permissions); ~BLECharacteristic(); void set_value(ByteBuffer buffer); @@ -97,7 +97,7 @@ class BLECharacteristic final { std::unique_ptr, uint16_t)>> on_write_callback_; std::unique_ptr> on_read_callback_; - esp_gatt_perm_t permissions_ = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE; + esp_gatt_perm_t permissions_; enum State : uint8_t { FAILED = 0x00, diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 7810dba9f5c..9f82dfe9854 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -13,17 +13,16 @@ ESPHOME_LOG_TAG(TAG, "esp32_ble_server.descriptor"); static RAMAllocator descriptor_allocator{}; // NOLINT -BLEDescriptor::BLEDescriptor(ESPBTUUID uuid, uint16_t max_len, bool read, bool write) { - this->uuid_ = uuid; +BLEDescriptor::BLEDescriptor(ESPBTUUID uuid, uint16_t max_len, bool read, bool write) + : BLEDescriptor(uuid, max_len, + static_cast((read ? ESP_GATT_PERM_READ : 0) | (write ? ESP_GATT_PERM_WRITE : 0))) { +} + +BLEDescriptor::BLEDescriptor(ESPBTUUID uuid, uint16_t max_len, esp_gatt_perm_t permissions) + : uuid_(uuid), permissions_(permissions) { this->value_.attr_len = 0; this->value_.attr_max_len = max_len; this->value_.attr_value = descriptor_allocator.allocate(max_len); - if (read) { - this->permissions_ |= ESP_GATT_PERM_READ; - } - if (write) { - this->permissions_ |= ESP_GATT_PERM_WRITE; - } } BLEDescriptor::~BLEDescriptor() { free(this->value_.attr_value); } // NOLINT diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 1ffd4034346..dacaba5173a 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -22,6 +22,7 @@ class BLECharacteristic; class BLEDescriptor { public: BLEDescriptor(ESPBTUUID uuid, uint16_t max_len = 100, bool read = true, bool write = true); + BLEDescriptor(ESPBTUUID uuid, uint16_t max_len, esp_gatt_perm_t permissions); virtual ~BLEDescriptor(); void do_create(BLECharacteristic *characteristic); ESPBTUUID get_uuid() const { return this->uuid_; } @@ -51,7 +52,7 @@ class BLEDescriptor { std::unique_ptr, uint16_t)>> on_write_callback_; - esp_gatt_perm_t permissions_{}; + esp_gatt_perm_t permissions_; enum State : uint8_t { FAILED = 0x00, diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 0dec0a74c5d..0c693ef8345 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -1,5 +1,6 @@ #include "ble_server.h" +#include "esphome/components/ble_device_base/ble_device.h" #include "esphome/components/esp32_ble/ble.h" #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -14,6 +15,7 @@ #endif #include #include +#include namespace esphome::esp32_ble_server { @@ -51,6 +53,14 @@ void BLEServer::loop() { break; } case INIT: { +#ifdef USE_ESP32_BLE_SERVER_STATIC_PASSKEY + // A stack setting, so it is set again each time BLE is enabled + esp_err_t passkey_err = + esp_ble_gap_set_security_param(ESP_BLE_SM_SET_STATIC_PASSKEY, &this->passkey_, sizeof(this->passkey_)); + if (passkey_err != ESP_OK) { + ESP_LOGE(TAG, "Setting static passkey failed: %d", passkey_err); + } +#endif esp_err_t err = esp_ble_gatts_app_register(0); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gatts_app_register failed: %d", err); @@ -181,7 +191,7 @@ void BLEServer::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t ga switch (event) { case ESP_GATTS_CONNECT_EVT: { ESP_LOGD(TAG, "BLE Client connected"); - this->add_client_(param->connect.conn_id); + this->add_client_(param->connect); // Resume advertising so additional clients can discover and connect if (this->client_count_ < this->max_clients_) { this->parent_->advertising_refresh(); @@ -218,13 +228,23 @@ int8_t BLEServer::find_client_index_(uint16_t conn_id) const { return -1; } -void BLEServer::add_client_(uint16_t conn_id) { +void BLEServer::add_client_(const esp_ble_gatts_cb_param_t::gatts_connect_evt_param &connect) { // Check if already in list - if (this->find_client_index_(conn_id) >= 0) + if (this->find_client_index_(connect.conn_id) >= 0) return; // Add if there's space if (this->client_count_ < USE_ESP32_BLE_MAX_CONNECTIONS) { - this->clients_[this->client_count_++] = conn_id; +#ifdef USE_ESP32_BLE_SERVER_SECURITY + // Links this device opened as a central (link_role 0) are reported here too; their pairing + // belongs to ble_client, so a zero address keeps them out of the security handler + auto &address = this->client_addresses_[this->client_count_]; + if (connect.link_role == 1) { + memcpy(address, connect.remote_bda, sizeof(esp_bd_addr_t)); + } else { + memset(address, 0, sizeof(esp_bd_addr_t)); + } +#endif + this->clients_[this->client_count_++] = connect.conn_id; } else { // This should never happen since max clients is known at compile time ESP_LOGE(TAG, "Client array full"); @@ -236,9 +256,22 @@ void BLEServer::remove_client_(uint16_t conn_id) { if (index >= 0) { // Replace with last element and decrement count (client order not preserved) this->clients_[index] = this->clients_[--this->client_count_]; +#ifdef USE_ESP32_BLE_SERVER_SECURITY + memcpy(this->client_addresses_[index], this->client_addresses_[this->client_count_], sizeof(esp_bd_addr_t)); +#endif } } +#ifdef USE_ESP32_BLE_SERVER_SECURITY +bool BLEServer::is_client_address_(const esp_bd_addr_t address) const { + for (uint8_t i = 0; i < this->client_count_; i++) { + if (memcmp(this->client_addresses_[i], address, sizeof(esp_bd_addr_t)) == 0) + return true; + } + return false; +} +#endif + void BLEServer::ble_before_disabled_event_handler() { // Advertising is re-requested once the server is running again after BLE is re-enabled this->release_advertising_(); @@ -252,6 +285,81 @@ void BLEServer::ble_before_disabled_event_handler() { this->state_ = INIT; } +#ifdef USE_ESP32_BLE_SERVER_SECURITY +void BLEServer::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: + case ESP_GAP_BLE_AUTH_CMPL_EVT: + case ESP_GAP_BLE_PASSKEY_REQ_EVT: + case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: + case ESP_GAP_BLE_NC_REQ_EVT: + break; + default: + return; + } + // The params of every security event start with the peer address. Security events are sent to every GAP + // handler; ble_client and bluetooth_proxy links are not ours. + const uint8_t *bd_addr = param->ble_security.ble_req.bd_addr; + if (!this->is_client_address_(bd_addr)) + return; + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, this->pairing_enabled_); + break; + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + const auto &auth_cmpl = param->ble_security.auth_cmpl; + char addr_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + format_mac_addr_upper(bd_addr, addr_str); + if (auth_cmpl.success) { + ESP_LOGI(TAG, "[%s] Authentication complete", addr_str); + } else { + ESP_LOGW(TAG, "[%s] Authentication failed, reason 0x%x", addr_str, auth_cmpl.fail_reason); + } + break; + } +#ifdef USE_ESP32_BLE_SERVER_ON_PASSKEY_REQUEST + case ESP_GAP_BLE_PASSKEY_REQ_EVT: + this->passkey_request_callback_.call(ble_addr_to_uint64(bd_addr)); + break; +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_PASSKEY_NOTIFICATION + case ESP_GAP_BLE_PASSKEY_NOTIF_EVT: + this->passkey_notification_callback_.call(ble_addr_to_uint64(bd_addr), param->ble_security.key_notif.passkey); + break; +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_NUMERIC_COMPARISON_REQUEST + case ESP_GAP_BLE_NC_REQ_EVT: + this->numeric_comparison_request_callback_.call(ble_addr_to_uint64(bd_addr), + param->ble_security.key_notif.passkey); + break; +#endif + default: + break; + } +} + +void BLEServer::passkey_reply(uint64_t address, uint32_t passkey) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + esp_ble_passkey_reply(bda, true, passkey); +} + +void BLEServer::numeric_comparison_reply(uint64_t address, bool accept) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + esp_ble_confirm_reply(bda, accept); +} +#endif // USE_ESP32_BLE_SERVER_SECURITY + +void BLEServer::remove_bond(uint64_t address) { + esp_bd_addr_t bda; + ble_device_base::uint64_to_mac_msb_first(address, bda); + esp_err_t err = esp_ble_remove_bond_device(bda); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Removing bond failed: %d", err); + } +} + float BLEServer::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH + 10; } void BLEServer::dump_config() { diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index e469b60e088..fa8205d7cb8 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -63,6 +63,20 @@ class BLEServer final : public Component, public Parented { void ble_before_disabled_event_handler(); +#ifdef USE_ESP32_BLE_SERVER_SECURITY + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param); + + void set_pairing_enabled(bool pairing_enabled) { this->pairing_enabled_ = pairing_enabled; } +#ifdef USE_ESP32_BLE_SERVER_STATIC_PASSKEY + void set_passkey(uint32_t passkey) { this->passkey_ = passkey; } +#endif + /// Answer a passkey request from the connected client at `address`. + void passkey_reply(uint64_t address, uint32_t passkey); + /// Accept or reject a numeric comparison request from the connected client at `address`. + void numeric_comparison_reply(uint64_t address, bool accept); +#endif + void remove_bond(uint64_t address); + // Direct callback registration - supports multiple callbacks void on_connect(std::function &&callback) { this->callbacks_.push_back({CallbackType::ON_CONNECT, std::move(callback)}); @@ -70,6 +84,21 @@ class BLEServer final : public Component, public Parented { void on_disconnect(std::function &&callback) { this->callbacks_.push_back({CallbackType::ON_DISCONNECT, std::move(callback)}); } +#ifdef USE_ESP32_BLE_SERVER_ON_PASSKEY_REQUEST + template void add_on_passkey_request_callback(F &&callback) { + this->passkey_request_callback_.add(std::forward(callback)); + } +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_PASSKEY_NOTIFICATION + template void add_on_passkey_notification_callback(F &&callback) { + this->passkey_notification_callback_.add(std::forward(callback)); + } +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_NUMERIC_COMPARISON_REQUEST + template void add_on_numeric_comparison_request_callback(F &&callback) { + this->numeric_comparison_request_callback_.add(std::forward(callback)); + } +#endif protected: enum class CallbackType : uint8_t { @@ -93,19 +122,41 @@ class BLEServer final : public Component, public Parented { void release_advertising_(); int8_t find_client_index_(uint16_t conn_id) const; - void add_client_(uint16_t conn_id); + void add_client_(const esp_ble_gatts_cb_param_t::gatts_connect_evt_param &connect); +#ifdef USE_ESP32_BLE_SERVER_SECURITY + bool is_client_address_(const esp_bd_addr_t address) const; +#endif void remove_client_(uint16_t conn_id); void dispatch_callbacks_(CallbackType type, uint16_t conn_id); std::vector callbacks_; +#ifdef USE_ESP32_BLE_SERVER_ON_PASSKEY_REQUEST + CallbackManager passkey_request_callback_; +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_PASSKEY_NOTIFICATION + CallbackManager passkey_notification_callback_; +#endif +#ifdef USE_ESP32_BLE_SERVER_ON_NUMERIC_COMPARISON_REQUEST + CallbackManager numeric_comparison_request_callback_; +#endif +#ifdef USE_ESP32_BLE_SERVER_STATIC_PASSKEY + uint32_t passkey_{0}; +#endif std::vector manufacturer_data_{}; esp_gatt_if_t gatts_if_{0}; bool registered_{false}; +#ifdef USE_ESP32_BLE_SERVER_SECURITY + bool pairing_enabled_{true}; +#endif bool advertising_required_{true}; bool advertising_requested_{false}; uint16_t clients_[USE_ESP32_BLE_MAX_CONNECTIONS]{}; +#ifdef USE_ESP32_BLE_SERVER_SECURITY + // Peer address of each entry in clients_, kept in the same order + esp_bd_addr_t client_addresses_[USE_ESP32_BLE_MAX_CONNECTIONS]{}; +#endif uint8_t client_count_{0}; uint8_t max_clients_{1}; std::vector services_{}; diff --git a/esphome/components/esp32_ble_server/ble_service.cpp b/esphome/components/esp32_ble_server/ble_service.cpp index c8f4ecbf332..fbdea2aa7d8 100644 --- a/esphome/components/esp32_ble_server/ble_service.cpp +++ b/esphome/components/esp32_ble_server/ble_service.cpp @@ -27,15 +27,18 @@ BLECharacteristic *BLEService::get_characteristic(ESPBTUUID uuid) { BLECharacteristic *BLEService::get_characteristic(uint16_t uuid) { return this->get_characteristic(ESPBTUUID::from_uint16(uuid)); } -BLECharacteristic *BLEService::create_characteristic(uint16_t uuid, esp_gatt_char_prop_t properties) { - return create_characteristic(ESPBTUUID::from_uint16(uuid), properties); +BLECharacteristic *BLEService::create_characteristic(uint16_t uuid, esp_gatt_char_prop_t properties, + esp_gatt_perm_t permissions) { + return create_characteristic(ESPBTUUID::from_uint16(uuid), properties, permissions); } -BLECharacteristic *BLEService::create_characteristic(const std::string &uuid, esp_gatt_char_prop_t properties) { - return create_characteristic(ESPBTUUID::from_raw(uuid), properties); +BLECharacteristic *BLEService::create_characteristic(const std::string &uuid, esp_gatt_char_prop_t properties, + esp_gatt_perm_t permissions) { + return create_characteristic(ESPBTUUID::from_raw(uuid), properties, permissions); } -BLECharacteristic *BLEService::create_characteristic(ESPBTUUID uuid, esp_gatt_char_prop_t properties) { +BLECharacteristic *BLEService::create_characteristic(ESPBTUUID uuid, esp_gatt_char_prop_t properties, + esp_gatt_perm_t permissions) { // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - BLECharacteristic *characteristic = new BLECharacteristic(uuid, properties); + BLECharacteristic *characteristic = new BLECharacteristic(uuid, properties, permissions); this->characteristics_.push_back(characteristic); return characteristic; } diff --git a/esphome/components/esp32_ble_server/ble_service.h b/esphome/components/esp32_ble_server/ble_service.h index 51a6ce72cde..1564a0dffbf 100644 --- a/esphome/components/esp32_ble_server/ble_service.h +++ b/esphome/components/esp32_ble_server/ble_service.h @@ -26,9 +26,12 @@ class BLEService final { BLECharacteristic *get_characteristic(ESPBTUUID uuid); BLECharacteristic *get_characteristic(uint16_t uuid); - BLECharacteristic *create_characteristic(const std::string &uuid, esp_gatt_char_prop_t properties); - BLECharacteristic *create_characteristic(uint16_t uuid, esp_gatt_char_prop_t properties); - BLECharacteristic *create_characteristic(ESPBTUUID uuid, esp_gatt_char_prop_t properties); + BLECharacteristic *create_characteristic(const std::string &uuid, esp_gatt_char_prop_t properties, + esp_gatt_perm_t permissions = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE); + BLECharacteristic *create_characteristic(uint16_t uuid, esp_gatt_char_prop_t properties, + esp_gatt_perm_t permissions = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE); + BLECharacteristic *create_characteristic(ESPBTUUID uuid, esp_gatt_char_prop_t properties, + esp_gatt_perm_t permissions = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE); ESPBTUUID get_uuid() { return this->uuid_; } uint8_t get_inst_id() { return this->inst_id_; } diff --git a/esphome/components/zephyr_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 6be56574c93..35820b89672 100644 --- a/esphome/components/zephyr_ble_server/__init__.py +++ b/esphome/components/zephyr_ble_server/__init__.py @@ -2,6 +2,9 @@ from dataclasses import dataclass from esphome import automation import esphome.codegen as cg +from esphome.components.ble_device_base.server_automation import ( + register_server_apply_action, +) from esphome.components.const import CONF_ACCEPT, CONF_ON_NUMERIC_COMPARISON_REQUEST from esphome.components.nrf52.framework import ( bluetooth_west_projects, @@ -108,8 +111,9 @@ BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA = cv.Schema( ) -automation.register_apply_action( +register_server_apply_action( "ble_server.numeric_comparison_reply", + lambda: CORE.using_zephyr, BLE_NUMERIC_COMPARISON_REPLY_ACTION_SCHEMA, automation.ApplyField(CONF_ACCEPT, "numeric_comparison_reply", cg.bool_), ) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 01ffcdab11e..db0656f7f0e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -517,6 +517,11 @@ #define USE_ESP32_BLE_SERVER_DESCRIPTOR_ON_WRITE #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT +#define USE_ESP32_BLE_SERVER_ON_PASSKEY_REQUEST +#define USE_ESP32_BLE_SERVER_ON_PASSKEY_NOTIFICATION +#define USE_ESP32_BLE_SERVER_ON_NUMERIC_COMPARISON_REQUEST +#define USE_ESP32_BLE_SERVER_SECURITY +#define USE_ESP32_BLE_SERVER_STATIC_PASSKEY #define USE_ESP32_BLE_TRACKER #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 2, 0) #define USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD diff --git a/tests/component_tests/esp32_ble_server/config/auth_req_mode_only.yaml b/tests/component_tests/esp32_ble_server/config/auth_req_mode_only.yaml new file mode 100644 index 00000000000..8cdffdc928e --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/auth_req_mode_only.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble: + auth_req_mode: sc_mitm_bond + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] diff --git a/tests/component_tests/esp32_ble_server/config/pairing.yaml b/tests/component_tests/esp32_ble_server/config/pairing.yaml new file mode 100644 index 00000000000..f5d4da031ce --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/pairing.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + on_numeric_comparison_request: + - ble_server.numeric_comparison_reply: + address: !lambda return address; + accept: true + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] diff --git a/tests/component_tests/esp32_ble_server/config/pairing_action_only.yaml b/tests/component_tests/esp32_ble_server/config/pairing_action_only.yaml new file mode 100644 index 00000000000..a94f42c496e --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/pairing_action_only.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] + +interval: + - interval: 60s + then: + - ble_server.enable_pairing: diff --git a/tests/component_tests/esp32_ble_server/config/reply_actions_only.yaml b/tests/component_tests/esp32_ble_server/config/reply_actions_only.yaml new file mode 100644 index 00000000000..0b8960fc346 --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/reply_actions_only.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] + +interval: + - interval: 60s + then: + - ble_server.passkey_reply: + address: "11:22:33:44:55:66" + passkey: 123456 + - ble_server.numeric_comparison_reply: + address: "11:22:33:44:55:66" + accept: true diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index e3006d2c692..88dc6f5591f 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -7,18 +7,26 @@ from pathlib import Path import pytest +from esphome.automation import ACTION_REGISTRY from esphome.components.esp32_ble_server import ( CCCD_DESCRIPTOR_UUID, + CHARACTERISTIC_SCHEMA, CONF_ENDIANNESS, CONF_STRING_ENCODING, CUD_DESCRIPTOR_UUID, DEVICE_INFORMATION_SERVICE_UUID, + characteristic_permissions, + descriptor_permissions, float32, uuid_is, value_bytes, ) + +# Registers its own ble_server.numeric_comparison_reply, which must not replace this one +import esphome.components.zephyr_ble_server # noqa: F401 import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_TYPE +from esphome.const import CONF_DATA, CONF_TYPE, CONF_UUID +from esphome.schema_extractors import SCHEMA_EXTRACT @pytest.mark.parametrize( @@ -145,3 +153,147 @@ def test_float32_rejects_out_of_range() -> None: float32(value) assert float32(3.4028235e38) == 3.4028235e38 assert float32(float("inf")) == float("inf") + + +_SERVER_GAP_HANDLER = ( + "{ auto *h = ble_server; esp32_ble_esp32ble_id->add_gap_event_callback(" +) + + +@pytest.mark.parametrize( + ("config_file", "security"), + [ + ("improv_only.yaml", False), + ("own_service.yaml", False), + ("pairing.yaml", True), + # A pairing mode on esp32_ble alone needs the handler to answer the client's request + ("auth_req_mode_only.yaml", True), + # The action alone needs the handler, since it gates the answer to a pairing request + ("pairing_action_only.yaml", True), + # The reply actions are only compiled in with the handler + ("reply_actions_only.yaml", True), + ], +) +def test_security_handler_only_when_used( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, + security: bool, +) -> None: + main_cpp = generate_main(component_config_path(config_file)) + + # Registered once even when both the config and an action ask for it + assert main_cpp.count(_SERVER_GAP_HANDLER) == int(security) + + +def test_numeric_comparison_reply_uses_esp32_builder( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("pairing.yaml")) + + # The esp32 schema and builder are used, not zephyr_ble_server's + assert "numeric_comparison_reply(static_cast(address), true);" in main_cpp + + +def _permissions(char: dict[str, object]) -> tuple[str, dict[int, str]]: + char_conf = CHARACTERISTIC_SCHEMA( + { + "uuid": "cad48e28-7fbe-41cf-bae9-d77a6c233423", + "description": "Text", + "descriptors": [{"uuid": 0x2904, "value": [1, 2]}], + **char, + } + ) + return str(characteristic_permissions(char_conf)), { + desc[CONF_UUID]: str(descriptor_permissions(desc, char_conf)) + for desc in char_conf["descriptors"] + } + + +def test_encrypted_permissions() -> None: + char_perms, desc_perms = _permissions( + { + "read": True, + "write": True, + "notify": True, + "read_encrypt": True, + "read_encrypt_mitm": True, + "write_encrypt": True, + } + ) + assert char_perms == "ESP_GATT_PERM_READ_ENC_MITM | ESP_GATT_PERM_WRITE_ENCRYPTED" + # User descriptors take the level of each direction, the CCCD the read level for both + assert desc_perms == { + 0x2904: "static_cast(ESP_GATT_PERM_READ_ENC_MITM | ESP_GATT_PERM_WRITE_ENCRYPTED)", + CUD_DESCRIPTOR_UUID: "static_cast(ESP_GATT_PERM_READ)", + CCCD_DESCRIPTOR_UUID: "static_cast(ESP_GATT_PERM_READ_ENC_MITM | ESP_GATT_PERM_WRITE_ENC_MITM)", + } + + +def test_open_permissions() -> None: + char_perms, desc_perms = _permissions({"read": True, "indicate": True}) + assert char_perms == "ESP_GATT_PERM_READ" + assert desc_perms == { + 0x2904: "static_cast(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE)", + CUD_DESCRIPTOR_UUID: "static_cast(ESP_GATT_PERM_READ)", + CCCD_DESCRIPTOR_UUID: "static_cast(ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE)", + } + + +@pytest.mark.parametrize( + ("properties", "expected"), + [ + ({"write": True, "write_encrypt": True}, "ESP_GATT_PERM_WRITE_ENCRYPTED"), + ({"write_no_response": True}, "ESP_GATT_PERM_WRITE"), + ({"read": True, "read_encrypt": True}, "ESP_GATT_PERM_READ_ENCRYPTED"), + ({"notify": True}, "0"), + ], +) +def test_permissions_only_for_configured_directions( + properties: dict[str, bool], expected: str +) -> None: + char_perms, _ = _permissions(properties) + assert char_perms == expected + + +@pytest.mark.parametrize("access", ["notify", "indicate"]) +def test_encrypted_subscription_without_read(access: str) -> None: + char_perms, desc_perms = _permissions({access: True, "read_encrypt_mitm": True}) + assert char_perms == "0" + assert desc_perms[CCCD_DESCRIPTOR_UUID] == ( + "static_cast(ESP_GATT_PERM_READ_ENC_MITM | ESP_GATT_PERM_WRITE_ENC_MITM)" + ) + + +@pytest.mark.parametrize( + ("properties", "message"), + [ + ({"read_encrypt": True}, "read_encrypt requires the read, notify or indicate"), + ({"write_encrypt": True}, "write_encrypt requires the write"), + ( + {"read_encrypt_mitm": True}, + "read_encrypt_mitm requires the read, notify or indicate", + ), + ({"write_encrypt_mitm": True}, "write_encrypt_mitm requires the write"), + ], +) +def test_encrypt_requires_access_property( + properties: dict[str, bool], message: str +) -> None: + with pytest.raises(cv.Invalid, match=message): + CHARACTERISTIC_SCHEMA( + {"uuid": "cad48e28-7fbe-41cf-bae9-d77a6c233423", **properties} + ) + + +def test_numeric_comparison_reply_language_schema_has_both_platforms_keys() -> None: + schema = ACTION_REGISTRY["ble_server.numeric_comparison_reply"].raw_schema( + SCHEMA_EXTRACT + ) + markers = {str(key): key for key in schema.schema} + # accept and id are shared; address is esp32 only, so it shows as optional + assert set(markers) == {"id", "accept", "address"} + assert isinstance(markers["accept"], cv.Required) + assert isinstance(markers["address"], cv.Optional) + assert isinstance(markers["id"], cv.GenerateID) diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index 01994e2d7a7..5d298067fd6 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -4,12 +4,40 @@ esp32_ble_server: manufacturer: ESPHome appearance: 0x1 model: Test + passkey: 123456 + pairing_enabled: false on_connect: - lambda: |- ESP_LOGD("BLE", "Connection from %d", id); + - ble_server.disable_pairing: + id: ble_server on_disconnect: - lambda: |- ESP_LOGD("BLE", "Disconnection from %d", id); + - ble_server.remove_bond: + id: ble_server + address: "11:22:33:44:55:66" + - ble_server.enable_pairing: + id: ble_server + on_numeric_comparison_request: + - logger.log: + format: "Compare this passkey with the one on your BLE device: %06u" + args: ["(unsigned) passkey"] + - ble_server.numeric_comparison_reply: + id: ble_server + address: !lambda return address; + accept: true + on_passkey_notification: + - logger.log: + format: "Passkey: %06u" + args: ["(unsigned) passkey"] + on_passkey_request: + - logger.log: "Got passkey request" + - ble_server.passkey_reply: + id: ble_server + address: !lambda return address; + passkey: 123456 + services: - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d advertise: false @@ -18,6 +46,7 @@ esp32_ble_server: description: "Notify characteristic" uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 read: true + read_encrypt: true notify: true value: [1, 2, 3, 4] descriptors: @@ -37,6 +66,13 @@ esp32_ble_server: uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc12c read: true value: !lambda return { 1, 2 }; + - uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc13d + read: true + read_encrypt_mitm: true + write: true + write_encrypt_mitm: true + notify: true + value: "authenticated" - id: test_change_characteristic uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc11c read: true @@ -57,6 +93,7 @@ esp32_ble_server: ESP_LOGD("BLE", "Descriptor received: %s from %d", std::string(x.begin(), x.end()).c_str(), id); - uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc99a write: true + write_encrypt: true on_write: then: - lambda: |- From 107a573ffb51c3bd81089d3d9789048a87275ce5 Mon Sep 17 00:00:00 2001 From: ishai-levy Date: Sun, 1 Nov 2026 05:07:23 +0200 Subject: [PATCH 038/170] [es8388] Add configurable ADC PGA gain (#21363) --- esphome/components/es8388/audio_dac.py | 27 ++++++++++++++++++++++++-- esphome/components/es8388/es8388.cpp | 5 +++-- esphome/components/es8388/es8388.h | 17 ++++++++++++++++ tests/components/es8388/common.yaml | 1 + 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/esphome/components/es8388/audio_dac.py b/esphome/components/es8388/audio_dac.py index ac1cc9f8177..e4d73263407 100644 --- a/esphome/components/es8388/audio_dac.py +++ b/esphome/components/es8388/audio_dac.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac import esphome.config_validation as cv +from esphome.const import CONF_MIC_GAIN from esphome.types import ConfigType CODEOWNERS = ["@P4uLT"] @@ -11,14 +12,36 @@ es8388_ns = cg.esphome_ns.namespace("es8388") ES8388 = es8388_ns.class_("ES8388", AudioDac, cg.Component, i2c.I2CDevice) +es8388_mic_gain = es8388_ns.enum("ES8388MicGain") +ES8388_MIC_GAIN_ENUM = es8388_mic_gain.options( + "0DB", + "3DB", + "6DB", + "9DB", + "12DB", + "15DB", + "18DB", + "21DB", + "24DB", + prefix="ES8388_MIC_GAIN_", +) + DEPENDENCIES = ["i2c"] CONFIG_SCHEMA = ( - cv.Schema({cv.GenerateID(): cv.declare_id(ES8388)}) + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ES8388), + cv.Optional(CONF_MIC_GAIN, default="0DB"): cv.enum( + ES8388_MIC_GAIN_ENUM, upper=True + ), + } + ) .extend(cv.COMPONENT_SCHEMA) .extend(i2c.i2c_device_schema(0x10)) ) async def to_code(config: ConfigType) -> None: - await i2c.new_i2c_component(config) + var = await i2c.new_i2c_component(config) + cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index b50df969aaf..88a1e661207 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -49,8 +49,6 @@ static constexpr i2c::I2CRegValue ES8388_INIT[] PROGMEM = { {ES8388_DACCONTROL23, 0x00}, // power down adc and line in {ES8388_ADCPOWER, 0xFF}, - //@nightdav - {ES8388_ADCCONTROL1, 0x00}, // +21dB : recommended value for ALC & voice recording // set to Mono Right {ES8388_ADCCONTROL3, 0x02}, // I2S 16 Bits length and I2S serial audio data format @@ -92,6 +90,8 @@ void ES8388::setup() { this->set_mute_state_(true); ES8388_ERROR_FAILED(this->write_registers(ES8388_INIT)); + // ADC PGA gain (reg 0x09): MicAmpL in bits 7:4, MicAmpR in bits 3:0, 3 dB per step + ES8388_ERROR_FAILED(this->write_byte(ES8388_ADCCONTROL1, this->mic_gain_)); delay(1); ES8388_ERROR_FAILED(this->write_byte(ES8388_CHIPPOWER, 0x00)); @@ -142,6 +142,7 @@ void ES8388::setup() { void ES8388::dump_config() { ESP_LOGCONFIG(TAG, "ES8388 Audio Codec:"); LOG_I2C_DEVICE(this); + ESP_LOGCONFIG(TAG, " Mic gain: %u dB", (this->mic_gain_ & 0x0F) * 3); #ifdef USE_ES8388_SELECT LOG_SELECT(" ", "DAC Output", this->dac_output_select_); LOG_SELECT(" ", "ADC Input Mic", this->adc_input_mic_select_); diff --git a/esphome/components/es8388/es8388.h b/esphome/components/es8388/es8388.h index b55538c94f8..5b8e8e0965d 100644 --- a/esphome/components/es8388/es8388.h +++ b/esphome/components/es8388/es8388.h @@ -25,6 +25,19 @@ enum AdcInputMicLine : uint8_t { ADC_INPUT_MIC_DIFFERENCE, }; +/// ADC PGA gain (reg 0x09): the same 3 dB step in both channel nibbles +enum ES8388MicGain : uint8_t { + ES8388_MIC_GAIN_0DB = 0x00, + ES8388_MIC_GAIN_3DB = 0x11, + ES8388_MIC_GAIN_6DB = 0x22, + ES8388_MIC_GAIN_9DB = 0x33, + ES8388_MIC_GAIN_12DB = 0x44, + ES8388_MIC_GAIN_15DB = 0x55, + ES8388_MIC_GAIN_18DB = 0x66, + ES8388_MIC_GAIN_21DB = 0x77, + ES8388_MIC_GAIN_24DB = 0x88, +}; + class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice { #ifdef USE_ES8388_SELECT SUB_SELECT(dac_output) @@ -68,6 +81,8 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I bool set_dac_output(DacOutputLine line); bool set_adc_input_mic(AdcInputMicLine line); + void set_mic_gain(ES8388MicGain gain) { this->mic_gain_ = gain; } + #ifdef USE_ES8388_SELECT /// Line to apply in setup() instead of keeping what the codec reports. void set_dac_output_initial(DacOutputLine line) { this->dac_output_initial_ = line; } @@ -80,6 +95,8 @@ class ES8388 final : public audio_dac::AudioDac, public Component, public i2c::I /// @return True if successful and false otherwise bool set_mute_state_(bool mute_state); + ES8388MicGain mic_gain_{ES8388_MIC_GAIN_0DB}; + #ifdef USE_ES8388_SELECT optional dac_output_initial_{}; optional adc_input_mic_initial_{}; diff --git a/tests/components/es8388/common.yaml b/tests/components/es8388/common.yaml index aa08b9b0470..03c795ebb08 100644 --- a/tests/components/es8388/common.yaml +++ b/tests/components/es8388/common.yaml @@ -11,6 +11,7 @@ audio_dac: - platform: es8388 i2c_id: i2c_bus id: es8388_parent + mic_gain: 21DB select: - platform: es8388 From e5f2060262536d6b188095e9cfbfd9d254b88f18 Mon Sep 17 00:00:00 2001 From: chunckus-aurelius <158745765+chunckus-aurelius@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:08:19 -0400 Subject: [PATCH 039/170] [lis2dw12][msa3xx] Move the tap constants to the shared component constants (#22087) --- esphome/components/const/__init__.py | 3 +++ esphome/components/lis2dw12/__init__.py | 3 +-- esphome/components/lis2dw12/binary_sensor.py | 2 +- esphome/components/msa3xx/__init__.py | 3 +-- esphome/components/msa3xx/binary_sensor.py | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index fd1643d8f63..2f9662d4080 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -59,6 +59,7 @@ CONF_MAX_PACKET_SIZE = "max_packet_size" CONF_MODEM = "modem" CONF_MODULATION = "modulation" CONF_NOX_INDEX = "nox_index" +CONF_ON_DOUBLE_TAP = "on_double_tap" CONF_ON_NUMERIC_COMPARISON_REQUEST = "on_numeric_comparison_request" CONF_ON_PACKET = "on_packet" CONF_ON_PASSKEY_NOTIFICATION = "on_passkey_notification" @@ -67,6 +68,7 @@ CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_ON_STOP = "on_stop" +CONF_ON_TAP = "on_tap" CONF_OUT_PIN = "out_pin" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" @@ -90,6 +92,7 @@ CONF_SLOT = "slot" CONF_SOURCES = "sources" CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" +CONF_TAP = "tap" CONF_TARGET_COUNT = "target_count" CONF_TEMPERATURE_SENSOR = "temperature_sensor" CONF_USE_PSRAM = "use_psram" diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py index 76a69433910..ab0bfb61a96 100644 --- a/esphome/components/lis2dw12/__init__.py +++ b/esphome/components/lis2dw12/__init__.py @@ -1,5 +1,6 @@ from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_ON_DOUBLE_TAP, CONF_ON_TAP import esphome.config_validation as cv from esphome.const import ( CONF_CALIBRATION, @@ -24,10 +25,8 @@ CONF_LIS2DW12_ID = "lis2dw12_id" CONF_MIRROR_Z = "mirror_z" CONF_ON_ACTIVE = "on_active" -CONF_ON_DOUBLE_TAP = "on_double_tap" CONF_ON_FREEFALL = "on_freefall" CONF_ON_ORIENTATION = "on_orientation" -CONF_ON_TAP = "on_tap" CONF_LOW_NOISE = "low_noise" CONF_FILTER_BANDWIDTH = "filter_bandwidth" diff --git a/esphome/components/lis2dw12/binary_sensor.py b/esphome/components/lis2dw12/binary_sensor.py index 3ff0e8f15c2..281eacf1970 100644 --- a/esphome/components/lis2dw12/binary_sensor.py +++ b/esphome/components/lis2dw12/binary_sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_TAP import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE from esphome.types import ConfigType @@ -11,7 +12,6 @@ DEPENDENCIES = ["lis2dw12"] CONF_DOUBLE_TAP = "double_tap" CONF_FREEFALL = "freefall" -CONF_TAP = "tap" ICON_DOUBLE_TAP = "mdi:gesture-double-tap" ICON_FREEFALL = "mdi:parachute" diff --git a/esphome/components/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 4623e1d66ff..136903e454e 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import CONF_ON_DOUBLE_TAP, CONF_ON_TAP import esphome.config_validation as cv from esphome.const import ( CONF_CALIBRATION, @@ -27,10 +28,8 @@ CONF_MSA3XX_ID = "msa3xx_id" CONF_MIRROR_Z = "mirror_z" CONF_ON_ACTIVE = "on_active" -CONF_ON_DOUBLE_TAP = "on_double_tap" CONF_ON_FREEFALL = "on_freefall" CONF_ON_ORIENTATION = "on_orientation" -CONF_ON_TAP = "on_tap" MODEL_MSA301 = "MSA301" MODEL_MSA311 = "MSA311" diff --git a/esphome/components/msa3xx/binary_sensor.py b/esphome/components/msa3xx/binary_sensor.py index ef27c98e662..5802e77a413 100644 --- a/esphome/components/msa3xx/binary_sensor.py +++ b/esphome/components/msa3xx/binary_sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.components.const import CONF_TAP import esphome.config_validation as cv from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE from esphome.types import ConfigType @@ -9,7 +10,6 @@ from . import CONF_MSA3XX_ID, MSA_SENSOR_SCHEMA CODEOWNERS = ["@latonita"] DEPENDENCIES = ["msa3xx"] -CONF_TAP = "tap" CONF_DOUBLE_TAP = "double_tap" ICON_TAP = "mdi:gesture-tap" From bf86c11e4460e5669b9c7a895225a2e63678696b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 Oct 2026 17:48:18 -1000 Subject: [PATCH 040/170] [lis3dh][esp32_hosted] Move CONF_ACTIVE_HIGH to the shared component constants (#22088) --- esphome/components/const/__init__.py | 1 + esphome/components/esp32_hosted/__init__.py | 3 +-- esphome/components/lis3dh/motion.py | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 2f9662d4080..db9122e7ef2 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -9,6 +9,7 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_ACCEPT = "accept" +CONF_ACTIVE_HIGH = "active_high" CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_TIME = "adc_time" CONF_ALLOWED_IPS = "allowed_ips" diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index dd9eb3ee46f..98e77746c1d 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -4,7 +4,7 @@ from pathlib import Path from esphome import pins from esphome.components import esp32 -from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM +from esphome.components.const import CONF_ACTIVE_HIGH, CONF_SLOT, CONF_USE_PSRAM import esphome.config_validation as cv from esphome.const import ( CONF_CLK_PIN, @@ -27,7 +27,6 @@ DOMAIN = "esp32_hosted" # esp32_ble raises the task watchdog around the remote BT controller bring-up AUTO_LOAD = ["watchdog"] -CONF_ACTIVE_HIGH = "active_high" CONF_BUS_WIDTH = "bus_width" CONF_CMD_PIN = "cmd_pin" CONF_D0_PIN = "d0_pin" diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py index fa3bdf7f2b1..07c6babfc19 100644 --- a/esphome/components/lis3dh/motion.py +++ b/esphome/components/lis3dh/motion.py @@ -3,7 +3,11 @@ import math import esphome.codegen as cg from esphome.components import i2c -from esphome.components.const import CONF_ACCELEROMETER_ODR, CONF_ACCELEROMETER_RANGE +from esphome.components.const import ( + CONF_ACCELEROMETER_ODR, + CONF_ACCELEROMETER_RANGE, + CONF_ACTIVE_HIGH, +) from esphome.components.motion import AXES, motion_schema, new_motion_component import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD @@ -16,7 +20,6 @@ _LOGGER = logging.getLogger(__name__) CONF_OPERATING_MODE = "operating_mode" CONF_AXES = "axes" CONF_LATCHED = "latched" -CONF_ACTIVE_HIGH = "active_high" CONF_HIGH_PASS_FILTER = "high_pass_filter" # Enum values must match the C++ enums. From 36c666c8a96ce35f7b43e81fa8d2cf403fbbf835 Mon Sep 17 00:00:00 2001 From: Mark Dietzer Date: Sat, 31 Oct 2026 21:24:20 -0700 Subject: [PATCH 041/170] [usb_host] Size the USB FIFO for periodic packets on the ESP32-P4 (#21739) --- esphome/components/usb_host/__init__.py | 79 +++++++++++++++------ tests/component_tests/usb_host/test_fifo.py | 66 ++++++++++++++++- 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 20b8567beaf..7dbe6188f4f 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,5 +1,6 @@ import itertools import logging +from typing import NamedTuple import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER, CONF_MAX_PACKET_SIZE @@ -125,22 +126,54 @@ def _final_validate(config: ConfigType) -> ConfigType: FINAL_VALIDATE_SCHEMA = _final_validate -# S2/S3 full-speed controller: 200 usable 4-byte FIFO lines (EPINFO takes the rest of 256) shared by RX, -# non-periodic TX and periodic TX -FIFO_VARIANTS = (VARIANT_ESP32S2, VARIANT_ESP32S3) -FIFO_LINES = 200 +class FifoGeometry(NamedTuple): + """FIFO lines of the USB host controller usb_host_install() picks on a variant.""" + + # Nominal depth ESP-IDF scales its splits by, picked from the PHY type: 256 on full-speed, 1024 on high-speed + depth: int + # GHWCFG3 depth: the lines left for RX, NPTX and PTX once EPINFO takes 4 per endpoint direction + usable: int + # Largest bulk OUT packet at the controller's speed; ESP-IDF rejects bulk endpoints larger than NPTX + bulk_mps: int + + @property + def nptx(self) -> int: + """NPTX lines of a custom split: one bulk packet.""" + return self.bulk_mps // FIFO_LINE_BYTES + + @property + def ptx_min(self) -> int: + return self.depth // 8 + + @property + def rx_min(self) -> int: + return self.depth // 8 + FIFO_RX_STATUS_LINES + + def bias_split(self, bias: str | None) -> tuple[int, int, int]: + """(RX, NPTX, PTX) lines ESP-IDF picks for a CONFIG_USB_HOST_HW_BUFFER_BIAS_* option.""" + nptx = self.depth // 16 + if bias == "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN": + return self.usable - nptx - self.ptx_min, nptx, self.ptx_min + if bias == "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT": + return self.rx_min, nptx, self.usable - nptx - self.rx_min + nptx = self.depth // 4 + return self.usable - nptx - self.ptx_min, nptx, self.ptx_min + + FIFO_LINE_BYTES = 4 FIFO_RX_STATUS_LINES = 2 -# (RX, NPTX, PTX) lines ESP-IDF picks for each CONFIG_USB_HOST_HW_BUFFER_BIAS_* option -FIFO_BIAS_SPLITS = { - "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN": (152, 16, 32), - "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT": (34, 16, 150), +FIFO_BIAS_OPTIONS = ( + "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN", + "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT", +) +# usb_host_install() takes the high-speed controller on the P4 and the only one elsewhere +FIFO_GEOMETRY = { + # Full-speed: 256 lines, 7 endpoints + VARIANT_ESP32S2: FifoGeometry(depth=256, usable=200, bulk_mps=64), + VARIANT_ESP32S3: FifoGeometry(depth=256, usable=200, bulk_mps=64), + # High-speed: 1024 lines, 16 endpoints + VARIANT_ESP32P4: FifoGeometry(depth=1024, usable=896, bulk_mps=512), } -FIFO_BALANCED_SPLIT = (104, 64, 32) -# Custom split: 64-byte control/bulk, at least 128 bytes each way -FIFO_NPTX_LINES = 16 -FIFO_PTX_MIN_LINES = 32 -FIFO_RX_MIN_LINES = 32 + FIFO_RX_STATUS_LINES # usb_host_config_t::fifo_settings_custom; USB host is not reliable on older releases FIFO_CUSTOM_MIN_IDF = cv.Version(5, 5, 0) KEY_PERIODIC_FIFO = "periodic_fifo" @@ -160,19 +193,21 @@ def _fifo_lines(size: int) -> int: def _bias_option() -> str | None: framework = fv.full_config.get().get("esp32", {}).get(CONF_FRAMEWORK, {}) options = framework.get("sdkconfig_options", {}) - return next((o for o in FIFO_BIAS_SPLITS if options.get(o) in ("y", True)), None) + return next((o for o in FIFO_BIAS_OPTIONS if options.get(o) in ("y", True)), None) def fifo_split() -> tuple[int, int, int] | None: """(RX, NPTX, PTX) lines for the requests, or None when the default split fits them.""" requested = CORE.data.get(DOMAIN, {}).get(KEY_PERIODIC_FIFO) - if requested is None or get_esp32_variant() not in FIFO_VARIANTS: + if requested is None: + return None + if (geometry := FIFO_GEOMETRY.get(get_esp32_variant())) is None: return None in_bytes, out_bytes = requested rx = _fifo_lines(in_bytes) + FIFO_RX_STATUS_LINES ptx = _fifo_lines(out_bytes) bias = _bias_option() - default_rx, _, default_ptx = FIFO_BIAS_SPLITS[bias] if bias else FIFO_BALANCED_SPLIT + default_rx, _, default_ptx = geometry.bias_split(bias) if rx <= default_rx and ptx <= default_ptx: return None if bias: @@ -180,13 +215,13 @@ def fifo_split() -> tuple[int, int, int] | None: f"The requested USB packets don't fit the FIFO split {bias} selects; remove it from " "sdkconfig_options so usb_host can size the FIFO" ) - rx = max(FIFO_RX_MIN_LINES, rx) - ptx = max(FIFO_PTX_MIN_LINES, ptx) - spare = FIFO_LINES - FIFO_NPTX_LINES - rx - ptx + rx = max(geometry.rx_min, rx) + ptx = max(geometry.ptx_min, ptx) + spare = geometry.usable - geometry.nptx - rx - ptx if spare < 0: raise cv.Invalid( - f"The requested USB packets need {(rx + ptx + FIFO_NPTX_LINES) * FIFO_LINE_BYTES} bytes of USB FIFO, " - f"but the {get_esp32_variant()} has {FIFO_LINES * FIFO_LINE_BYTES} usable" + f"The requested USB packets need {(rx + ptx + geometry.nptx) * FIFO_LINE_BYTES} bytes of USB FIFO, " + f"but the {get_esp32_variant()} has {geometry.usable * FIFO_LINE_BYTES} usable" ) # Devices may declare a larger max packet size, so the requested directions share the rest if in_bytes and out_bytes: @@ -196,7 +231,7 @@ def fifo_split() -> tuple[int, int, int] | None: ptx += spare else: rx += spare - return rx, FIFO_NPTX_LINES, ptx + return rx, geometry.nptx, ptx def _reconcile_fifo() -> None: diff --git a/tests/component_tests/usb_host/test_fifo.py b/tests/component_tests/usb_host/test_fifo.py index e4b84cf05cd..1f50cfcc88c 100644 --- a/tests/component_tests/usb_host/test_fifo.py +++ b/tests/component_tests/usb_host/test_fifo.py @@ -4,7 +4,13 @@ import pytest from esphome import config_validation as cv from esphome.components import usb_host -from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +from esphome.components.esp32 import ( + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, +) from esphome.config import Config from esphome.const import ( CONF_FRAMEWORK, @@ -89,11 +95,65 @@ def test_requests_keep_the_largest_packet( assert CORE.data[usb_host.DOMAIN][usb_host.KEY_PERIODIC_FIFO] == [96, 400] -def test_only_full_speed_variants_are_sized( +@pytest.mark.parametrize( + ("variant", "bias", "split"), + [ + # The splits ESP-IDF's _calculate_fifo_from_bias() picks + (VARIANT_ESP32S3, None, (104, 64, 32)), + (VARIANT_ESP32S3, "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN", (152, 16, 32)), + (VARIANT_ESP32S3, "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT", (34, 16, 150)), + (VARIANT_ESP32P4, None, (512, 256, 128)), + (VARIANT_ESP32P4, "CONFIG_USB_HOST_HW_BUFFER_BIAS_IN", (704, 64, 128)), + ( + VARIANT_ESP32P4, + "CONFIG_USB_HOST_HW_BUFFER_BIAS_PERIODIC_OUT", + (130, 64, 702), + ), + ], +) +def test_bias_splits_match_esp_idf( + variant: str, bias: str | None, split: tuple[int, int, int] +) -> None: + assert usb_host.FIFO_GEOMETRY[variant].bias_split(bias) == split + + +def test_high_speed_request_that_fits_the_default_keeps_it( + set_core_config: SetCoreConfigCallable, +) -> None: + # The P4's default split has 512 bytes of periodic TX. + _set_variant(set_core_config, VARIANT_ESP32P4) + usb_host.request_periodic_fifo(in_bytes=1024, out_bytes=512) + assert usb_host.fifo_split() is None + + +def test_high_speed_requests_share_the_spare_lines( + set_core_config: SetCoreConfigCallable, +) -> None: + # 1024-byte interrupt packets each way, as a Stream Deck XL uses: 258 + 256 lines, plus 254 spare. + _set_variant(set_core_config, VARIANT_ESP32P4) + usb_host.request_periodic_fifo(in_bytes=1024, out_bytes=1024) + rx, nptx, ptx = usb_host.fifo_split() + assert (rx, nptx, ptx) == (385, 128, 383) + assert rx + nptx + ptx == 896 + # ESP-IDF rejects bulk OUT endpoints larger than NPTX; high-speed bulk packets are 512 bytes. + assert nptx * 4 >= 512 + + +def test_high_speed_requests_that_do_not_fit_are_rejected( set_core_config: SetCoreConfigCallable, ) -> None: _set_variant(set_core_config, VARIANT_ESP32P4) - usb_host.request_periodic_fifo(out_bytes=196) + usb_host.request_periodic_fifo(in_bytes=2048, out_bytes=2048) + with pytest.raises(cv.Invalid, match="the ESP32P4 has 3584 usable"): + usb_host.fifo_split() + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32H4, VARIANT_ESP32S31]) +def test_variants_without_known_geometry_are_not_sized( + set_core_config: SetCoreConfigCallable, variant: str +) -> None: + _set_variant(set_core_config, variant) + usb_host.request_periodic_fifo(out_bytes=1024) assert usb_host.fifo_split() is None From 7ae4ff99ce73000117408cd44f0b583f25ed2d5d Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Sun, 1 Nov 2026 10:08:43 +0100 Subject: [PATCH 042/170] [runtime_image] Decode progressive and SOF1 JPEGs with JPEGDEC (#21784) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/runtime_image/__init__.py | 7 +- .../components/runtime_image/jpeg_decoder.cpp | 37 +-- .../components/runtime_image/jpeg_decoder.h | 3 +- platformio.ini | 4 +- .../online_image/test_download_end.cpp | 164 +++++++++--- .../runtime_image/test_jpeg_out_of_memory.cpp | 4 + .../runtime_image/test_jpeg_source.cpp | 246 +++++++++++++++--- 7 files changed, 371 insertions(+), 94 deletions(-) diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 70ede29907c..230524c95a3 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -98,11 +98,14 @@ class JPEGFormat(Format): # reads, garbled RGB8888 output of grayscale images, and two ARM NEON faults seen on arm64 hosts # (swapped red and blue in the 4:2:0 RGB8888 path, writes past the row on clipped MCUs). It also # uses the ESP32-S3 SIMD colour conversion in ESP-IDF builds, not only Arduino ones, and is built - # with -O2. + # with -O2. It also decodes progressive images in full, through a buffer for the coefficients of + # the whole image (PSRAM first on ESP32). When that buffer does not fit, the image is drawn at the + # same size from only the DC, so each block is one colour. It also decodes 8-bit extended + # sequential (SOF1) images. cg.add_library( "JPEGDEC", "1.8.4", - "https://github.com/esphome-libs/JPEGDEC#1.8.4-esphome.3", + "https://github.com/esphome-libs/JPEGDEC#1.8.4-esphome.4", ) if CORE.is_host: # JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only diff --git a/esphome/components/runtime_image/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index 0adf6f27d6d..ce99d5b3b42 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -51,6 +51,18 @@ void JpegDecoder::draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels) } } +// Some very big images take too long to decode, so feed the watchdog as the decode goes +// to avoid crashing if the executing task has a watchdog enabled. +static void feed_watchdog() { +#ifdef USE_ESP_IDF + if (esp_task_wdt_status(nullptr) == ESP_OK) { +#endif + App.feed_wdt(); +#ifdef USE_ESP_IDF + } +#endif +} + /** * @brief Callback method that will be called by the JPEGDEC engine when a chunk * of the image is decoded. @@ -65,15 +77,7 @@ static int draw_callback(JPEGDRAW *jpeg) { return 0; } - // Some very big images take too long to decode, so feed the watchdog on each callback - // to avoid crashing if the executing task has a watchdog enabled. -#ifdef USE_ESP_IDF - if (esp_task_wdt_status(nullptr) == ESP_OK) { -#endif - App.feed_wdt(); -#ifdef USE_ESP_IDF - } -#endif + feed_watchdog(); if (decoder->uses_direct_rgb565()) { decoder->draw_rgb565(jpeg->x, jpeg->y, jpeg->iWidth, jpeg->iHeight, reinterpret_cast(jpeg->pPixels)); @@ -122,8 +126,8 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { return 0; } - // If size unknown, try to decode and see if it's valid - // The JPEGDEC library will fail gracefully if data is incomplete + // If size unknown, try to decode what has arrived. JPEGDEC usually fails on an incomplete baseline image, but draws + // the scans that arrived of an incomplete progressive image and reports success, so any data after that cannot open. if (!this->jpeg_->openRAM(buffer, size, draw_callback)) { ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_->getLastError()); @@ -132,7 +136,13 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) { const int err = this->start_(); if (err != 0) return err; - if (!this->jpeg_->decode(0, 0, this->scale_option_())) + // Row by row rather than with decode(): a progressive image decodes all its scans before the first draw callback + if (!this->jpeg_->decodeBegin(0, 0, this->scale_option_())) + return this->fail_(); + int result; + while ((result = this->jpeg_->decodeRows(1)) == JPEG_DECODE_MORE) + feed_watchdog(); + if (result != 1) return this->fail_(); this->decoded_bytes_ = size; this->jpeg_->close(); @@ -144,9 +154,6 @@ int JpegDecoder::start_() { if (jpeg_type == JPEG_MODE_INVALID) { ESP_LOGE(TAG, "Unsupported JPEG image"); return DECODE_ERROR_INVALID_TYPE; - } else if (jpeg_type == JPEG_MODE_PROGRESSIVE) { - ESP_LOGE(TAG, "Progressive JPEG images not supported"); - return DECODE_ERROR_UNSUPPORTED_FORMAT; } ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_->getWidth(), this->jpeg_->getHeight(), this->jpeg_->getBpp()); diff --git a/esphome/components/runtime_image/jpeg_decoder.h b/esphome/components/runtime_image/jpeg_decoder.h index be59fc6ef42..cec9b4f2a20 100644 --- a/esphome/components/runtime_image/jpeg_decoder.h +++ b/esphome/components/runtime_image/jpeg_decoder.h @@ -27,7 +27,8 @@ class JpegDecoder : public ImageDecoder { /// The source is read as the decode needs it, through JPEGDEC's own 2 KB buffer bool supports_source() const override { return true; } int begin_source(DecodeSource *source) override; - /// Decodes one row of MCUs (8 or 16 pixel rows), or waits part way through one for more of the source + /// Decodes one row of MCUs (8 or 16 pixel rows), or waits part way through one for more of the source. + /// A progressive image is decoded one row of one scan per step, and drawn in the steps after its last scan. int HOT decode_step() override; /// JPEGDEC decodes the image at 1/get_scale() of its size: 1, 2, 4 or 8 diff --git a/platformio.ini b/platformio.ini index f7e5b3c3824..be6a7c499aa 100644 --- a/platformio.ini +++ b/platformio.ini @@ -48,7 +48,7 @@ lib_deps = improv/Improv@1.2.7 ; improv_serial / improv_ble kikuchan98/pngle@1.1.0 ; runtime_image ; Using the repository directly, otherwise ESP-IDF can't use the library - https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.3 ; runtime_image + https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.4 ; runtime_image ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -253,7 +253,7 @@ lib_deps = heman/AsyncMqttClient-esphome@2.0.0 ; mqtt improv/Improv@1.2.7 ; improv_serial kikuchan98/pngle@1.1.0 ; runtime_image - https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.3 ; runtime_image + https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.4 ; runtime_image build_flags = ${common:arduino.build_flags} -DUSE_LIBRETINY diff --git a/tests/components/online_image/test_download_end.cpp b/tests/components/online_image/test_download_end.cpp index df5b6119e91..92dd5696287 100644 --- a/tests/components/online_image/test_download_end.cpp +++ b/tests/components/online_image/test_download_end.cpp @@ -284,36 +284,125 @@ class SlowDraws : public runtime_image::PngDecoder, public runtime_image::Runtim } }; -// 8x8 progressive JPEG, 521 bytes -static const uint8_t JPEG_PROGRESSIVE[] = { +// JPEG_48X40 made progressive with jpegtran: the same coefficients in ten scans, so it decodes to the same pixels. +// At 2196 bytes it is also larger than JPEGDEC's read buffer. +static const uint8_t JPEG_48X40_PROGRESSIVE[] = { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, - 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, - 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, - 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, - 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, - 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, - 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x15, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xFF, 0xC4, 0x00, 0x15, 0x01, 0x01, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x05, 0xFF, 0xDA, 0x00, 0x0C, 0x03, - 0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0x94, 0x05, 0x3F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, - 0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, - 0x01, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0x7F, 0xFF, 0xC4, 0x00, - 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, - 0x01, 0x00, 0x01, 0x3F, 0x21, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, - 0x10, 0x0B, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, - 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, - 0x01, 0x00, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xD9, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x04, 0x03, 0x02, 0x02, 0x02, 0x02, 0x05, 0x04, 0x04, 0x03, 0x04, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05, + 0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x06, 0x07, 0x09, 0x07, 0x06, 0x06, 0x08, 0x0B, 0x08, 0x09, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x06, 0x08, 0x0B, 0x0C, 0x0B, 0x0A, 0x0C, 0x09, 0x0A, 0x0A, 0x0A, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x05, 0x03, 0x03, 0x05, 0x0A, 0x07, 0x06, 0x07, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x28, 0x00, 0x30, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x18, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x05, 0x06, 0x00, 0x08, 0xFF, 0xC4, 0x00, 0x19, 0x01, 0x00, + 0x02, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x07, 0x04, 0x06, + 0x08, 0x03, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0xE5, 0x43, 0xAB, + 0x09, 0x65, 0x8C, 0x52, 0xFC, 0xDA, 0x40, 0xAD, 0x09, 0xE6, 0x14, 0x8F, 0xEB, 0xCC, 0xF8, 0x53, 0x29, 0x1A, 0xF1, + 0xC1, 0xA8, 0x69, 0xCB, 0xA6, 0x84, 0xD0, 0x99, 0x73, 0x50, 0x7D, 0x6D, 0xA3, 0x06, 0xEF, 0x69, 0xF8, 0x7C, 0xCA, + 0x1D, 0xAB, 0x4D, 0x07, 0x8D, 0xB6, 0x18, 0xD3, 0xFF, 0xC4, 0x00, 0x20, 0x10, 0x00, 0x02, 0x03, 0x00, 0x01, 0x04, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x05, 0x01, 0x03, 0x04, 0x06, 0x11, 0x12, 0x13, + 0x15, 0x00, 0x14, 0x16, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x21, 0xAE, 0xF8, 0x52, 0x47, + 0xDB, 0x35, 0x56, 0x41, 0x3E, 0x49, 0x8C, 0x41, 0x9F, 0x3D, 0xB5, 0xDF, 0xA2, 0xDA, 0xC5, 0x32, 0xE9, 0x3C, 0x09, + 0xB0, 0xCE, 0x6A, 0x12, 0x28, 0xF1, 0xD2, 0xA9, 0x68, 0xD6, 0xA9, 0x42, 0x6E, 0x92, 0x89, 0x75, 0x63, 0x5F, 0x1D, + 0x1E, 0xD5, 0x5C, 0x60, 0x0C, 0x29, 0x4B, 0x27, 0x50, 0x71, 0xBA, 0xA3, 0xE6, 0x24, 0x47, 0x7E, 0x7F, 0xCE, 0xD3, + 0x5D, 0xFE, 0xA1, 0x97, 0x6A, 0xD5, 0x0C, 0xFE, 0xBC, 0x2B, 0x64, 0x63, 0x4A, 0x36, 0x66, 0x58, 0xD6, 0xB2, 0x89, + 0xF5, 0xAE, 0xF2, 0x01, 0x40, 0x5C, 0x2B, 0xBA, 0x58, 0x59, 0x4E, 0xF9, 0xA8, 0xC8, 0x88, 0x72, 0x04, 0x18, 0x17, + 0x50, 0x9F, 0xFF, 0xC4, 0x00, 0x2F, 0x11, 0x00, 0x01, 0x01, 0x04, 0x06, 0x07, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x05, 0x00, 0x02, 0x03, 0x11, 0x04, 0x06, 0x41, 0x51, 0x61, 0xF0, 0x12, 0x21, 0x23, + 0x71, 0xA1, 0xB1, 0xC1, 0x14, 0x15, 0x22, 0x31, 0x35, 0x42, 0x52, 0x81, 0xD1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, + 0x01, 0x01, 0x3F, 0x01, 0x48, 0x55, 0xA3, 0xF6, 0xE7, 0x5D, 0x91, 0xCB, 0xA5, 0xAA, 0xFA, 0x94, 0x12, 0xF4, 0x41, + 0x23, 0x2D, 0x56, 0x0C, 0x71, 0xCC, 0x99, 0x2D, 0x62, 0x07, 0x76, 0x43, 0x98, 0x27, 0xCF, 0x9E, 0xFB, 0x19, 0x1D, + 0x65, 0xF1, 0x4F, 0x1B, 0x61, 0xC2, 0xE3, 0x83, 0x21, 0xAD, 0x3C, 0x74, 0xF4, 0x63, 0x0B, 0x2E, 0xC7, 0x56, 0x79, + 0x32, 0x52, 0xD4, 0x43, 0x41, 0x77, 0x6A, 0x3D, 0xDF, 0x1B, 0xCD, 0xAC, 0x9C, 0xF1, 0x87, 0x4E, 0x98, 0xB2, 0x7D, + 0x7F, 0x1A, 0xAF, 0x9F, 0x14, 0x51, 0xBB, 0xAB, 0x26, 0xFA, 0x7B, 0xBF, 0x7C, 0x5B, 0xFF, 0xC4, 0x00, 0x2A, 0x11, + 0x00, 0x01, 0x03, 0x03, 0x03, 0x02, 0x04, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x11, + 0x21, 0x03, 0x04, 0x31, 0x00, 0x05, 0x12, 0x22, 0x71, 0x13, 0x41, 0x51, 0xA1, 0x42, 0x61, 0x81, 0x82, 0xB1, 0xC1, + 0xD1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0xBB, 0xDD, 0x6D, 0xED, 0xB9, 0x32, 0x67, 0xE2, + 0x02, 0x1F, 0xD7, 0xCC, 0x03, 0xF4, 0x23, 0xC8, 0xBB, 0x6A, 0xF3, 0x79, 0xB5, 0xBA, 0x45, 0x30, 0xBA, 0x6C, 0x85, + 0xB9, 0x7F, 0x90, 0x23, 0x30, 0xEE, 0x38, 0xF1, 0xC8, 0x21, 0xE2, 0x4A, 0x52, 0x2E, 0x6B, 0xD1, 0x15, 0x17, 0xC4, + 0x34, 0xBB, 0x06, 0x82, 0x7F, 0x0E, 0x9E, 0xAC, 0xF6, 0x65, 0x49, 0xBB, 0xDF, 0x15, 0x5A, 0x89, 0x5D, 0x45, 0x80, + 0x90, 0xF1, 0xD0, 0x0B, 0x00, 0x3B, 0x44, 0xC7, 0x52, 0x9C, 0x96, 0x97, 0x6D, 0x54, 0xDD, 0xEE, 0x7C, 0x44, 0x29, + 0x2A, 0x05, 0x30, 0x0B, 0x94, 0x32, 0xBC, 0xF0, 0x5C, 0x8E, 0x4A, 0x31, 0xDC, 0x1E, 0x2F, 0xAB, 0x8D, 0xCD, 0x55, + 0x2E, 0x96, 0xB5, 0xD7, 0x72, 0x19, 0xC8, 0x08, 0x98, 0x04, 0x1F, 0xBC, 0xBB, 0xE4, 0x8C, 0x9E, 0x38, 0x17, 0x54, + 0x52, 0x6C, 0xAA, 0x54, 0x54, 0x95, 0x66, 0x00, 0x76, 0x4A, 0x48, 0xC0, 0x18, 0x7F, 0xDE, 0x5C, 0xEB, 0x74, 0x52, + 0xA9, 0x56, 0xA0, 0x5D, 0xF9, 0x05, 0x8F, 0x6F, 0x4C, 0x7F, 0x49, 0x72, 0xF1, 0xA2, 0xBA, 0x95, 0x2F, 0xA4, 0xE0, + 0x7B, 0xA4, 0xB0, 0x3D, 0xE7, 0xD8, 0x01, 0x11, 0xAF, 0xFF, 0xC4, 0x00, 0x30, 0x10, 0x00, 0x01, 0x02, 0x04, 0x03, + 0x06, 0x05, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x11, 0x00, 0x03, 0x12, 0x21, 0x22, + 0x31, 0x41, 0x04, 0x13, 0x32, 0x51, 0x81, 0xF0, 0x61, 0x71, 0x91, 0xA1, 0xD1, 0x14, 0x23, 0xB1, 0x24, 0x62, 0x72, + 0x82, 0xF1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0xA5, 0x2F, 0x4A, 0x6C, 0x04, 0xC3, 0x72, + 0x9C, 0xAC, 0x35, 0x80, 0x2B, 0x75, 0x2A, 0x94, 0x85, 0x2C, 0x8E, 0x6D, 0xEB, 0xA4, 0x5E, 0x5F, 0x0B, 0x13, 0x5A, + 0xFD, 0xB2, 0xFC, 0xC2, 0x69, 0x61, 0xA2, 0x81, 0x25, 0x87, 0x80, 0xB7, 0x4E, 0xB0, 0xBA, 0xC3, 0x58, 0x16, 0xAE, + 0xC5, 0xF3, 0xBC, 0x24, 0x94, 0x39, 0xFD, 0xFA, 0x5B, 0xBE, 0xC5, 0x97, 0x85, 0x8B, 0xD8, 0xA9, 0x76, 0x7F, 0x9E, + 0xF4, 0x89, 0x83, 0x65, 0x40, 0x29, 0x09, 0xC2, 0x43, 0xBE, 0xBF, 0x1E, 0xE6, 0x02, 0x68, 0x1C, 0x4C, 0x29, 0x26, + 0x9D, 0x47, 0x5E, 0x91, 0xBB, 0x4E, 0xC0, 0x80, 0x9E, 0x20, 0xA0, 0x54, 0x35, 0xFC, 0x67, 0x0B, 0xC0, 0x7E, 0xE3, + 0x00, 0x2A, 0x50, 0x2F, 0xDB, 0x43, 0x19, 0x24, 0x90, 0xF9, 0xA9, 0x4C, 0xD6, 0x3C, 0xED, 0x14, 0x6F, 0x52, 0x84, + 0xA8, 0x85, 0x24, 0x04, 0x95, 0x31, 0xD3, 0x23, 0xDB, 0x44, 0xDA, 0x76, 0x99, 0x7C, 0x75, 0x6E, 0xB7, 0x6E, 0x3A, + 0x76, 0xC5, 0xA0, 0x7E, 0xBD, 0x3B, 0xB9, 0x8A, 0x2A, 0x6A, 0x41, 0x27, 0xDF, 0xB6, 0x84, 0x05, 0xED, 0x82, 0xA6, + 0xC3, 0x52, 0x0E, 0x1E, 0x62, 0xC7, 0xC3, 0xDA, 0x0A, 0xC6, 0xD2, 0x54, 0x42, 0x78, 0xC4, 0xBD, 0x6E, 0xD6, 0xF2, + 0x81, 0x2B, 0x7A, 0x68, 0xAA, 0xCF, 0x2D, 0x2D, 0xEA, 0xEE, 0xF0, 0x07, 0xD2, 0xEF, 0x70, 0x83, 0x82, 0x68, 0x24, + 0x7F, 0x57, 0xD3, 0x28, 0x54, 0xE4, 0x04, 0xA3, 0x16, 0x10, 0x54, 0x31, 0x66, 0xFE, 0xB0, 0x99, 0xAB, 0x4D, 0x20, + 0x82, 0x41, 0x42, 0x83, 0xF9, 0xE6, 0xE3, 0x28, 0xFB, 0x9B, 0x34, 0xB4, 0x05, 0x0B, 0x50, 0xB0, 0xCF, 0x96, 0x9D, + 0x98, 0x9C, 0x94, 0xEC, 0x2E, 0x95, 0x0C, 0x35, 0xA9, 0x23, 0x43, 0xF1, 0xDB, 0xC6, 0xE9, 0x7B, 0x0D, 0x29, 0x2A, + 0xC3, 0x30, 0x28, 0x61, 0xE8, 0xFC, 0xF9, 0xF2, 0x84, 0xC9, 0x97, 0x31, 0x5A, 0x10, 0x01, 0xCC, 0x5C, 0xB1, 0xF5, + 0x89, 0xC5, 0xAA, 0xD2, 0x9A, 0xB2, 0x3D, 0xFF, 0x00, 0x90, 0xD2, 0xE6, 0x26, 0x5A, 0x2A, 0xBB, 0x2B, 0x3F, 0x2F, + 0x58, 0x2B, 0x21, 0x2E, 0x50, 0x14, 0x65, 0xAC, 0xD8, 0xBB, 0x96, 0x85, 0xAE, 0x5C, 0xC5, 0xAA, 0x84, 0x25, 0x6E, + 0xA3, 0x96, 0x6F, 0xE7, 0x6F, 0x71, 0x08, 0x56, 0xF3, 0xF8, 0x66, 0x71, 0x72, 0xF1, 0x8F, 0xFF, 0xC4, 0x00, 0x22, + 0x10, 0x01, 0x01, 0x00, 0x02, 0x03, 0x00, 0x02, 0x02, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x11, + 0x21, 0x31, 0x00, 0x41, 0x51, 0x61, 0x71, 0x91, 0xB1, 0x81, 0xA1, 0xC1, 0xD1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, + 0x00, 0x01, 0x3F, 0x21, 0x42, 0xE1, 0xF3, 0xCC, 0x0C, 0x0C, 0xE0, 0x2C, 0xFF, 0x00, 0x55, 0x22, 0x69, 0x4C, 0x2A, + 0xF8, 0x21, 0xA7, 0xC8, 0x7E, 0x13, 0x93, 0x4C, 0xE9, 0x1F, 0x37, 0xED, 0x91, 0xF4, 0x61, 0x99, 0x78, 0x98, 0x0A, + 0x74, 0x88, 0xFD, 0x03, 0xF9, 0x9E, 0x39, 0x07, 0x2A, 0xAD, 0x44, 0xC5, 0x34, 0x0C, 0xFF, 0x00, 0x78, 0xED, 0x58, + 0xF9, 0x68, 0x4E, 0x80, 0x3F, 0x66, 0x37, 0x7E, 0xDC, 0x74, 0xD7, 0xD9, 0xB8, 0xA3, 0x47, 0x31, 0x7E, 0xF1, 0xB3, + 0x90, 0x46, 0x6A, 0x3E, 0xB0, 0x53, 0x19, 0x7C, 0xCE, 0xC4, 0xE0, 0x4C, 0xCA, 0xC6, 0x7D, 0x33, 0x04, 0x06, 0x7C, + 0x55, 0x73, 0xDF, 0x2A, 0xA0, 0x87, 0x3B, 0x94, 0xCB, 0xDB, 0x49, 0xF0, 0x0E, 0x38, 0x05, 0xC1, 0x1F, 0x92, 0x61, + 0x1A, 0xEE, 0x41, 0x7D, 0x5E, 0xF9, 0x6E, 0x4E, 0x4C, 0xCB, 0x64, 0x45, 0x33, 0x7B, 0xFC, 0xF0, 0x26, 0x10, 0x0E, + 0xEE, 0xB2, 0xDB, 0x7D, 0xD3, 0x6F, 0x1D, 0x6B, 0x08, 0x12, 0x42, 0xB8, 0x25, 0xF1, 0xFA, 0x13, 0x5C, 0x1A, 0x2D, + 0x99, 0x86, 0x0C, 0xED, 0x76, 0x50, 0xBD, 0xB7, 0xC1, 0xA2, 0xE1, 0x78, 0x59, 0x87, 0xC3, 0x77, 0x28, 0xFA, 0xBC, + 0xB9, 0xD7, 0xD1, 0x65, 0xB3, 0x58, 0xCA, 0x6E, 0xB9, 0xDE, 0x92, 0xBE, 0x0E, 0x80, 0x87, 0xAE, 0xC1, 0xD9, 0xB1, + 0xC6, 0x39, 0x75, 0xE4, 0x64, 0xFE, 0x06, 0x12, 0xB8, 0x60, 0xD1, 0x9B, 0xA7, 0x01, 0x4D, 0x67, 0x8E, 0xCC, 0x84, + 0x68, 0x2C, 0x72, 0x98, 0xCF, 0x30, 0xB4, 0x3A, 0x8C, 0x22, 0x8E, 0xC6, 0x5F, 0xB4, 0x93, 0x68, 0x02, 0xD6, 0x31, + 0x89, 0xE0, 0x92, 0xE8, 0xDA, 0xF1, 0xF0, 0xB9, 0xD4, 0x41, 0x88, 0x67, 0x27, 0x9F, 0xCF, 0x11, 0x01, 0x6B, 0xF7, + 0x91, 0xFF, 0x00, 0x4D, 0x1E, 0x0B, 0x9C, 0xFC, 0xB5, 0xC0, 0xCC, 0x1A, 0x96, 0xBD, 0x5C, 0x32, 0x62, 0x51, 0xCC, + 0x00, 0xD4, 0x83, 0xBF, 0x6B, 0x8C, 0x2E, 0x06, 0x78, 0xB0, 0x05, 0x39, 0x3F, 0xA0, 0x84, 0x76, 0x78, 0x73, 0x1C, + 0xC8, 0xE8, 0x04, 0x03, 0x8C, 0x54, 0xB8, 0x65, 0x0D, 0x45, 0xA8, 0xC9, 0x34, 0x95, 0x4B, 0x1E, 0x15, 0x8C, 0xE2, + 0x37, 0xB0, 0x22, 0x95, 0x6E, 0xDF, 0xC7, 0xA3, 0x0D, 0xDC, 0x4E, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, 0x10, 0xD4, 0xEB, 0x05, 0x80, 0xB8, 0xA3, 0x8F, 0xFF, 0xC4, 0x00, 0x1D, 0x11, + 0x01, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x11, 0x21, + 0x31, 0x41, 0x00, 0x61, 0x71, 0x51, 0x91, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x48, 0xDC, + 0xCD, 0x50, 0x65, 0x84, 0x8E, 0xBB, 0x9A, 0x43, 0xE7, 0x97, 0xCE, 0x2D, 0xE9, 0x47, 0xF5, 0xBC, 0xD0, 0xC9, 0x96, + 0x47, 0xC0, 0xC2, 0x51, 0x50, 0xE2, 0xE2, 0x29, 0xB0, 0x02, 0x98, 0x51, 0x00, 0xF2, 0xAE, 0x5A, 0xC8, 0xB2, 0xE0, + 0xF8, 0x77, 0x6C, 0xA1, 0xAB, 0xE7, 0xE3, 0xB6, 0x4B, 0x78, 0x48, 0x9C, 0x0E, 0x0F, 0xCF, 0xE7, 0x92, 0xA1, 0x1D, + 0x28, 0x7A, 0x3C, 0x20, 0x68, 0xC7, 0x43, 0x8F, 0x89, 0xED, 0x64, 0xFF, 0x00, 0x06, 0x64, 0xBA, 0x78, 0xB0, 0x71, + 0x54, 0xCF, 0x15, 0xBD, 0xF6, 0x65, 0xD0, 0x4C, 0xD5, 0x6A, 0x1F, 0xC7, 0xE2, 0xD5, 0x6C, 0xFD, 0x7F, 0x7B, 0x7D, + 0xFF, 0xC4, 0x00, 0x1A, 0x11, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x11, 0x21, 0x31, 0x00, 0x51, 0x61, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, + 0x02, 0xE1, 0x0A, 0x80, 0x31, 0x4C, 0xAB, 0x22, 0xA0, 0xE2, 0x83, 0x51, 0x93, 0x49, 0x80, 0xA4, 0x5A, 0x41, 0xA4, + 0x21, 0x08, 0x67, 0x9B, 0x8E, 0x89, 0x0F, 0x9E, 0x33, 0x03, 0x51, 0xA0, 0x38, 0x2A, 0xFA, 0x02, 0x35, 0x4D, 0x73, + 0x00, 0xA7, 0x6F, 0xC0, 0x82, 0x63, 0x29, 0x50, 0x2D, 0x01, 0x4B, 0x06, 0x84, 0x40, 0x56, 0x07, 0x81, 0x9F, 0x9B, + 0xC4, 0xD3, 0x0F, 0x10, 0x36, 0xC5, 0x55, 0x7C, 0x4B, 0x2A, 0x1E, 0x82, 0xFA, 0xCD, 0x34, 0x89, 0xBF, 0x54, 0x2D, + 0xD1, 0x1A, 0x09, 0x12, 0xE4, 0x46, 0xD5, 0xC8, 0x80, 0x42, 0x08, 0xAA, 0x45, 0x00, 0x26, 0x9D, 0x9A, 0x00, 0x0F, + 0x42, 0x60, 0x57, 0xFF, 0xC4, 0x00, 0x1C, 0x10, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x11, 0x21, 0x00, 0x31, 0x41, 0x61, 0x51, 0x71, 0xFF, 0xDA, 0x00, 0x08, 0x01, + 0x01, 0x00, 0x01, 0x3F, 0x10, 0x0F, 0x56, 0x5B, 0x08, 0x1A, 0xCC, 0xA4, 0xAE, 0x04, 0x79, 0x69, 0xF7, 0x79, 0x4A, + 0xA9, 0xE6, 0x42, 0xD3, 0x2A, 0x94, 0x29, 0x43, 0x38, 0xD3, 0x10, 0x02, 0x45, 0x2A, 0x20, 0x80, 0x50, 0xEC, 0x8C, + 0x32, 0x85, 0xAB, 0x12, 0x89, 0x5E, 0x32, 0xF8, 0xE4, 0x88, 0x13, 0x21, 0x08, 0x64, 0x10, 0x17, 0xFC, 0x0E, 0x1A, + 0xD7, 0x68, 0xE6, 0x96, 0x38, 0x7E, 0xCA, 0x24, 0x4E, 0x59, 0x71, 0x14, 0x88, 0x2B, 0x94, 0x0A, 0xB6, 0x12, 0x9C, + 0x04, 0x88, 0x24, 0x44, 0xAB, 0x63, 0x40, 0xDF, 0xF8, 0x60, 0x81, 0x98, 0x48, 0x6F, 0x41, 0x2D, 0xD0, 0x88, 0x3F, + 0x40, 0xE5, 0xCC, 0xAA, 0xF2, 0x04, 0x00, 0xA8, 0x0F, 0x25, 0x13, 0x0E, 0x3D, 0x5C, 0x1F, 0xD1, 0x68, 0x8E, 0x25, + 0xE1, 0xBE, 0x4B, 0x60, 0x99, 0x7A, 0xC4, 0xDE, 0x94, 0x13, 0x98, 0x07, 0xA8, 0x51, 0x6D, 0x97, 0x7C, 0x45, 0x40, + 0xF8, 0x60, 0x15, 0x29, 0xD5, 0x34, 0x08, 0x00, 0x08, 0x68, 0xED, 0xC2, 0xB6, 0x8D, 0xB6, 0xCA, 0x16, 0xFA, 0x26, + 0x8C, 0x4A, 0x8D, 0x80, 0x01, 0x0F, 0x02, 0x00, 0x42, 0xA9, 0xB5, 0x55, 0xAC, 0x10, 0x15, 0x5A, 0xE2, 0xD9, 0x60, + 0x4A, 0x6A, 0xC7, 0x68, 0x26, 0x4C, 0xAB, 0xD6, 0x8C, 0xAF, 0x6B, 0xC5, 0x22, 0x20, 0xAA, 0xC7, 0xB8, 0xAE, 0x43, + 0xFF, 0x00, 0x6A, 0xF6, 0x2E, 0x50, 0x16, 0x01, 0x1A, 0x14, 0x73, 0x42, 0x9E, 0x36, 0xA0, 0x21, 0x20, 0x4F, 0x49, + 0x60, 0x08, 0x92, 0x70, 0x05, 0xFC, 0x03, 0x46, 0x89, 0x8A, 0x17, 0x12, 0xA2, 0xE2, 0x31, 0x0C, 0x2A, 0x93, 0x60, + 0x02, 0xBD, 0x92, 0x4A, 0x8E, 0xD5, 0x14, 0xD6, 0x23, 0x51, 0x4F, 0xB1, 0x45, 0x2C, 0xFB, 0x42, 0x28, 0x21, 0xA6, + 0x54, 0x34, 0x85, 0x06, 0x0B, 0xF4, 0x02, 0xC2, 0x24, 0x26, 0xD5, 0x28, 0x81, 0x45, 0x3C, 0x0C, 0xD7, 0xD0, 0x10, + 0x02, 0xAA, 0x5A, 0xC5, 0x78, 0x27, 0x0C, 0x41, 0xB0, 0x30, 0x13, 0xAE, 0x58, 0x20, 0x21, 0x40, 0x1A, 0x54, 0x3C, + 0x50, 0x53, 0x0E, 0x01, 0x89, 0xC1, 0xCC, 0xF9, 0x6A, 0x59, 0x55, 0x0A, 0x68, 0x0A, 0x60, 0x14, 0xA2, 0xF4, 0xAD, + 0x19, 0xA2, 0x55, 0xAB, 0x45, 0x51, 0x3C, 0x38, 0x7F, 0xFF, 0xD9, }; // 48x40 4:2:0 JPEG (quality 95), 2344 bytes: three rows of 16 pixel MCUs, with the scan starting at byte 609 @@ -779,18 +868,21 @@ TEST_F(OnlineImageDownloadEnd, JpegOpensInAPassOfItsOwn) { EXPECT_EQ(this->finished_, 1); } -// A progressive JPEG is refused while it downloads, without blaming the buffer size -TEST_F(OnlineImageDownloadEnd, JpegProgressiveReportsError) { - auto image = this->make_image_(runtime_image::JPEG, 256); - std::vector body(JPEG_PROGRESSIVE, JPEG_PROGRESSIVE + sizeof(JPEG_PROGRESSIVE)); +// A progressive JPEG is decoded scan by scan as it downloads, and drawn once the last scan has arrived +TEST_F(OnlineImageDownloadEnd, JpegProgressiveDownloadsAndDecodes) { + auto image = this->make_image_(runtime_image::JPEG, 1024); + std::vector body(JPEG_48X40_PROGRESSIVE, JPEG_48X40_PROGRESSIVE + sizeof(JPEG_48X40_PROGRESSIVE)); auto container = std::make_shared(body, 256, false); this->start_(*image, container); - for (int i = 0; i < 10 && this->finished_ + this->errors_ == 0; i++) + for (int i = 0; i < 100 && this->finished_ + this->errors_ == 0; i++) { image->loop(); + container->deliver(256); + } - EXPECT_EQ(this->errors_, 1); - EXPECT_EQ(this->finished_, 0); + ASSERT_EQ(this->finished_, 1); + EXPECT_EQ(this->errors_, 0); + EXPECT_EQ(image_pixels(*image), reference_jpeg_pixels()); } // on_download_started fires once the server has accepted the request, before the first progress report, and not diff --git a/tests/components/runtime_image/test_jpeg_out_of_memory.cpp b/tests/components/runtime_image/test_jpeg_out_of_memory.cpp index 9fe8e026225..d9b7411cd60 100644 --- a/tests/components/runtime_image/test_jpeg_out_of_memory.cpp +++ b/tests/components/runtime_image/test_jpeg_out_of_memory.cpp @@ -21,6 +21,10 @@ class StatelessJpegDecoder : public JpegDecoder { // The ~18 KB of JPEGDEC state lives in its own allocation, so it can come from PSRAM TEST(JpegOutOfMemory, DecoderStateIsAllocatedSeparately) { EXPECT_LT(sizeof(JpegDecoder), sizeof(JPEGDEC) / 4); } +// The coefficient buffer of a progressive image comes from JPEG_MALLOC, fixed when JPEGDEC is built, so these tests +// cannot make it fail. JPEGDEC's prog_test.cpp covers it: without room for every coefficient the image is drawn +// from the DC only, and without room for even that decodeBegin() fails with JPEG_ERROR_MEMORY. + TEST(JpegOutOfMemory, MissingStateFailsWithoutStartingASession) { RuntimeImage img(JPEG, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); StatelessJpegDecoder decoder(&img); diff --git a/tests/components/runtime_image/test_jpeg_source.cpp b/tests/components/runtime_image/test_jpeg_source.cpp index 67a8dd751a4..874421635af 100644 --- a/tests/components/runtime_image/test_jpeg_source.cpp +++ b/tests/components/runtime_image/test_jpeg_source.cpp @@ -142,36 +142,125 @@ static const uint8_t JPEG_ROWS[] = { 0x7A, 0xBF, 0x5E, 0xB7, 0x3F, 0xFF, 0xD9, }; -// 8x8 progressive JPEG, 521 bytes -static const uint8_t JPEG_PROGRESSIVE[] = { +// JPEG_ROWS made progressive with jpegtran: the same coefficients in ten scans, so it decodes to the same pixels. +// At 2196 bytes it is also larger than JPEGDEC's read buffer. +static const uint8_t JPEG_ROWS_PROGRESSIVE[] = { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, - 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x10, 0x0B, 0x0C, 0x0E, 0x0C, 0x0A, 0x10, 0x0E, 0x0D, 0x0E, 0x12, 0x11, 0x10, - 0x13, 0x18, 0x28, 0x1A, 0x18, 0x16, 0x16, 0x18, 0x31, 0x23, 0x25, 0x1D, 0x28, 0x3A, 0x33, 0x3D, 0x3C, 0x39, 0x33, - 0x38, 0x37, 0x40, 0x48, 0x5C, 0x4E, 0x40, 0x44, 0x57, 0x45, 0x37, 0x38, 0x50, 0x6D, 0x51, 0x57, 0x5F, 0x62, 0x67, - 0x68, 0x67, 0x3E, 0x4D, 0x71, 0x79, 0x70, 0x64, 0x78, 0x5C, 0x65, 0x67, 0x63, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x11, - 0x12, 0x12, 0x18, 0x15, 0x18, 0x2F, 0x1A, 0x1A, 0x2F, 0x63, 0x42, 0x38, 0x42, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, - 0x63, 0x63, 0x63, 0x63, 0x63, 0x63, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x08, 0x00, 0x08, 0x03, 0x01, 0x22, 0x00, - 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x15, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xFF, 0xC4, 0x00, 0x15, 0x01, 0x01, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x05, 0xFF, 0xDA, 0x00, 0x0C, 0x03, - 0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0x94, 0x05, 0x3F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, - 0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, - 0x01, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0x7F, 0xFF, 0xC4, 0x00, - 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, - 0x01, 0x00, 0x01, 0x3F, 0x21, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, - 0x10, 0x0B, 0xFF, 0xC4, 0x00, 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, - 0x14, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, - 0x01, 0x00, 0x01, 0x3F, 0x10, 0x7F, 0xFF, 0xD9, + 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x02, 0x01, 0x01, 0x01, 0x01, 0x01, 0x02, 0x01, 0x01, 0x01, 0x02, 0x02, 0x02, + 0x02, 0x02, 0x04, 0x03, 0x02, 0x02, 0x02, 0x02, 0x05, 0x04, 0x04, 0x03, 0x04, 0x06, 0x05, 0x06, 0x06, 0x06, 0x05, + 0x06, 0x06, 0x06, 0x07, 0x09, 0x08, 0x06, 0x07, 0x09, 0x07, 0x06, 0x06, 0x08, 0x0B, 0x08, 0x09, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x06, 0x08, 0x0B, 0x0C, 0x0B, 0x0A, 0x0C, 0x09, 0x0A, 0x0A, 0x0A, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x02, + 0x02, 0x02, 0x02, 0x02, 0x02, 0x05, 0x03, 0x03, 0x05, 0x0A, 0x07, 0x06, 0x07, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, + 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0x0A, 0xFF, 0xC2, 0x00, 0x11, 0x08, 0x00, 0x28, 0x00, 0x30, 0x03, 0x01, 0x22, 0x00, + 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x18, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x05, 0x06, 0x00, 0x08, 0xFF, 0xC4, 0x00, 0x19, 0x01, 0x00, + 0x02, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x07, 0x04, 0x06, + 0x08, 0x03, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x10, 0x03, 0x10, 0x00, 0x00, 0x01, 0xE5, 0x43, 0xAB, + 0x09, 0x65, 0x8C, 0x52, 0xFC, 0xDA, 0x40, 0xAD, 0x09, 0xE6, 0x14, 0x8F, 0xEB, 0xCC, 0xF8, 0x53, 0x29, 0x1A, 0xF1, + 0xC1, 0xA8, 0x69, 0xCB, 0xA6, 0x84, 0xD0, 0x99, 0x73, 0x50, 0x7D, 0x6D, 0xA3, 0x06, 0xEF, 0x69, 0xF8, 0x7C, 0xCA, + 0x1D, 0xAB, 0x4D, 0x07, 0x8D, 0xB6, 0x18, 0xD3, 0xFF, 0xC4, 0x00, 0x20, 0x10, 0x00, 0x02, 0x03, 0x00, 0x01, 0x04, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x05, 0x01, 0x03, 0x04, 0x06, 0x11, 0x12, 0x13, + 0x15, 0x00, 0x14, 0x16, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x05, 0x02, 0x21, 0xAE, 0xF8, 0x52, 0x47, + 0xDB, 0x35, 0x56, 0x41, 0x3E, 0x49, 0x8C, 0x41, 0x9F, 0x3D, 0xB5, 0xDF, 0xA2, 0xDA, 0xC5, 0x32, 0xE9, 0x3C, 0x09, + 0xB0, 0xCE, 0x6A, 0x12, 0x28, 0xF1, 0xD2, 0xA9, 0x68, 0xD6, 0xA9, 0x42, 0x6E, 0x92, 0x89, 0x75, 0x63, 0x5F, 0x1D, + 0x1E, 0xD5, 0x5C, 0x60, 0x0C, 0x29, 0x4B, 0x27, 0x50, 0x71, 0xBA, 0xA3, 0xE6, 0x24, 0x47, 0x7E, 0x7F, 0xCE, 0xD3, + 0x5D, 0xFE, 0xA1, 0x97, 0x6A, 0xD5, 0x0C, 0xFE, 0xBC, 0x2B, 0x64, 0x63, 0x4A, 0x36, 0x66, 0x58, 0xD6, 0xB2, 0x89, + 0xF5, 0xAE, 0xF2, 0x01, 0x40, 0x5C, 0x2B, 0xBA, 0x58, 0x59, 0x4E, 0xF9, 0xA8, 0xC8, 0x88, 0x72, 0x04, 0x18, 0x17, + 0x50, 0x9F, 0xFF, 0xC4, 0x00, 0x2F, 0x11, 0x00, 0x01, 0x01, 0x04, 0x06, 0x07, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x05, 0x00, 0x02, 0x03, 0x11, 0x04, 0x06, 0x41, 0x51, 0x61, 0xF0, 0x12, 0x21, 0x23, + 0x71, 0xA1, 0xB1, 0xC1, 0x14, 0x15, 0x22, 0x31, 0x35, 0x42, 0x52, 0x81, 0xD1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, + 0x01, 0x01, 0x3F, 0x01, 0x48, 0x55, 0xA3, 0xF6, 0xE7, 0x5D, 0x91, 0xCB, 0xA5, 0xAA, 0xFA, 0x94, 0x12, 0xF4, 0x41, + 0x23, 0x2D, 0x56, 0x0C, 0x71, 0xCC, 0x99, 0x2D, 0x62, 0x07, 0x76, 0x43, 0x98, 0x27, 0xCF, 0x9E, 0xFB, 0x19, 0x1D, + 0x65, 0xF1, 0x4F, 0x1B, 0x61, 0xC2, 0xE3, 0x83, 0x21, 0xAD, 0x3C, 0x74, 0xF4, 0x63, 0x0B, 0x2E, 0xC7, 0x56, 0x79, + 0x32, 0x52, 0xD4, 0x43, 0x41, 0x77, 0x6A, 0x3D, 0xDF, 0x1B, 0xCD, 0xAC, 0x9C, 0xF1, 0x87, 0x4E, 0x98, 0xB2, 0x7D, + 0x7F, 0x1A, 0xAF, 0x9F, 0x14, 0x51, 0xBB, 0xAB, 0x26, 0xFA, 0x7B, 0xBF, 0x7C, 0x5B, 0xFF, 0xC4, 0x00, 0x2A, 0x11, + 0x00, 0x01, 0x03, 0x03, 0x03, 0x02, 0x04, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x11, + 0x21, 0x03, 0x04, 0x31, 0x00, 0x05, 0x12, 0x22, 0x71, 0x13, 0x41, 0x51, 0xA1, 0x42, 0x61, 0x81, 0x82, 0xB1, 0xC1, + 0xD1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x01, 0xBB, 0xDD, 0x6D, 0xED, 0xB9, 0x32, 0x67, 0xE2, + 0x02, 0x1F, 0xD7, 0xCC, 0x03, 0xF4, 0x23, 0xC8, 0xBB, 0x6A, 0xF3, 0x79, 0xB5, 0xBA, 0x45, 0x30, 0xBA, 0x6C, 0x85, + 0xB9, 0x7F, 0x90, 0x23, 0x30, 0xEE, 0x38, 0xF1, 0xC8, 0x21, 0xE2, 0x4A, 0x52, 0x2E, 0x6B, 0xD1, 0x15, 0x17, 0xC4, + 0x34, 0xBB, 0x06, 0x82, 0x7F, 0x0E, 0x9E, 0xAC, 0xF6, 0x65, 0x49, 0xBB, 0xDF, 0x15, 0x5A, 0x89, 0x5D, 0x45, 0x80, + 0x90, 0xF1, 0xD0, 0x0B, 0x00, 0x3B, 0x44, 0xC7, 0x52, 0x9C, 0x96, 0x97, 0x6D, 0x54, 0xDD, 0xEE, 0x7C, 0x44, 0x29, + 0x2A, 0x05, 0x30, 0x0B, 0x94, 0x32, 0xBC, 0xF0, 0x5C, 0x8E, 0x4A, 0x31, 0xDC, 0x1E, 0x2F, 0xAB, 0x8D, 0xCD, 0x55, + 0x2E, 0x96, 0xB5, 0xD7, 0x72, 0x19, 0xC8, 0x08, 0x98, 0x04, 0x1F, 0xBC, 0xBB, 0xE4, 0x8C, 0x9E, 0x38, 0x17, 0x54, + 0x52, 0x6C, 0xAA, 0x54, 0x54, 0x95, 0x66, 0x00, 0x76, 0x4A, 0x48, 0xC0, 0x18, 0x7F, 0xDE, 0x5C, 0xEB, 0x74, 0x52, + 0xA9, 0x56, 0xA0, 0x5D, 0xF9, 0x05, 0x8F, 0x6F, 0x4C, 0x7F, 0x49, 0x72, 0xF1, 0xA2, 0xBA, 0x95, 0x2F, 0xA4, 0xE0, + 0x7B, 0xA4, 0xB0, 0x3D, 0xE7, 0xD8, 0x01, 0x11, 0xAF, 0xFF, 0xC4, 0x00, 0x30, 0x10, 0x00, 0x01, 0x02, 0x04, 0x03, + 0x06, 0x05, 0x03, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x11, 0x00, 0x03, 0x12, 0x21, 0x22, + 0x31, 0x41, 0x04, 0x13, 0x32, 0x51, 0x81, 0xF0, 0x61, 0x71, 0x91, 0xA1, 0xD1, 0x14, 0x23, 0xB1, 0x24, 0x62, 0x72, + 0x82, 0xF1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x06, 0x3F, 0x02, 0xA5, 0x2F, 0x4A, 0x6C, 0x04, 0xC3, 0x72, + 0x9C, 0xAC, 0x35, 0x80, 0x2B, 0x75, 0x2A, 0x94, 0x85, 0x2C, 0x8E, 0x6D, 0xEB, 0xA4, 0x5E, 0x5F, 0x0B, 0x13, 0x5A, + 0xFD, 0xB2, 0xFC, 0xC2, 0x69, 0x61, 0xA2, 0x81, 0x25, 0x87, 0x80, 0xB7, 0x4E, 0xB0, 0xBA, 0xC3, 0x58, 0x16, 0xAE, + 0xC5, 0xF3, 0xBC, 0x24, 0x94, 0x39, 0xFD, 0xFA, 0x5B, 0xBE, 0xC5, 0x97, 0x85, 0x8B, 0xD8, 0xA9, 0x76, 0x7F, 0x9E, + 0xF4, 0x89, 0x83, 0x65, 0x40, 0x29, 0x09, 0xC2, 0x43, 0xBE, 0xBF, 0x1E, 0xE6, 0x02, 0x68, 0x1C, 0x4C, 0x29, 0x26, + 0x9D, 0x47, 0x5E, 0x91, 0xBB, 0x4E, 0xC0, 0x80, 0x9E, 0x20, 0xA0, 0x54, 0x35, 0xFC, 0x67, 0x0B, 0xC0, 0x7E, 0xE3, + 0x00, 0x2A, 0x50, 0x2F, 0xDB, 0x43, 0x19, 0x24, 0x90, 0xF9, 0xA9, 0x4C, 0xD6, 0x3C, 0xED, 0x14, 0x6F, 0x52, 0x84, + 0xA8, 0x85, 0x24, 0x04, 0x95, 0x31, 0xD3, 0x23, 0xDB, 0x44, 0xDA, 0x76, 0x99, 0x7C, 0x75, 0x6E, 0xB7, 0x6E, 0x3A, + 0x76, 0xC5, 0xA0, 0x7E, 0xBD, 0x3B, 0xB9, 0x8A, 0x2A, 0x6A, 0x41, 0x27, 0xDF, 0xB6, 0x84, 0x05, 0xED, 0x82, 0xA6, + 0xC3, 0x52, 0x0E, 0x1E, 0x62, 0xC7, 0xC3, 0xDA, 0x0A, 0xC6, 0xD2, 0x54, 0x42, 0x78, 0xC4, 0xBD, 0x6E, 0xD6, 0xF2, + 0x81, 0x2B, 0x7A, 0x68, 0xAA, 0xCF, 0x2D, 0x2D, 0xEA, 0xEE, 0xF0, 0x07, 0xD2, 0xEF, 0x70, 0x83, 0x82, 0x68, 0x24, + 0x7F, 0x57, 0xD3, 0x28, 0x54, 0xE4, 0x04, 0xA3, 0x16, 0x10, 0x54, 0x31, 0x66, 0xFE, 0xB0, 0x99, 0xAB, 0x4D, 0x20, + 0x82, 0x41, 0x42, 0x83, 0xF9, 0xE6, 0xE3, 0x28, 0xFB, 0x9B, 0x34, 0xB4, 0x05, 0x0B, 0x50, 0xB0, 0xCF, 0x96, 0x9D, + 0x98, 0x9C, 0x94, 0xEC, 0x2E, 0x95, 0x0C, 0x35, 0xA9, 0x23, 0x43, 0xF1, 0xDB, 0xC6, 0xE9, 0x7B, 0x0D, 0x29, 0x2A, + 0xC3, 0x30, 0x28, 0x61, 0xE8, 0xFC, 0xF9, 0xF2, 0x84, 0xC9, 0x97, 0x31, 0x5A, 0x10, 0x01, 0xCC, 0x5C, 0xB1, 0xF5, + 0x89, 0xC5, 0xAA, 0xD2, 0x9A, 0xB2, 0x3D, 0xFF, 0x00, 0x90, 0xD2, 0xE6, 0x26, 0x5A, 0x2A, 0xBB, 0x2B, 0x3F, 0x2F, + 0x58, 0x2B, 0x21, 0x2E, 0x50, 0x14, 0x65, 0xAC, 0xD8, 0xBB, 0x96, 0x85, 0xAE, 0x5C, 0xC5, 0xAA, 0x84, 0x25, 0x6E, + 0xA3, 0x96, 0x6F, 0xE7, 0x6F, 0x71, 0x08, 0x56, 0xF3, 0xF8, 0x66, 0x71, 0x72, 0xF1, 0x8F, 0xFF, 0xC4, 0x00, 0x22, + 0x10, 0x01, 0x01, 0x00, 0x02, 0x03, 0x00, 0x02, 0x02, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x11, + 0x21, 0x31, 0x00, 0x41, 0x51, 0x61, 0x71, 0x91, 0xB1, 0x81, 0xA1, 0xC1, 0xD1, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, + 0x00, 0x01, 0x3F, 0x21, 0x42, 0xE1, 0xF3, 0xCC, 0x0C, 0x0C, 0xE0, 0x2C, 0xFF, 0x00, 0x55, 0x22, 0x69, 0x4C, 0x2A, + 0xF8, 0x21, 0xA7, 0xC8, 0x7E, 0x13, 0x93, 0x4C, 0xE9, 0x1F, 0x37, 0xED, 0x91, 0xF4, 0x61, 0x99, 0x78, 0x98, 0x0A, + 0x74, 0x88, 0xFD, 0x03, 0xF9, 0x9E, 0x39, 0x07, 0x2A, 0xAD, 0x44, 0xC5, 0x34, 0x0C, 0xFF, 0x00, 0x78, 0xED, 0x58, + 0xF9, 0x68, 0x4E, 0x80, 0x3F, 0x66, 0x37, 0x7E, 0xDC, 0x74, 0xD7, 0xD9, 0xB8, 0xA3, 0x47, 0x31, 0x7E, 0xF1, 0xB3, + 0x90, 0x46, 0x6A, 0x3E, 0xB0, 0x53, 0x19, 0x7C, 0xCE, 0xC4, 0xE0, 0x4C, 0xCA, 0xC6, 0x7D, 0x33, 0x04, 0x06, 0x7C, + 0x55, 0x73, 0xDF, 0x2A, 0xA0, 0x87, 0x3B, 0x94, 0xCB, 0xDB, 0x49, 0xF0, 0x0E, 0x38, 0x05, 0xC1, 0x1F, 0x92, 0x61, + 0x1A, 0xEE, 0x41, 0x7D, 0x5E, 0xF9, 0x6E, 0x4E, 0x4C, 0xCB, 0x64, 0x45, 0x33, 0x7B, 0xFC, 0xF0, 0x26, 0x10, 0x0E, + 0xEE, 0xB2, 0xDB, 0x7D, 0xD3, 0x6F, 0x1D, 0x6B, 0x08, 0x12, 0x42, 0xB8, 0x25, 0xF1, 0xFA, 0x13, 0x5C, 0x1A, 0x2D, + 0x99, 0x86, 0x0C, 0xED, 0x76, 0x50, 0xBD, 0xB7, 0xC1, 0xA2, 0xE1, 0x78, 0x59, 0x87, 0xC3, 0x77, 0x28, 0xFA, 0xBC, + 0xB9, 0xD7, 0xD1, 0x65, 0xB3, 0x58, 0xCA, 0x6E, 0xB9, 0xDE, 0x92, 0xBE, 0x0E, 0x80, 0x87, 0xAE, 0xC1, 0xD9, 0xB1, + 0xC6, 0x39, 0x75, 0xE4, 0x64, 0xFE, 0x06, 0x12, 0xB8, 0x60, 0xD1, 0x9B, 0xA7, 0x01, 0x4D, 0x67, 0x8E, 0xCC, 0x84, + 0x68, 0x2C, 0x72, 0x98, 0xCF, 0x30, 0xB4, 0x3A, 0x8C, 0x22, 0x8E, 0xC6, 0x5F, 0xB4, 0x93, 0x68, 0x02, 0xD6, 0x31, + 0x89, 0xE0, 0x92, 0xE8, 0xDA, 0xF1, 0xF0, 0xB9, 0xD4, 0x41, 0x88, 0x67, 0x27, 0x9F, 0xCF, 0x11, 0x01, 0x6B, 0xF7, + 0x91, 0xFF, 0x00, 0x4D, 0x1E, 0x0B, 0x9C, 0xFC, 0xB5, 0xC0, 0xCC, 0x1A, 0x96, 0xBD, 0x5C, 0x32, 0x62, 0x51, 0xCC, + 0x00, 0xD4, 0x83, 0xBF, 0x6B, 0x8C, 0x2E, 0x06, 0x78, 0xB0, 0x05, 0x39, 0x3F, 0xA0, 0x84, 0x76, 0x78, 0x73, 0x1C, + 0xC8, 0xE8, 0x04, 0x03, 0x8C, 0x54, 0xB8, 0x65, 0x0D, 0x45, 0xA8, 0xC9, 0x34, 0x95, 0x4B, 0x1E, 0x15, 0x8C, 0xE2, + 0x37, 0xB0, 0x22, 0x95, 0x6E, 0xDF, 0xC7, 0xA3, 0x0D, 0xDC, 0x4E, 0x7F, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, + 0x02, 0x00, 0x03, 0x00, 0x00, 0x00, 0x10, 0xD4, 0xEB, 0x05, 0x80, 0xB8, 0xA3, 0x8F, 0xFF, 0xC4, 0x00, 0x1D, 0x11, + 0x01, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x11, 0x21, + 0x31, 0x41, 0x00, 0x61, 0x71, 0x51, 0x91, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x03, 0x01, 0x01, 0x3F, 0x10, 0x48, 0xDC, + 0xCD, 0x50, 0x65, 0x84, 0x8E, 0xBB, 0x9A, 0x43, 0xE7, 0x97, 0xCE, 0x2D, 0xE9, 0x47, 0xF5, 0xBC, 0xD0, 0xC9, 0x96, + 0x47, 0xC0, 0xC2, 0x51, 0x50, 0xE2, 0xE2, 0x29, 0xB0, 0x02, 0x98, 0x51, 0x00, 0xF2, 0xAE, 0x5A, 0xC8, 0xB2, 0xE0, + 0xF8, 0x77, 0x6C, 0xA1, 0xAB, 0xE7, 0xE3, 0xB6, 0x4B, 0x78, 0x48, 0x9C, 0x0E, 0x0F, 0xCF, 0xE7, 0x92, 0xA1, 0x1D, + 0x28, 0x7A, 0x3C, 0x20, 0x68, 0xC7, 0x43, 0x8F, 0x89, 0xED, 0x64, 0xFF, 0x00, 0x06, 0x64, 0xBA, 0x78, 0xB0, 0x71, + 0x54, 0xCF, 0x15, 0xBD, 0xF6, 0x65, 0xD0, 0x4C, 0xD5, 0x6A, 0x1F, 0xC7, 0xE2, 0xD5, 0x6C, 0xFD, 0x7F, 0x7B, 0x7D, + 0xFF, 0xC4, 0x00, 0x1A, 0x11, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x11, 0x21, 0x31, 0x00, 0x51, 0x61, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x02, 0x01, 0x01, 0x3F, 0x10, + 0x02, 0xE1, 0x0A, 0x80, 0x31, 0x4C, 0xAB, 0x22, 0xA0, 0xE2, 0x83, 0x51, 0x93, 0x49, 0x80, 0xA4, 0x5A, 0x41, 0xA4, + 0x21, 0x08, 0x67, 0x9B, 0x8E, 0x89, 0x0F, 0x9E, 0x33, 0x03, 0x51, 0xA0, 0x38, 0x2A, 0xFA, 0x02, 0x35, 0x4D, 0x73, + 0x00, 0xA7, 0x6F, 0xC0, 0x82, 0x63, 0x29, 0x50, 0x2D, 0x01, 0x4B, 0x06, 0x84, 0x40, 0x56, 0x07, 0x81, 0x9F, 0x9B, + 0xC4, 0xD3, 0x0F, 0x10, 0x36, 0xC5, 0x55, 0x7C, 0x4B, 0x2A, 0x1E, 0x82, 0xFA, 0xCD, 0x34, 0x89, 0xBF, 0x54, 0x2D, + 0xD1, 0x1A, 0x09, 0x12, 0xE4, 0x46, 0xD5, 0xC8, 0x80, 0x42, 0x08, 0xAA, 0x45, 0x00, 0x26, 0x9D, 0x9A, 0x00, 0x0F, + 0x42, 0x60, 0x57, 0xFF, 0xC4, 0x00, 0x1C, 0x10, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x11, 0x21, 0x00, 0x31, 0x41, 0x61, 0x51, 0x71, 0xFF, 0xDA, 0x00, 0x08, 0x01, + 0x01, 0x00, 0x01, 0x3F, 0x10, 0x0F, 0x56, 0x5B, 0x08, 0x1A, 0xCC, 0xA4, 0xAE, 0x04, 0x79, 0x69, 0xF7, 0x79, 0x4A, + 0xA9, 0xE6, 0x42, 0xD3, 0x2A, 0x94, 0x29, 0x43, 0x38, 0xD3, 0x10, 0x02, 0x45, 0x2A, 0x20, 0x80, 0x50, 0xEC, 0x8C, + 0x32, 0x85, 0xAB, 0x12, 0x89, 0x5E, 0x32, 0xF8, 0xE4, 0x88, 0x13, 0x21, 0x08, 0x64, 0x10, 0x17, 0xFC, 0x0E, 0x1A, + 0xD7, 0x68, 0xE6, 0x96, 0x38, 0x7E, 0xCA, 0x24, 0x4E, 0x59, 0x71, 0x14, 0x88, 0x2B, 0x94, 0x0A, 0xB6, 0x12, 0x9C, + 0x04, 0x88, 0x24, 0x44, 0xAB, 0x63, 0x40, 0xDF, 0xF8, 0x60, 0x81, 0x98, 0x48, 0x6F, 0x41, 0x2D, 0xD0, 0x88, 0x3F, + 0x40, 0xE5, 0xCC, 0xAA, 0xF2, 0x04, 0x00, 0xA8, 0x0F, 0x25, 0x13, 0x0E, 0x3D, 0x5C, 0x1F, 0xD1, 0x68, 0x8E, 0x25, + 0xE1, 0xBE, 0x4B, 0x60, 0x99, 0x7A, 0xC4, 0xDE, 0x94, 0x13, 0x98, 0x07, 0xA8, 0x51, 0x6D, 0x97, 0x7C, 0x45, 0x40, + 0xF8, 0x60, 0x15, 0x29, 0xD5, 0x34, 0x08, 0x00, 0x08, 0x68, 0xED, 0xC2, 0xB6, 0x8D, 0xB6, 0xCA, 0x16, 0xFA, 0x26, + 0x8C, 0x4A, 0x8D, 0x80, 0x01, 0x0F, 0x02, 0x00, 0x42, 0xA9, 0xB5, 0x55, 0xAC, 0x10, 0x15, 0x5A, 0xE2, 0xD9, 0x60, + 0x4A, 0x6A, 0xC7, 0x68, 0x26, 0x4C, 0xAB, 0xD6, 0x8C, 0xAF, 0x6B, 0xC5, 0x22, 0x20, 0xAA, 0xC7, 0xB8, 0xAE, 0x43, + 0xFF, 0x00, 0x6A, 0xF6, 0x2E, 0x50, 0x16, 0x01, 0x1A, 0x14, 0x73, 0x42, 0x9E, 0x36, 0xA0, 0x21, 0x20, 0x4F, 0x49, + 0x60, 0x08, 0x92, 0x70, 0x05, 0xFC, 0x03, 0x46, 0x89, 0x8A, 0x17, 0x12, 0xA2, 0xE2, 0x31, 0x0C, 0x2A, 0x93, 0x60, + 0x02, 0xBD, 0x92, 0x4A, 0x8E, 0xD5, 0x14, 0xD6, 0x23, 0x51, 0x4F, 0xB1, 0x45, 0x2C, 0xFB, 0x42, 0x28, 0x21, 0xA6, + 0x54, 0x34, 0x85, 0x06, 0x0B, 0xF4, 0x02, 0xC2, 0x24, 0x26, 0xD5, 0x28, 0x81, 0x45, 0x3C, 0x0C, 0xD7, 0xD0, 0x10, + 0x02, 0xAA, 0x5A, 0xC5, 0x78, 0x27, 0x0C, 0x41, 0xB0, 0x30, 0x13, 0xAE, 0x58, 0x20, 0x21, 0x40, 0x1A, 0x54, 0x3C, + 0x50, 0x53, 0x0E, 0x01, 0x89, 0xC1, 0xCC, 0xF9, 0x6A, 0x59, 0x55, 0x0A, 0x68, 0x0A, 0x60, 0x14, 0xA2, 0xF4, 0xAD, + 0x19, 0xA2, 0x55, 0xAB, 0x45, 0x51, 0x3C, 0x38, 0x7F, 0xFF, 0xD9, }; class SourceImage : public RuntimeImage { @@ -236,8 +325,8 @@ class TrickleSource : public DecodeSource { size_t pos_{0}; }; -static bool decode_fed(RuntimeImage &img) { - std::vector buffer(JPEG_ROWS, JPEG_ROWS + sizeof(JPEG_ROWS)); +static bool decode_fed(RuntimeImage &img, const uint8_t *data = JPEG_ROWS, size_t size = sizeof(JPEG_ROWS)) { + std::vector buffer(data, data + size); if (!img.begin_decode(buffer.size())) return false; if (img.feed_data(buffer.data(), buffer.size()) != static_cast(buffer.size())) @@ -246,9 +335,9 @@ static bool decode_fed(RuntimeImage &img) { } /// Decodes from a source a step at a time; returns the last result and counts the steps -static int decode_stepped(RuntimeImage &img, DecodeSource &source, int &steps) { +static int decode_stepped(RuntimeImage &img, DecodeSource &source, int &steps, size_t size = sizeof(JPEG_ROWS)) { steps = 0; - if (!img.begin_decode(sizeof(JPEG_ROWS)) || !img.decoder_supports_source()) + if (!img.begin_decode(size) || !img.decoder_supports_source()) return DECODE_ERROR_UNSUPPORTED_FORMAT; int result = img.begin_source(&source); if (result != 0) @@ -391,12 +480,93 @@ TEST(JpegSourceDecode, FailedSeekToTheStartIsAReadError) { EXPECT_EQ(img.decode_step(), DECODE_ERROR_INVALID_TYPE); } -TEST(JpegSourceDecode, ProgressiveImageIsUnsupported) { +// Fed and stepped, at full size and at each reduced scale JPEGDEC picks for a smaller image +TEST(JpegSourceDecode, ProgressiveImageMatchesBaseline) { + for (int scale : {1, 2, 4, 8}) { + for (auto type : {image::IMAGE_TYPE_RGB565, image::IMAGE_TYPE_RGB}) { + SourceImage baseline(type, 48 / scale, 40 / scale); + ASSERT_TRUE(decode_fed(baseline)); + ASSERT_EQ(baseline.scale(), scale); + + SourceImage fed(type, 48 / scale, 40 / scale); + ASSERT_TRUE(decode_fed(fed, JPEG_ROWS_PROGRESSIVE, sizeof(JPEG_ROWS_PROGRESSIVE))); + EXPECT_EQ(pixels(fed), pixels(baseline)) << "fed, scale " << scale << ", image type " << type; + + SourceImage stepped(type, 48 / scale, 40 / scale); + MemorySource source(JPEG_ROWS_PROGRESSIVE, sizeof(JPEG_ROWS_PROGRESSIVE)); + int steps; + ASSERT_EQ(decode_stepped(stepped, source, steps, sizeof(JPEG_ROWS_PROGRESSIVE)), DECODE_STEP_DONE); + ASSERT_EQ(stepped.scale(), scale); + EXPECT_EQ(pixels(stepped), pixels(baseline)) << "stepped, scale " << scale << ", image type " << type; + } + } +} + +// An extended sequential (SOF1) frame holds what a baseline one does, so changing the marker keeps the pixels +TEST(JpegSourceDecode, ExtendedSequentialImageMatchesBaseline) { + static const uint8_t SOF0[] = {0xFF, 0xC0}; + std::vector extended(JPEG_ROWS, JPEG_ROWS + sizeof(JPEG_ROWS)); + auto sof = std::search(extended.begin(), extended.end(), std::begin(SOF0), std::end(SOF0)); + ASSERT_NE(sof, extended.end()); + *(sof + 1) = 0xC1; + + for (int scale : {1, 2, 4, 8}) { + for (auto type : {image::IMAGE_TYPE_RGB565, image::IMAGE_TYPE_RGB}) { + SourceImage baseline(type, 48 / scale, 40 / scale); + ASSERT_TRUE(decode_fed(baseline)); + ASSERT_EQ(baseline.scale(), scale); + + SourceImage fed(type, 48 / scale, 40 / scale); + ASSERT_TRUE(decode_fed(fed, extended.data(), extended.size())); + EXPECT_EQ(pixels(fed), pixels(baseline)) << "fed, scale " << scale << ", image type " << type; + + SourceImage stepped(type, 48 / scale, 40 / scale); + MemorySource source(extended.data(), extended.size()); + int steps; + ASSERT_EQ(decode_stepped(stepped, source, steps, extended.size()), DECODE_STEP_DONE); + ASSERT_EQ(stepped.scale(), scale); + EXPECT_EQ(pixels(stepped), pixels(baseline)) << "stepped, scale " << scale << ", image type " << type; + } + } +} + +TEST(JpegSourceDecode, ProgressiveImageWaitsForDataThatHasNotArrived) { + SourceImage baseline; + ASSERT_TRUE(decode_fed(baseline)); + + // Enough for the headers (the first scan starts at byte 230); more arrives only when the decode asks for it SourceImage img; - MemorySource source(JPEG_PROGRESSIVE, sizeof(JPEG_PROGRESSIVE)); - ASSERT_TRUE(img.begin_decode(sizeof(JPEG_PROGRESSIVE))); - EXPECT_EQ(img.begin_source(&source), DECODE_ERROR_UNSUPPORTED_FORMAT); - EXPECT_EQ(img.decode_step(), DECODE_ERROR_INVALID_TYPE); + TrickleSource source(JPEG_ROWS_PROGRESSIVE, sizeof(JPEG_ROWS_PROGRESSIVE), 300); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS_PROGRESSIVE))); + ASSERT_EQ(img.begin_source(&source), 0); + int waits = 0; + int steps = 0; + int result; + while ((result = img.decode_step()) == DECODE_STEP_MORE || result == DECODE_STEP_NEED_DATA) { + if (result == DECODE_STEP_NEED_DATA) { + waits++; + source.deliver(50); + } + ASSERT_LT(++steps, 1000); + } + ASSERT_EQ(result, DECODE_STEP_DONE); + ASSERT_TRUE(img.end_decode()); + EXPECT_GT(waits, 0); + EXPECT_EQ(pixels(img), pixels(baseline)); +} + +// The coefficient buffer of a progressive image is freed with the image, so the decoder can be used again +TEST(JpegSourceDecode, ReleasePartWayThroughAProgressiveImageLetsTheNextDecodeStart) { + SourceImage img; + MemorySource first(JPEG_ROWS_PROGRESSIVE, sizeof(JPEG_ROWS_PROGRESSIVE)); + ASSERT_TRUE(img.begin_decode(sizeof(JPEG_ROWS_PROGRESSIVE))); + ASSERT_EQ(img.begin_source(&first), 0); + ASSERT_EQ(img.decode_step(), DECODE_STEP_MORE); + img.release(); + + MemorySource second(JPEG_ROWS_PROGRESSIVE, sizeof(JPEG_ROWS_PROGRESSIVE)); + int steps; + EXPECT_EQ(decode_stepped(img, second, steps, sizeof(JPEG_ROWS_PROGRESSIVE)), DECODE_STEP_DONE); } TEST(JpegSourceDecode, CompletedDecodeCountsTheWholeImage) { From 27aa91bb9d4fc270d535e0e9ace4ff4582a13cef Mon Sep 17 00:00:00 2001 From: Giorgio Aresu Date: Sun, 1 Nov 2026 10:19:31 +0100 Subject: [PATCH 043/170] [mipi_rgb] Add GC9503V and ZX3D95CE01S-TR-4848 (#21588) --- esphome/components/mipi_rgb/mipi_rgb.cpp | 6 +- esphome/components/mipi_rgb/models/gc9503v.py | 43 +++++ esphome/components/mipi_rgb/models/panlee.py | 75 +++++++++ .../component_tests/mipi_rgb/test_gc9503v.py | 157 ++++++++++++++++++ 4 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 esphome/components/mipi_rgb/models/gc9503v.py create mode 100644 esphome/components/mipi_rgb/models/panlee.py create mode 100644 tests/component_tests/mipi_rgb/test_gc9503v.py diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 017a3fa9e0a..e7fd946cb0a 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -40,10 +40,10 @@ void MipiRgb::setup_enables_() { void MipiRgbSpi::setup() { this->setup_enables_(); this->spi_setup(); - this->common_setup_(); + this->write_init_sequence_(); if (this->is_failed()) return; - this->write_init_sequence_(); + this->common_setup_(); } void MipiRgbSpi::write_command_(uint8_t value) { this->enable(); @@ -107,7 +107,7 @@ void MipiRgbSpi::write_init_sequence_() { delay(10); } } - // this->spi_teardown(); // SPI not needed after this + this->spi_teardown(); delay(10); } diff --git a/esphome/components/mipi_rgb/models/gc9503v.py b/esphome/components/mipi_rgb/models/gc9503v.py new file mode 100644 index 00000000000..19989cec1b3 --- /dev/null +++ b/esphome/components/mipi_rgb/models/gc9503v.py @@ -0,0 +1,43 @@ +from esphome.components.mipi import MODE_BGR +from esphome.const import CONF_COLOR_ORDER, CONF_MIRROR_X, CONF_MIRROR_Y + +from . import RgbDriverChip + +# The GC9503V does not use the standard MADCTL (0x36) register. Its memory access +# control register is the vendor specific command 0xB1. +GC9503V_MADCTL = 0xB1 +GC9503V_MADCTL_DEFAULT = 0x10 # Power-on default; 0x10 selects column inversion, 0x00 selects zigzag inversion +GC9503V_MADCTL_SS = 0x01 # Source scan direction, used for mirror_y +GC9503V_MADCTL_GS = 0x02 # Gate scan direction, used for mirror_x +GC9503V_MADCTL_BGR = 0x20 # RGB/BGR colour order, 0 = RGB, 1 = BGR + + +class GC9503V(RgbDriverChip): + def add_madctl(self, sequence: list, config: dict) -> None: + transform = self.get_transform(config) + madctl = GC9503V_MADCTL_DEFAULT + if config[CONF_COLOR_ORDER] == MODE_BGR: + madctl |= GC9503V_MADCTL_BGR + if transform.get(CONF_MIRROR_X): + madctl |= GC9503V_MADCTL_GS + if transform.get(CONF_MIRROR_Y): + madctl |= GC9503V_MADCTL_SS + sequence.append((GC9503V_MADCTL, madctl)) + + +# fmt: off +gc9503v = GC9503V( + "GC9503V", + hsync_pulse_width=10, + hsync_back_porch=50, + hsync_front_porch=8, + vsync_pulse_width=10, + vsync_back_porch=20, + vsync_front_porch=8, + pclk_frequency="15MHz", + pclk_inverted=True, + # The panel does not initialise reliably at the 1MHz default; the vendor code uses 10MHz + data_rate="10MHz", + # Espressif's reference driver waits 120ms after reset + reset_delay=120, +) diff --git a/esphome/components/mipi_rgb/models/panlee.py b/esphome/components/mipi_rgb/models/panlee.py new file mode 100644 index 00000000000..4d579b13e16 --- /dev/null +++ b/esphome/components/mipi_rgb/models/panlee.py @@ -0,0 +1,75 @@ +from .gc9503v import gc9503v + +# Panlee ZX3D95CE01S-TR-4848 - 480x480 smart screen, ESP32-S3 with PSRAM, GC9503V driver. +# The GPIO 45 (SPI clock) and 48 (SPI MOSI) used for the init sequence are shared with RGB data +# lines (blue 1 and 2), so the spi bus must declare them with ``allow_other_uses: true``. +# fmt: off +gc9503v.extend( + "ZX3D95CE01S-TR-4848", + width=480, + height=480, + pixel_mode="18bit", + requires={"psram"}, + # The board docs indicates it has no separate LCD reset GPIO. The panel uses RGB_VS + # for its auto-reset/keep-alive behavior, so GPIO 41 is shared. + reset_pin={"number": 41, "allow_other_uses": True}, + cs_pin=38, + de_pin=40, + hsync_pin=42, + vsync_pin={"number": 41, "allow_other_uses": True}, + pclk_pin=39, + data_pins={ + "red": [18, 8, 3, 46, 10], + "green": [14, 13, 12, 11, 16, 17], + "blue": [ + {"number": 45, "allow_other_uses": True, "ignore_strapping_warning": True}, + {"number": 48, "allow_other_uses": True}, + 47, + 0, + 21, + ], + }, + initsequence=( + (0xF0, 0x55, 0xAA, 0x52, 0x08, 0x00), + (0xF6, 0x5A, 0x87), + (0xC1, 0x3F), + (0xC2, 0x0E), + (0xC6, 0xF8), + (0xC9, 0x10), + (0xCD, 0x25), + (0xF8, 0x8A), + (0xAC, 0x45), + (0xA0, 0xDD), + (0xA7, 0x47), + (0xFA, 0x00, 0x00, 0x00, 0x04), + (0x86, 0x99, 0xA3, 0xA3, 0x51), + (0xA3, 0xEE), + (0xFD, 0x3C, 0x3C, 0x00), + (0x71, 0x48), + (0x72, 0x48), + (0x73, 0x00, 0x44), + (0x97, 0xEE), + (0x83, 0x93), + (0x9A, 0x72), + (0x9B, 0x5A), + (0x82, 0x2C, 0x2C), + (0x6D, 0x00, 0x1F, 0x19, 0x1A, 0x10, 0x0E, 0x0C, 0x0A, 0x02, 0x07, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x1E, 0x08, 0x01, 0x09, 0x0B, 0x0D, 0x0F, 0x1A, 0x19, 0x1F, 0x00), + (0x64, 0x38, 0x05, 0x01, 0xDB, 0x03, 0x03, 0x38, 0x04, 0x01, 0xDC, 0x03, 0x03, 0x7A, 0x7A, 0x7A, 0x7A), + (0x65, 0x38, 0x03, 0x01, 0xDD, 0x03, 0x03, 0x38, 0x02, 0x01, 0xDE, 0x03, 0x03, 0x7A, 0x7A, 0x7A, 0x7A), + (0x66, 0x38, 0x01, 0x01, 0xDF, 0x03, 0x03, 0x38, 0x00, 0x01, 0xE0, 0x03, 0x03, 0x7A, 0x7A, 0x7A, 0x7A), + (0x67, 0x30, 0x01, 0x01, 0xE1, 0x03, 0x03, 0x30, 0x02, 0x01, 0xE2, 0x03, 0x03, 0x7A, 0x7A, 0x7A, 0x7A), + (0x68, 0x00, 0x08, 0x15, 0x08, 0x15, 0x7A, 0x7A, 0x08, 0x15, 0x08, 0x15, 0x7A, 0x7A), + (0x60, 0x38, 0x08, 0x7A, 0x7A, 0x38, 0x09, 0x7A, 0x7A), + (0x63, 0x31, 0xE4, 0x7A, 0x7A, 0x31, 0xE5, 0x7A, 0x7A), + (0x69, 0x04, 0x22, 0x14, 0x22, 0x14, 0x22, 0x08), + (0x6B, 0x07), + (0x7A, 0x08, 0x13), + (0x7B, 0x08, 0x13), + (0xD1, 0x00, 0x00, 0x00, 0x04, 0x00, 0x12, 0x00, 0x18, 0x00, 0x21, 0x00, 0x2A, 0x00, 0x35, 0x00, 0x47, 0x00, 0x56, 0x00, 0x90, 0x00, 0xE5, 0x01, 0x68, 0x01, 0xD5, 0x01, 0xD7, 0x02, 0x36, 0x02, 0xA6, 0x02, 0xEE, 0x03, 0x48, 0x03, 0xA0, 0x03, 0xBA, 0x03, 0xC5, 0x03, 0xD0, 0x03, 0xE0, 0x03, 0xEA, 0x03, 0xFA, 0x03, 0xFF), + (0xD2, 0x00, 0x00, 0x00, 0x04, 0x00, 0x12, 0x00, 0x18, 0x00, 0x21, 0x00, 0x2A, 0x00, 0x35, 0x00, 0x47, 0x00, 0x56, 0x00, 0x90, 0x00, 0xE5, 0x01, 0x68, 0x01, 0xD5, 0x01, 0xD7, 0x02, 0x36, 0x02, 0xA6, 0x02, 0xEE, 0x03, 0x48, 0x03, 0xA0, 0x03, 0xBA, 0x03, 0xC5, 0x03, 0xD0, 0x03, 0xE0, 0x03, 0xEA, 0x03, 0xFA, 0x03, 0xFF), + (0xD3, 0x00, 0x00, 0x00, 0x04, 0x00, 0x12, 0x00, 0x18, 0x00, 0x21, 0x00, 0x2A, 0x00, 0x35, 0x00, 0x47, 0x00, 0x56, 0x00, 0x90, 0x00, 0xE5, 0x01, 0x68, 0x01, 0xD5, 0x01, 0xD7, 0x02, 0x36, 0x02, 0xA6, 0x02, 0xEE, 0x03, 0x48, 0x03, 0xA0, 0x03, 0xBA, 0x03, 0xC5, 0x03, 0xD0, 0x03, 0xE0, 0x03, 0xEA, 0x03, 0xFA, 0x03, 0xFF), + (0xD4, 0x00, 0x00, 0x00, 0x04, 0x00, 0x12, 0x00, 0x18, 0x00, 0x21, 0x00, 0x2A, 0x00, 0x35, 0x00, 0x47, 0x00, 0x56, 0x00, 0x90, 0x00, 0xE5, 0x01, 0x68, 0x01, 0xD5, 0x01, 0xD7, 0x02, 0x36, 0x02, 0xA6, 0x02, 0xEE, 0x03, 0x48, 0x03, 0xA0, 0x03, 0xBA, 0x03, 0xC5, 0x03, 0xD0, 0x03, 0xE0, 0x03, 0xEA, 0x03, 0xFA, 0x03, 0xFF), + (0xD5, 0x00, 0x00, 0x00, 0x04, 0x00, 0x12, 0x00, 0x18, 0x00, 0x21, 0x00, 0x2A, 0x00, 0x35, 0x00, 0x47, 0x00, 0x56, 0x00, 0x90, 0x00, 0xE5, 0x01, 0x68, 0x01, 0xD5, 0x01, 0xD7, 0x02, 0x36, 0x02, 0xA6, 0x02, 0xEE, 0x03, 0x48, 0x03, 0xA0, 0x03, 0xBA, 0x03, 0xC5, 0x03, 0xD0, 0x03, 0xE0, 0x03, 0xEA, 0x03, 0xFA, 0x03, 0xFF), + (0xD6, 0x00, 0x00, 0x00, 0x04, 0x00, 0x12, 0x00, 0x18, 0x00, 0x21, 0x00, 0x2A, 0x00, 0x35, 0x00, 0x47, 0x00, 0x56, 0x00, 0x90, 0x00, 0xE5, 0x01, 0x68, 0x01, 0xD5, 0x01, 0xD7, 0x02, 0x36, 0x02, 0xA6, 0x02, 0xEE, 0x03, 0x48, 0x03, 0xA0, 0x03, 0xBA, 0x03, 0xC5, 0x03, 0xD0, 0x03, 0xE0, 0x03, 0xEA, 0x03, 0xFA, 0x03, 0xFF), + ), +) diff --git a/tests/component_tests/mipi_rgb/test_gc9503v.py b/tests/component_tests/mipi_rgb/test_gc9503v.py new file mode 100644 index 00000000000..fedab1e95c0 --- /dev/null +++ b/tests/component_tests/mipi_rgb/test_gc9503v.py @@ -0,0 +1,157 @@ +"""End-to-end tests for the GC9503V driver chip and the boards built on it. + +The GC9503V does not use the standard MADCTL (0x36) register, so these tests go +through real codegen and check the bytes that will actually be sent to the panel. +""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +_YAML = """ +esphome: + name: gc9503v-test +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf +psram: + mode: octal +spi: + clk_pin: + number: 45 + allow_other_uses: true + ignore_strapping_warning: true + mosi_pin: + number: 48 + allow_other_uses: true +display: + - platform: mipi_rgb + id: panel + model: ZX3D95CE01S-TR-4848 +{extra} +""" + + +def _commands( + main_cpp: str, display_id: str +) -> list[tuple[int | str, tuple[int, ...]]]: + """Decode the flattened init sequence of a display into (command, data) pairs. + + A delay is returned as ("delay", (milliseconds,)). + """ + match = re.search(rf"{display_id}->set_init_sequence\((\w+), \d+\);", main_cpp) + assert match is not None + table = re.search( + rf"static constexpr uint8_t {match.group(1)}\[\] PROGMEM = \{{([^;]*)\}};", + main_cpp, + ) + assert table is not None + values = [ + int(v, 0) for v in table.group(1).replace("\n", " ").split(",") if v.strip() + ] + commands: list[tuple[int | str, tuple[int, ...]]] = [] + index = 0 + while index < len(values): + command, length = values[index], values[index + 1] + if length == 0xFF: # delay flag + commands.append(("delay", (command,))) + index += 2 + else: + count = length & 0x7F + commands.append((command, tuple(values[index + 2 : index + 2 + count]))) + index += 2 + count + return commands + + +def _generate( + generate_main: Callable[[str | Path], str], tmp_path: Path, extra: str = "" +) -> list[tuple[int | str, tuple[int, ...]]]: + yaml_file = tmp_path / "gc9503v.yaml" + yaml_file.write_text(_YAML.format(extra=extra)) + return _commands(generate_main(yaml_file), "panel") + + +def test_reset_and_init_sequence_framing( + generate_main: Callable[[str | Path], str], tmp_path: Path +) -> None: + """No software reset is sent, as in the vendor init; only a settling delay.""" + commands = _generate(generate_main, tmp_path) + ids = [c for c, _ in commands] + + assert 0x01 not in ids + assert commands[0] == ("delay", (120,)) + assert commands[1] == (0xF0, (0x55, 0xAA, 0x52, 0x08, 0x00)) + # The driver supplies COLMOD, SLPOUT and DISPON itself; the board's own sequence + # must not contain them, or they would be sent twice. + assert ids.count(0x11) == 1 + assert ids.count(0x29) == 1 + assert ids.count(0x3A) == 1 + assert (0x3A, (0x66,)) in commands # board uses the 18bit pixel mode + + +def test_madctl_uses_vendor_register_not_0x36( + generate_main: Callable[[str | Path], str], tmp_path: Path +) -> None: + """Colour order and mirroring go to register 0xB1; 0x36 is never written.""" + commands = _generate(generate_main, tmp_path) + ids = [c for c, _ in commands] + + assert 0x36 not in ids + assert 0xC7 not in ids # the ST7701S scan direction register + # Default: RGB order, no mirroring leaves the power-on value of 0x10 untouched, + # and the write comes straight after COLMOD. + assert commands[ids.index(0x3A) + 1] == (0xB1, (0x10,)) + + +@pytest.mark.parametrize( + ("extra", "expected"), + [ + (" color_order: BGR", 0x30), + (" transform:\n mirror_x: true\n mirror_y: false", 0x12), + (" transform:\n mirror_x: false\n mirror_y: true", 0x11), + ( + " color_order: BGR\n transform:\n mirror_x: true\n mirror_y: true", + 0x33, + ), + ], +) +def test_madctl_bits( + generate_main: Callable[[str | Path], str], + tmp_path: Path, + extra: str, + expected: int, +) -> None: + """BGR is bit 5, mirror_x is GS (bit 1), mirror_y is SS (bit 0).""" + commands = _generate(generate_main, tmp_path, extra) + ids = [c for c, _ in commands] + assert commands[ids.index(0x3A) + 1] == (0xB1, (expected,)) + + +def test_board_init_sequence_is_complete( + generate_main: Callable[[str | Path], str], tmp_path: Path +) -> None: + """Guard against a truncated or mangled panel sequence.""" + commands = _generate(generate_main, tmp_path) + ids = [c for c, _ in commands] + + # Settling delay, 41 panel commands, then COLMOD, MADCTL, INVOFF, + # delay, SLPOUT, delay, DISPON, delay. + assert len(commands) == 1 + 41 + 8 + # Gamma tables D1..D6 are 52 bytes each. + for gamma in range(0xD1, 0xD7): + assert len(commands[ids.index(gamma)][1]) == 52 + # GIP timing registers. + assert len(commands[ids.index(0x6D)][1]) == 32 + + +def test_spi_runs_at_10mhz( + generate_main: Callable[[str | Path], str], tmp_path: Path +) -> None: + """The vendor init clocks the 3-wire SPI at 10MHz.""" + yaml_file = tmp_path / "gc9503v.yaml" + yaml_file.write_text(_YAML.format(extra="")) + + assert "panel->set_data_rate(10000000.0f);" in generate_main(yaml_file) From 0548ad7a72a1ae6e3b7583e98580bc9f8f5aaaf1 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:47:34 +1100 Subject: [PATCH 044/170] [lvgl] Make a scrollable container automatically clickable (#22094) Co-authored-by: Claude --- esphome/components/lvgl/widgets/container.py | 16 ++++++- .../config/container_scrollable_test.yaml | 48 +++++++++++++++++++ .../lvgl/test_container_scrollable.py | 44 +++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 5 ++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/config/container_scrollable_test.yaml create mode 100644 tests/component_tests/lvgl/test_container_scrollable.py diff --git a/esphome/components/lvgl/widgets/container.py b/esphome/components/lvgl/widgets/container.py index 427b46affa1..1339855ab10 100644 --- a/esphome/components/lvgl/widgets/container.py +++ b/esphome/components/lvgl/widgets/container.py @@ -1,8 +1,16 @@ import esphome.config_validation as cv from esphome.const import CONF_HEIGHT, CONF_WIDTH +from esphome.cpp_generator import MockObj -from ..defines import CONF_CONTAINER, CONF_MAIN, CONF_SCROLLBAR +from ..defines import ( + CONF_CLICKABLE, + CONF_CONTAINER, + CONF_MAIN, + CONF_SCROLLABLE, + CONF_SCROLLBAR, +) from ..lv_validation import size +from ..lvcode import lv_obj from ..types import lv_obj_t from . import WidgetType @@ -31,5 +39,11 @@ class ContainerType(WidgetType): ) self.styles = {} + async def on_create(self, var: MockObj, config: dict) -> None: + # A container is not clickable, so dragging its background does not scroll it. + # An explicit `scrollable: true` asks for that, unless `clickable` is also set. + if config.get(CONF_SCROLLABLE) is True and CONF_CLICKABLE not in config: + lv_obj.set_clickable(var, True) + container_spec = ContainerType() diff --git a/tests/component_tests/lvgl/config/container_scrollable_test.yaml b/tests/component_tests/lvgl/config/container_scrollable_test.yaml new file mode 100644 index 00000000000..eae679ce7aa --- /dev/null +++ b/tests/component_tests/lvgl/config/container_scrollable_test.yaml @@ -0,0 +1,48 @@ +esphome: + name: test-container-scrollable + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - container: + id: cont_default + - container: + id: cont_scrollable + scrollable: true + - container: + id: cont_not_scrollable + scrollable: false + - container: + id: cont_scrollable_not_clickable + scrollable: true + clickable: false + - container: + id: cont_scrollable_lambda + scrollable: !lambda return true; + - obj: + id: obj_scrollable + scrollable: true diff --git a/tests/component_tests/lvgl/test_container_scrollable.py b/tests/component_tests/lvgl/test_container_scrollable.py new file mode 100644 index 00000000000..d37b32d8cc5 --- /dev/null +++ b/tests/component_tests/lvgl/test_container_scrollable.py @@ -0,0 +1,44 @@ +"""An explicit ``scrollable: true`` on a container also makes it clickable, so a drag +on its background scrolls it. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.fixture +def main_cpp( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> str: + return generate_main(component_config_path("container_scrollable_test.yaml")) + + +def _set_clickable_calls(main_cpp: str, widget_id: str) -> list[str]: + prefix = f"lv_obj_set_clickable({widget_id}, " + return [ + line.strip().removeprefix(prefix).removesuffix(");") + for line in main_cpp.splitlines() + if line.strip().startswith(prefix) + ] + + +@pytest.mark.parametrize( + ("widget_id", "expected"), + [ + ("cont_scrollable", ["true"]), + ("cont_default", []), + ("cont_not_scrollable", []), + ("cont_scrollable_not_clickable", ["false"]), + ("cont_scrollable_lambda", []), + ("obj_scrollable", []), + ], +) +def test_container_set_clickable( + main_cpp: str, widget_id: str, expected: list[str] +) -> None: + assert _set_clickable_calls(main_cpp, widget_id) == expected diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 81aa24b41a4..314ea7c2008 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -590,6 +590,11 @@ lvgl: align: top_left text_letter_space: -4 text_line_space: -4 + - container: + id: scrollable_container + scrollable: true + width: 50 + height: 50 - container: align: center anim_duration: 1s From e5e068ff20786bd1deabfde7572d67f92b5039e2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:56:23 +1300 Subject: [PATCH 045/170] [mipi_spi] Add generic SPD2010 model and SenseCAP Watcher board (#21354) --- esphome/components/mipi_spi/models/spd2010.py | 19 +++++++++++++++---- .../fixtures/spd2010_sensecap_watcher.yaml | 16 ++++++++++++++++ tests/component_tests/mipi_spi/test_init.py | 17 +++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/mipi_spi/fixtures/spd2010_sensecap_watcher.yaml diff --git a/esphome/components/mipi_spi/models/spd2010.py b/esphome/components/mipi_spi/models/spd2010.py index bd5f0c53394..c8ed4cb4bf7 100644 --- a/esphome/components/mipi_spi/models/spd2010.py +++ b/esphome/components/mipi_spi/models/spd2010.py @@ -8,15 +8,13 @@ from esphome.components.spi import TYPE_QUAD from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y # fmt: off -DriverChip( - "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46", +SPD2010 = DriverChip( + "SPD2010", width=412, height=412, bus_mode=TYPE_QUAD, spi_mode="MODE0", data_rate="40MHz", - cs_pin=21, - reset_pin={"pca9554": None, "number": 1}, color_order=MODE_RGB, draw_rounding=4, transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, @@ -415,3 +413,16 @@ DriverChip( ), ) # fmt: on + +SPD2010.extend( + "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46", + cs_pin=21, + reset_pin={"pca9554": None, "number": 1}, + requires={"pca9554"}, +) + +# The panel reset is wired to the chip reset, so only the software reset is available. +SPD2010.extend( + "SEEED-SENSECAP-WATCHER", + cs_pin={"number": 45, "ignore_strapping_warning": True}, +) diff --git a/tests/component_tests/mipi_spi/fixtures/spd2010_sensecap_watcher.yaml b/tests/component_tests/mipi_spi/fixtures/spd2010_sensecap_watcher.yaml new file mode 100644 index 00000000000..7cf21ace67a --- /dev/null +++ b/tests/component_tests/mipi_spi/fixtures/spd2010_sensecap_watcher.yaml @@ -0,0 +1,16 @@ +esphome: + name: sensecap-watcher + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +spi: + type: quad + clk_pin: 7 + data_pins: [9, 1, 14, 13] + +display: + - platform: mipi_spi + model: SEEED-SENSECAP-WATCHER diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 65b1b844ae0..de3b9c427b1 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -414,6 +414,23 @@ def test_spd2010_expander_default_generation( assert "mipi_spi_mipispi_id->set_reset_pin(pca9554_pca9554gpiopin_id);" in main_cpp +def test_sensecap_watcher_generation( + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], +) -> None: + """The Watcher model needs no expander, sets CS 45 and resets by software.""" + main_cpp = generate_main(component_fixture_path("spd2010_sensecap_watcher.yaml")) + + assert "pca9554" not in main_cpp + assert "set_reset_pin" not in main_cpp + assert "set_pin(::GPIO_NUM_45);" in main_cpp + assert ( + "mipi_spi_mipispi_id->set_cs_pin(esp32_esp32internalgpiopin_id_2);" in main_cpp + ) + # SWRESET ({1, 0}) is prepended because there is no reset pin. + assert _init_sequence(main_cpp).startswith("{1, 0, 20, 255,") + + def test_spd2010_default_reset_requires_expander() -> None: """A registered pin schema alone cannot satisfy the default expander reference.""" result = validate_config( From dc324dd2cb8c301788979fa3c774f5953511e363 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 1 Nov 2026 21:00:34 +0100 Subject: [PATCH 046/170] [nrf52][zephyr] Fix building LVGL on nRF52 (#21554) Co-authored-by: Claude --- esphome/components/lvgl/__init__.py | 5 ++++ esphome/components/nrf52/__init__.py | 9 ++++-- esphome/components/openthread/__init__.py | 4 +-- esphome/components/zephyr/__init__.py | 14 +++++++++ esphome/components/zephyr/library.py | 21 ++++++++++++- tests/components/lvgl/common.yaml | 8 ----- tests/components/lvgl/lvgl-package.yaml | 2 +- tests/components/lvgl/test.esp32-idf.yaml | 8 +++++ .../components/lvgl/test.nrf52-xiao-ble.yaml | 29 ++++++++++++++++++ .../components/nrf52/test_compile_flags.py | 26 ++++++++++++++++ tests/unit_tests/test_zephyr_library.py | 8 +++++ tests/unit_tests/test_zephyr_prj_conf.py | 30 +++++++++++++++++++ 12 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 tests/components/lvgl/test.nrf52-xiao-ble.yaml create mode 100644 tests/unit_tests/test_zephyr_prj_conf.py diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index dca248cd588..d05bb1436f4 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -30,6 +30,7 @@ from esphome.components.image import ( get_image_metadata, ) from esphome.components.psram import DOMAIN as PSRAM_DOMAIN +from esphome.components.zephyr import zephyr_add_prj_conf_min import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -371,6 +372,10 @@ async def to_code(configs): else: df.add_define("LV_DRAW_BUF_ALIGN", "1") cg.add_library("lvgl/lvgl", LVGL_VERSION) + if CORE.using_zephyr: + # LVGL draws from the main thread, and its 2 KB default overflows while + # rendering + zephyr_add_prj_conf_min("MAIN_STACK_SIZE", 4096) df.add_define("LV_DRAW_BUF_STRIDE_ALIGN", "1") df.add_define("LV_USE_DRAW_SW", "1") # LVGL 9.6 rejects 0 here unless tiled rendering is disabled diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 32203bfbbd5..2f3565dc764 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -1130,9 +1130,11 @@ def _generate_cmake_lists(pch_on: bool) -> bool: # modules and discover them through EXTRA_ZEPHYR_MODULES (a CMake list, set # before find_package(Zephyr) so the modules are picked up). Only # framework-agnostic libraries actually compile under Zephyr. - from esphome.components.zephyr.library import generate_zephyr_modules + from esphome.components.zephyr.library import DEFINES_VAR, generate_zephyr_modules module_dirs = generate_zephyr_modules(list(CORE.platformio_libraries.values())) + defines = [flag for flag in compile_flags if flag.startswith("-D")] + options = [flag for flag in compile_flags if not flag.startswith("-D")] lines = [ "cmake_minimum_required(VERSION 3.20.0)", @@ -1144,6 +1146,9 @@ def _generate_cmake_lists(pch_on: bool) -> bool: if module_dirs: modules = ";".join(str(d).replace("\\", "/") for d in module_dirs) lines += [f'set(EXTRA_ZEPHYR_MODULES "{modules}")', ""] + if defines: + escaped = ";".join(cmake_escape(flag) for flag in defines) + lines += [f'set({DEFINES_VAR} "{escaped}")', ""] lines += [ "find_package(Zephyr REQUIRED)", @@ -1156,8 +1161,6 @@ def _generate_cmake_lists(pch_on: bool) -> bool: 'target_include_directories(app PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../src")', ] - defines = [flag for flag in compile_flags if flag.startswith("-D")] - options = [flag for flag in compile_flags if not flag.startswith("-D")] if defines: lines += [ "", diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 6caa1e004ad..d51fe7f4669 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -33,7 +33,7 @@ from esphome.components.esp32 import ( from esphome.components.mdns import MDNSComponent, enable_mdns_storage from esphome.components.network import add_use_address, request_ipv4_off, require_ipv6 from esphome.components.nrf52.framework import include_west_project -from esphome.components.zephyr import zephyr_add_prj_conf +from esphome.components.zephyr import zephyr_add_prj_conf, zephyr_add_prj_conf_min from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -591,7 +591,7 @@ async def to_code(config: ConfigType) -> None: f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True ) zephyr_add_prj_conf(f"OPENTHREAD_{config.get(CONF_DEVICE_TYPE)}", True) - zephyr_add_prj_conf("MAIN_STACK_SIZE", 4096) + zephyr_add_prj_conf_min("MAIN_STACK_SIZE", 4096) # Actions diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 2e3269e5830..7b50cf6ab77 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -137,6 +137,20 @@ def zephyr_add_prj_conf( prj_conf[name] = (value, required) +def zephyr_add_prj_conf_min(name: str, value: int, image: str = "") -> None: + """Require a numeric zephyr prj conf value of at least ``value``. + + For sizes several components ask for, such as the main stack: the largest + request wins instead of two different requests failing the build. + """ + if not name.startswith("CONFIG_"): + name = "CONFIG_" + name + prj_conf = zephyr_data()[KEY_PRJ_CONF].setdefault(image, {}) + if name in prj_conf: + value = max(value, prj_conf[name][0]) + prj_conf[name] = (value, True) + + def zephyr_add_overlay(content: str, image: str = "") -> None: data = zephyr_data() if image not in data[KEY_OVERLAY]: diff --git a/esphome/components/zephyr/library.py b/esphome/components/zephyr/library.py index d09f0afedbf..354363441df 100644 --- a/esphome/components/zephyr/library.py +++ b/esphome/components/zephyr/library.py @@ -37,6 +37,17 @@ from esphome.platformio.library import ( # framework mismatch warning fires. ZEPHYR_FRAMEWORK = "zephyr" +# Some libraries (LVGL) ship their own zephyr/Kconfig, which can clash with the +# Kconfig Zephyr already has for them. ESPHome configures libraries through build +# flags, so the module points at an empty Kconfig of its own instead. +MODULE_KCONFIG = "zephyr/Kconfig.esphome" + +# Set by the project CMakeLists to the project-wide -D flags. The modules sit in a +# cache shared between projects, so they read the flags from this variable rather +# than holding them. -W flags are left out: a target's own options come before the +# warning flags it inherits from zephyr_interface, so they would have no effect. +DEFINES_VAR = "ESPHOME_LIBRARY_DEFINES" + def _escape(p: PathType) -> str: # In CMakeLists.txt, backslashes and embedded quotes need escaping @@ -53,7 +64,7 @@ def generate_module_yml(component: ConvertedLibrary) -> str: return yaml_util.dump( { "name": component.get_require_name(), - "build": {"cmake": "zephyr"}, + "build": {"cmake": "zephyr", "kconfig": MODULE_KCONFIG}, } ) @@ -138,6 +149,13 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: *[f" {_escape(f)}" for f in build_flags], ")", ] + # Project-wide defines such as LVGL's -DLV_CONF_PATH, as PlatformIO and + # ESP-IDF builds pass them to libraries too + lines += [ + f"if(DEFINED {DEFINES_VAR})", + f" zephyr_library_compile_options(${{{DEFINES_VAR}}})", + "endif()", + ] # Best-effort link wiring; most Zephyr-portable libraries don't need it. link_flags = [f"-L{d}" for d in link_directories] + [ f"-l{lib}" for lib in link_libraries @@ -155,6 +173,7 @@ def generate_cmakelists_txt(component: ConvertedLibrary) -> str: def _emit_zephyr_module(component: ConvertedLibrary) -> None: zephyr_dir = component.path / "zephyr" write_file_if_changed(zephyr_dir / "module.yml", generate_module_yml(component)) + write_file_if_changed(component.path / MODULE_KCONFIG, "") write_file_if_changed( zephyr_dir / "CMakeLists.txt", generate_cmakelists_txt(component) ) diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index 4f99fd585e0..023d4b88b29 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -159,14 +159,6 @@ binary_sensor: id: lv_image src: !lambda if (x) return id(cat_image); else return id(dog_image); -wifi: - ssid: SSID - password: PASSWORD123 - -time: - - platform: sntp - id: sntp_time - text: - id: lvgl_text platform: lvgl diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 314ea7c2008..96b04182373 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1352,7 +1352,7 @@ lvgl: start_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); end_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); rotation: !lambda return (int)((float)rand() / RAND_MAX * 100); - change_rate: !lambda return (uint)((float)rand() / RAND_MAX * 100); + change_rate: !lambda return (uint32_t)((float)rand() / RAND_MAX * 100); mode: NORMAL - bar: id: bar_id diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 1c4f9d813d0..80b837611ff 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -4,6 +4,14 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml lvgl: !include common.yaml +wifi: + ssid: SSID + password: PASSWORD123 + +time: + - platform: sntp + id: sntp_time + sensor: - platform: rotary_encoder name: "Rotary Encoder" diff --git a/tests/components/lvgl/test.nrf52-xiao-ble.yaml b/tests/components/lvgl/test.nrf52-xiao-ble.yaml new file mode 100644 index 00000000000..12c92c49b87 --- /dev/null +++ b/tests/components/lvgl/test.nrf52-xiao-ble.yaml @@ -0,0 +1,29 @@ +packages: + lvgl_package: !include lvgl-package.yaml + spi: !include ../../test_build_components/common/spi/nrf52.yaml + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + lvgl: !include common.yaml + +# No network on this board, so a clock chip stands in for SNTP +time: + - platform: ds1307 + id: sntp_time + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + data_rate: 8MHz + cs_pin: P0.28 + dc_pin: P0.29 + reset_pin: P1.03 + auto_clear_enabled: false + update_interval: never + +lvgl: + displays: tft_display + buffer_size: 12% diff --git a/tests/unit_tests/components/nrf52/test_compile_flags.py b/tests/unit_tests/components/nrf52/test_compile_flags.py index 25fb9e85d1e..c85cad090fd 100644 --- a/tests/unit_tests/components/nrf52/test_compile_flags.py +++ b/tests/unit_tests/components/nrf52/test_compile_flags.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest.mock import patch from esphome.components import nrf52 +from esphome.components.lvgl import lv_conf_path_flag from esphome.components.zephyr import zephyr_to_code from esphome.core import CORE @@ -39,6 +40,31 @@ def test_double_promotion_warning_is_off_for_the_app(tmp_path: Path) -> None: ) in text +def test_project_flags_reach_the_libraries(tmp_path: Path) -> None: + """Libraries such as LVGL find their configuration through a project-wide define.""" + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + # Escaped quotes stay escaped, so CMake reads them as quotes, as for the app + CORE.build_flags.add(lv_conf_path_flag(Path("/build/src/lv_conf.h"))) + CORE.build_flags.add("-Wno-double-promotion") + with ( + patch( + "esphome.components.zephyr.library.generate_zephyr_modules", + return_value=[tmp_path / "lvgl"], + ), + patch.object(nrf52, "get_cmake_link_flags", return_value=[]), + ): + nrf52._generate_cmake_lists(False) + text = (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text() + # Set before find_package(Zephyr), which builds the library modules + before = text.split("find_package(Zephyr REQUIRED)", 1)[0] + # -W flags would come before the warnings inherited from zephyr_interface + assert ( + 'set(ESPHOME_LIBRARY_DEFINES "-DLV_CONF_PATH=\\"/build/src/lv_conf.h\\"")' + ) in before + + def test_build_flags_are_split_and_quoted(tmp_path: Path) -> None: """Entries are split like the other builds do, each flag is quoted as a CMake argument, and the linker flags of one entry stay one group.""" diff --git a/tests/unit_tests/test_zephyr_library.py b/tests/unit_tests/test_zephyr_library.py index 765e505c385..ff9f6ec9657 100644 --- a/tests/unit_tests/test_zephyr_library.py +++ b/tests/unit_tests/test_zephyr_library.py @@ -26,6 +26,8 @@ def test_generate_module_yml_uses_sanitized_name(): # "/" -> "__" and " " -> "_" so it's a valid Zephyr module name. assert "name: owner__My_Lib" in out assert "cmake: zephyr" in out + # The library's own zephyr/Kconfig must not be used. + assert "kconfig: zephyr/Kconfig.esphome" in out def test_generate_cmakelists_txt_basic(tmp_path): @@ -38,6 +40,11 @@ def test_generate_cmakelists_txt_basic(tmp_path): out = generate_cmakelists_txt(c) assert "zephyr_library_named(mylib)" in out + assert ( + "if(DEFINED ESPHOME_LIBRARY_DEFINES)\n" + " zephyr_library_compile_options(${ESPHOME_LIBRARY_DEFINES})\n" + "endif()" + ) in out assert "zephyr_library_sources(" in out # Sources are emitted as absolute paths (CMakeLists lives in zephyr/ subdir), # backslash-escaped for CMake (matching the output on Windows). @@ -125,6 +132,7 @@ def test_generate_zephyr_modules_collects_all_dirs_and_writes(tmp_path, monkeypa for comp in (top, dep): assert (comp.path / "zephyr" / "module.yml").is_file() assert (comp.path / "zephyr" / "CMakeLists.txt").is_file() + assert (comp.path / "zephyr" / "Kconfig.esphome").read_text() == "" def test_generate_zephyr_modules_errors_on_duplicate_module_name(tmp_path, monkeypatch): diff --git a/tests/unit_tests/test_zephyr_prj_conf.py b/tests/unit_tests/test_zephyr_prj_conf.py new file mode 100644 index 00000000000..5e7f5b72cab --- /dev/null +++ b/tests/unit_tests/test_zephyr_prj_conf.py @@ -0,0 +1,30 @@ +"""Tests for the prj.conf helpers in the Zephyr component.""" + +import pytest + +from esphome.components.zephyr import zephyr_add_prj_conf, zephyr_add_prj_conf_min +from esphome.components.zephyr.const import KEY_PRJ_CONF, KEY_ZEPHYR +from esphome.core import CORE + + +@pytest.fixture(autouse=True) +def clean_zephyr_data() -> None: + CORE.data[KEY_ZEPHYR] = {KEY_PRJ_CONF: {}} + + +def _prj_conf() -> dict: + return CORE.data[KEY_ZEPHYR][KEY_PRJ_CONF][""] + + +def test_min_overrides_an_optional_default() -> None: + zephyr_add_prj_conf("MAIN_STACK_SIZE", 2048, required=False) + zephyr_add_prj_conf_min("MAIN_STACK_SIZE", 8192) + + assert _prj_conf()["CONFIG_MAIN_STACK_SIZE"] == (8192, True) + + +def test_largest_min_wins_in_any_order() -> None: + zephyr_add_prj_conf_min("MAIN_STACK_SIZE", 8192) + zephyr_add_prj_conf_min("MAIN_STACK_SIZE", 4096) + + assert _prj_conf()["CONFIG_MAIN_STACK_SIZE"] == (8192, True) From 3bd76e11761d9892e3d7009ab5ca286eb9c0509f Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 1 Nov 2026 21:01:22 +0100 Subject: [PATCH 047/170] [nrf52] Warn when the Adafruit bootloader is older than 0.11.0 (#22119) Co-authored-by: Claude --- esphome/components/logger/logger.cpp | 3 +++ esphome/components/logger/logger.h | 3 +++ esphome/components/logger/logger_zephyr.cpp | 28 +++++++++++++++++++++ esphome/components/nrf52/__init__.py | 10 +++++--- esphome/core/defines.h | 1 + 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 6329a363c60..ea55ab32a6f 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -343,6 +343,9 @@ void Logger::dump_config() { #endif #ifdef USE_ZEPHYR dump_crash_(); +#ifdef USE_BOOTLOADER_ADAFRUIT + this->dump_bootloader_(); +#endif if (!device_is_ready(this->uart_dev_)) { ESP_LOGE(TAG, " %s is not ready.", LOG_STR_ARG(get_uart_selection_())); } diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index e34c24cef79..05f1849a943 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -345,6 +345,9 @@ class Logger final : public Component { #endif #if defined(USE_ZEPHYR) void dump_crash_(); +#ifdef USE_BOOTLOADER_ADAFRUIT + void dump_bootloader_(); +#endif const device *uart_dev_{nullptr}; #endif #if defined(USE_ESP32) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index c74943940c8..37af3a9b822 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -14,6 +14,9 @@ #ifdef USE_LOGGER_EARLY_MESSAGE #include #endif +#ifdef USE_BOOTLOADER_ADAFRUIT +#include +#endif namespace esphome::logger { @@ -171,6 +174,31 @@ static const char *reason_to_str(unsigned int reason, char *buf) { } } +#ifdef USE_BOOTLOADER_ADAFRUIT +static constexpr unsigned ADAFRUIT_BOOTLOADER_MIN_MAJOR = 0; +static constexpr unsigned ADAFRUIT_BOOTLOADER_MIN_MINOR = 11; +static constexpr unsigned ADAFRUIT_BOOTLOADER_MIN_PATCH = 0; +// Adafruit_nRF52_Bootloader passes its version to the app as 0x00MMmmpp (major, minor, patch) +static constexpr uint32_t ADAFRUIT_BOOTLOADER_MIN_VERSION = + (ADAFRUIT_BOOTLOADER_MIN_MAJOR << 16) | (ADAFRUIT_BOOTLOADER_MIN_MINOR << 8) | ADAFRUIT_BOOTLOADER_MIN_PATCH; + +void Logger::dump_bootloader_() { + // NOLINTNEXTLINE(clang-analyzer-core.FixedAddressDereference) -- NRF_TIMER2 is MMIO at a fixed address + const uint32_t version = NRF_TIMER2->CC[0]; + if (version >= ADAFRUIT_BOOTLOADER_MIN_VERSION) + return; + // Releases too old to pass their version leave the register at its reset value + char version_buf[sizeof("255.255.255")] = "unknown"; + if (version != 0) { + snprintf(version_buf, sizeof(version_buf), "%u.%u.%u", static_cast((version >> 16) & 0xFF), + static_cast((version >> 8) & 0xFF), static_cast(version & 0xFF)); + } + ESP_LOGW(TAG, + "Adafruit_nRF52_Bootloader version %s has known stability issues. Please update it to %u.%u.%u or newer.", + version_buf, ADAFRUIT_BOOTLOADER_MIN_MAJOR, ADAFRUIT_BOOTLOADER_MIN_MINOR, ADAFRUIT_BOOTLOADER_MIN_PATCH); +} +#endif + void Logger::dump_crash_() { ESP_LOGD(TAG, "Crash buffer address %p", &crash_buf); if (crash_buf.magic == App.get_config_hash()) { diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2f3565dc764..7d5295fca59 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -203,15 +203,15 @@ def set_framework(config: ConfigType) -> ConfigType: )(config) -# Bootloaders flashed over USB serial DFU: the Adafruit ones with adafruit-nrfutil, the factory Open -# DFU bootloader with Nordic's nrfutil; mcuboot uses mcumgr instead -SERIAL_DFU_BOOTLOADERS = ( +ADAFRUIT_BOOTLOADERS = ( BOOTLOADER_ADAFRUIT, BOOTLOADER_ADAFRUIT_NRF52_SD132, BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, - BOOTLOADER_NRF, ) +# Bootloaders flashed over USB serial DFU: the Adafruit ones with adafruit-nrfutil, the factory Open +# DFU bootloader with Nordic's nrfutil; mcuboot uses mcumgr instead +SERIAL_DFU_BOOTLOADERS = (*ADAFRUIT_BOOTLOADERS, BOOTLOADER_NRF) BOOTLOADERS = [*SERIAL_DFU_BOOTLOADERS, BOOTLOADER_MCUBOOT] @@ -462,6 +462,8 @@ async def to_code(config: ConfigType) -> None: if (len(bootloader)) > 3: sd_version = bootloader[3][1:] cg.add_define("USE_SOFTDEVICE_VERSION", int(sd_version)) + if config[KEY_BOOTLOADER] in ADAFRUIT_BOOTLOADERS: + cg.add_define("USE_BOOTLOADER_ADAFRUIT") zephyr_setup_preferences() zephyr_to_code(config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index db0656f7f0e..9e4af419aea 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -850,6 +850,7 @@ #endif #ifdef USE_NRF52 +#define USE_BOOTLOADER_ADAFRUIT #define ESPHOME_BLE_NUS_TX_RING_BUFFER_SIZE 512 #define ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE 512 #define ESPHOME_SUSPEND_LOOP From 45116a8f3e473aebab6f6f2acf9d3edd7da36a07 Mon Sep 17 00:00:00 2001 From: Craig Andrews Date: Sun, 1 Nov 2026 15:06:34 -0500 Subject: [PATCH 048/170] [epaper_spi] - Add support for the XSRUPB-7.5in-bwr panel (#19654) Co-authored-by: Scott Gusler <812172+mutilator@users.noreply.github.com> --- esphome/components/epaper_spi/colorconv.h | 30 +- .../components/epaper_spi/epaper_spi_4bpp.cpp | 5 - .../components/epaper_spi/epaper_spi_4bpp.h | 3 +- .../components/epaper_spi/epaper_spi_bwr.cpp | 102 +++++ .../components/epaper_spi/epaper_spi_bwr.h | 46 +++ .../epaper_spi/epaper_spi_inkplate2.cpp | 111 ----- .../epaper_spi/epaper_spi_inkplate2.h | 16 +- .../epaper_spi/epaper_waveshare_b.h | 5 +- .../epaper_spi/epaper_waveshare_bwr.cpp | 117 ------ .../epaper_spi/epaper_waveshare_bwr.h | 25 +- .../components/epaper_spi/epaper_weact_3c.cpp | 182 +-------- .../components/epaper_spi/epaper_weact_3c.h | 23 +- .../components/epaper_spi/models/inkplate2.py | 2 +- .../components/epaper_spi/models/uc8151.py | 2 +- .../epaper_spi/models/waveshare_b.py | 1 + .../epaper_spi/models/waveshare_bwr.py | 61 ++- .../components/epaper_spi/models/weact_bwr.py | 8 +- tests/component_tests/epaper_spi/test_init.py | 58 +++ .../epaper_spi/display/test_bwr.cpp | 385 ++++++++++++++++++ .../display/test_bwr_same_bytes.cpp | 179 ++++++++ .../epaper_spi/display/test_colorconv_bwr.cpp | 53 +++ .../display/test_weact_3c_transfer.cpp | 165 ++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 20 + 23 files changed, 1138 insertions(+), 461 deletions(-) create mode 100644 esphome/components/epaper_spi/epaper_spi_bwr.cpp create mode 100644 esphome/components/epaper_spi/epaper_spi_bwr.h create mode 100644 tests/components/epaper_spi/display/test_bwr.cpp create mode 100644 tests/components/epaper_spi/display/test_bwr_same_bytes.cpp create mode 100644 tests/components/epaper_spi/display/test_colorconv_bwr.cpp create mode 100644 tests/components/epaper_spi/display/test_weact_3c_transfer.cpp diff --git a/esphome/components/epaper_spi/colorconv.h b/esphome/components/epaper_spi/colorconv.h index cb59538abab..d701fecee07 100644 --- a/esphome/components/epaper_spi/colorconv.h +++ b/esphome/components/epaper_spi/colorconv.h @@ -23,6 +23,13 @@ using display::Rgb565ToMono; /** Delta for when to regard as gray */ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50; +/** Native color keys for black/white/red panels, for use with color_to_bwr */ +enum class BwrColor : uint8_t { + BWR_COLOR_BLACK, + BWR_COLOR_WHITE, + BWR_COLOR_RED, +}; + /** Map RGB color to one of 4 discrete gray levels (2 bits per pixel) * * @param color RGB color to convert from @@ -78,8 +85,8 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ /** Map RGB color to discrete BWR (black/white/red) 3 color key * - * Convenience wrapper over color_to_bwyr for panels without a yellow ink; the yellow corner is - * folded into white. + * Grays map to black or white by luma; other colors map to whichever of black, white or red is + * nearest in RGB space, so e.g. orange renders as red rather than being folded into white. * * @tparam NATIVE_COLOR Type of native hardware color values * @param color RGB color to convert from @@ -90,7 +97,24 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_ */ template constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_red) { - return color_to_bwyr(color, hw_black, hw_white, /*hw_yellow=*/hw_white, hw_red); + const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b}); + + if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) { + return color_to_mono(color) ? hw_white : hw_black; + } + + const int r = color.r; + const int g = color.g; + const int b = color.b; + const int d_black = r * r + g * g + b * b; + const int d_white = (255 - r) * (255 - r) + (255 - g) * (255 - g) + (255 - b) * (255 - b); + const int d_red = (255 - r) * (255 - r) + g * g + b * b; + + if (d_red < d_white && d_red < d_black) { + return hw_red; + } + // Ties (e.g. yellow) go to white + return d_white <= d_black ? hw_white : hw_black; } /** Map RGB color to discrete BWYRGB hex 6 color key diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp index 1eb53dd2b52..b7f68b2b709 100644 --- a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp @@ -21,11 +21,6 @@ void EPaper4bpp::fill(Color color) { this->mark_all_drawn_(); } -void EPaper4bpp::clear() { - // clear buffer to white, just like real paper. - this->fill(COLOR_ON); -} - void HOT EPaper4bpp::draw_pixel_at(int x, int y, Color color) { if (!this->rotate_coordinates_(x, y)) return; diff --git a/esphome/components/epaper_spi/epaper_spi_4bpp.h b/esphome/components/epaper_spi/epaper_spi_4bpp.h index 3843752f858..4fe89e7f27d 100644 --- a/esphome/components/epaper_spi/epaper_spi_4bpp.h +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.h @@ -7,7 +7,7 @@ namespace esphome::epaper_spi { /** * Intermediate base for panels with a 4-bit-per-pixel native buffer layout (2 pixels per byte). * - * Owns buffer sizing, fill()/clear()/draw_pixel_at() and the chunked SPI transfer loop shared by + * Owns buffer sizing, fill()/draw_pixel_at() and the chunked SPI transfer loop shared by * this family of controllers. Concrete subclasses supply only their RGB -> 4-bit color mapping via * color_to_native() plus their IC-specific power/refresh/sleep command sequences. */ @@ -18,7 +18,6 @@ class EPaper4bpp : public EPaperBase { : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 4) {} void fill(Color color) override; - void clear() override; protected: void draw_pixel_at(int x, int y, Color color) override; diff --git a/esphome/components/epaper_spi/epaper_spi_bwr.cpp b/esphome/components/epaper_spi/epaper_spi_bwr.cpp new file mode 100644 index 00000000000..29db7a588fd --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_bwr.cpp @@ -0,0 +1,102 @@ +#include "epaper_spi_bwr.h" + +#include +#include + +#include "colorconv.h" + +namespace esphome::epaper_spi { + +namespace { +// Whether a pixel of this color sets its bit in the black/white plane +bool sets_bw_bit(BwrColor bwr, bool red_sets_black) { + return bwr == BwrColor::BWR_COLOR_BLACK || (bwr == BwrColor::BWR_COLOR_RED && red_sets_black); +} +} // namespace + +void HOT EPaperBWR::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) + return; + + const uint32_t pos = (x / 8) + (y * this->row_width_); + const uint8_t bit = 0x80 >> (x & 0x07); + const uint32_t red_offset = this->buffer_length_ / 2u; + + const auto bwr = + color_to_bwr(color, BwrColor::BWR_COLOR_BLACK, BwrColor::BWR_COLOR_WHITE, BwrColor::BWR_COLOR_RED); + + assign_bits(this->buffer_[pos], bit, sets_bw_bit(bwr, this->red_sets_black_)); + assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrColor::BWR_COLOR_RED); +} + +void EPaperBWR::fill(Color color) { + if (this->get_clipping().is_set()) { + EPaperBase::fill(color); + return; + } + + const size_t half_buffer = this->buffer_length_ / 2u; + const auto bwr = + color_to_bwr(color, BwrColor::BWR_COLOR_BLACK, BwrColor::BWR_COLOR_WHITE, BwrColor::BWR_COLOR_RED); + + this->fill_range_(0, half_buffer, sets_bw_bit(bwr, this->red_sets_black_) ? 0xFF : 0x00); + this->fill_range_(half_buffer, this->buffer_length_, bwr == BwrColor::BWR_COLOR_RED ? 0xFF : 0x00); + + this->mark_all_drawn_(); +} + +void EPaperBWR::fill_range_(size_t index, size_t end, uint8_t value) { + while (index != end) { + size_t length; + uint8_t *data = this->buffer_.get_span(index, length); + length = std::min(length, end - index); + memset(data, value, length); + index += length; + } +} + +bool HOT EPaperBWR::send_buffer_range_(size_t end, uint8_t invert_mask, uint32_t start_time) { + uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; + this->start_data_(); + while (this->current_data_index_ < end) { + const size_t chunk = std::min(MAX_TRANSFER_SIZE, end - this->current_data_index_); + // The chunk may cross from one SplitBuffer part to the next, so copy it a span at a time + for (size_t filled = 0; filled != chunk;) { + size_t length; + const uint8_t *data = this->buffer_.get_span(this->current_data_index_ + filled, length); + length = std::min(length, chunk - filled); + for (size_t i = 0; i != length; i++) + bytes_to_send[filled + i] = data[i] ^ invert_mask; + filled += length; + } + this->write_array(bytes_to_send, chunk); + this->current_data_index_ += chunk; + if (this->current_data_index_ < end && millis() - start_time > MAX_TRANSFER_TIME) { + this->disable(); + return false; // yield; resume next loop + } + } + this->disable(); + return true; +} + +bool HOT EPaperBWR::transfer_data() { + const uint32_t start_time = millis(); + const size_t half_buffer = this->buffer_length_ / 2u; + + // Black/white plane (first half, always inverted) then red plane (second half, inverted if invert_red_). + if (this->current_data_index_ == 0) + this->command(0x10); + if (this->current_data_index_ < half_buffer && !this->send_buffer_range_(half_buffer, 0xFF, start_time)) + return false; + + if (this->current_data_index_ == half_buffer) + this->command(0x13); + if (!this->send_buffer_range_(this->buffer_length_, this->invert_red_ ? 0xFF : 0x00, start_time)) + return false; + + this->current_data_index_ = 0; + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_bwr.h b/esphome/components/epaper_spi/epaper_spi_bwr.h new file mode 100644 index 00000000000..a5f42d1e8af --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_bwr.h @@ -0,0 +1,46 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +/** + * Intermediate base for black/white/red panels with two 1-bit-per-pixel planes (8 pixels per byte). + * + * Provides fill(), draw_pixel_at() and the chunked SPI transfer of both planes: + * - Buffer first half: Black/White plane (1=black, 0=white or red; 1=black or red when red_sets_black is set), + * always sent inverted + * - Buffer second half: Red plane (1=red, 0=no red), inverted when invert_red is set + * The default transfer_data() sends the black/white plane with 0x10, then the red plane with 0x13. Subclasses + * supply their IC-specific power/refresh/sleep commands, and may override transfer_data() for other commands or + * plane order, using send_buffer_range_() to send each plane. + */ +class EPaperBWR : public EPaperBase { + public: + EPaperBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length, bool invert_red, bool red_sets_black = false) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 1, 2), + invert_red_(invert_red), + red_sets_black_(red_sets_black) {} + + void fill(Color color) override; + + protected: + void draw_pixel_at(int x, int y, Color color) override; + bool transfer_data() override; + + // Streams buffer_[current_data_index_ .. end) XORed with invert_mask in chunks; returns false if it yields on + // MAX_TRANSFER_TIME. Chip select is held for the whole range and released only on yield or completion. + // It only yields with data left to send, never at end: callers rely on this to tell from current_data_index_ + // alone which plane a resumed push is in, and that a plane has just finished. + bool send_buffer_range_(size_t end, uint8_t invert_mask, uint32_t start_time); + // Sets buffer_[index .. end) to value, one contiguous span at a time. + void fill_range_(size_t index, size_t end, uint8_t value); + + // Send the red plane as 0=red, 1=no red + bool invert_red_; + // Red pixels also set their bit in the black/white plane, for panels that need black under red + bool red_sets_black_; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp index 15bf33de600..bc843e9a8f6 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp @@ -1,20 +1,12 @@ // Reference: https://github.com/SolderedElectronics/Inkplate-Arduino-library (src/boards/Inkplate2) #include "epaper_spi_inkplate2.h" -#include "colorconv.h" #include "esphome/core/log.h" namespace esphome::epaper_spi { ESPHOME_LOG_TAG(TAG, "epaper_spi.inkplate2"); -// Map RGB to the panel's black/white/red via the shared converter. -enum class Inkplate2Color : uint8_t { BLACK, WHITE, RED }; - -static Inkplate2Color to_inkplate2_color(Color color) { - return color_to_bwr(color, Inkplate2Color::BLACK, Inkplate2Color::WHITE, Inkplate2Color::RED); -} - void EPaperInkplate2::power_on() { // Power-on (0x04) leads the init sequence, so there is nothing to do here. ESP_LOGV(TAG, "Power on"); @@ -39,107 +31,4 @@ void EPaperInkplate2::deep_sleep() { this->cmd_data(0x07, {0xA5}); } -void EPaperInkplate2::fill(Color color) { - if (this->get_clipping().is_set()) { - EPaperBase::fill(color); // clipping active: defer to the base per-pixel path - return; - } - - const size_t half_buffer = this->buffer_length_ / 2; - - // Plane encoding: B/W plane 1=white, 0=black; red plane 0=red, 1=no-red. - uint8_t bw_byte; - uint8_t red_byte; - switch (to_inkplate2_color(color)) { - case Inkplate2Color::BLACK: - bw_byte = 0x00; - red_byte = 0xFF; - break; - case Inkplate2Color::RED: - bw_byte = 0xFF; - red_byte = 0x00; - break; - case Inkplate2Color::WHITE: - default: - bw_byte = 0xFF; - red_byte = 0xFF; - break; - } - - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[i] = bw_byte; - for (size_t i = half_buffer; i < this->buffer_length_; i++) - this->buffer_[i] = red_byte; - - this->mark_all_drawn_(); -} - -void EPaperInkplate2::clear() { this->fill(COLOR_ON); } - -void HOT EPaperInkplate2::draw_pixel_at(int x, int y, Color color) { - if (!this->rotate_coordinates_(x, y)) - return; - - const size_t half_buffer = this->buffer_length_ / 2; - const size_t pos = y * this->row_width_ + x / 8; - const uint8_t mask = 0x80 >> (x & 0x07); // MSB first; see fill() for plane encoding - - switch (to_inkplate2_color(color)) { - case Inkplate2Color::BLACK: - this->buffer_[pos] &= ~mask; - this->buffer_[pos + half_buffer] |= mask; - break; - case Inkplate2Color::RED: - this->buffer_[pos] |= mask; - this->buffer_[pos + half_buffer] &= ~mask; - break; - case Inkplate2Color::WHITE: - default: - this->buffer_[pos] |= mask; - this->buffer_[pos + half_buffer] |= mask; - break; - } -} - -bool HOT EPaperInkplate2::send_buffer_range_(size_t end, uint32_t start_time) { - uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; - size_t buf_idx = 0; - while (this->current_data_index_ < end) { - bytes_to_send[buf_idx++] = this->buffer_[this->current_data_index_++]; - if (buf_idx == sizeof bytes_to_send) { - this->start_data_(); - this->write_array(bytes_to_send, buf_idx); - this->disable(); - buf_idx = 0; - if (millis() - start_time > MAX_TRANSFER_TIME) - return false; // yield; resume next loop - } - } - if (buf_idx != 0) { - this->start_data_(); - this->write_array(bytes_to_send, buf_idx); - this->disable(); - } - return true; -} - -bool HOT EPaperInkplate2::transfer_data() { - const uint32_t start_time = millis(); - const size_t half_buffer = this->buffer_length_ / 2; - - // Black/white plane (first half) then red plane (second half). - if (this->current_data_index_ == 0) - this->command(0x10); - if (this->current_data_index_ < half_buffer && !this->send_buffer_range_(half_buffer, start_time)) - return false; - - if (this->current_data_index_ == half_buffer) - this->command(0x13); - if (!this->send_buffer_range_(this->buffer_length_, start_time)) - return false; - - this->current_data_index_ = 0; - return true; -} - } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.h b/esphome/components/epaper_spi/epaper_spi_inkplate2.h index 09926db4ad8..a3f9a329c90 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate2.h +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.h @@ -1,30 +1,22 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_bwr.h" namespace esphome::epaper_spi { // Soldered Inkplate 2: 104x212 black/white/red (BWR) e-paper, UC8xxx-family controller. -class EPaperInkplate2 final : public EPaperBase { +// The init sequence sets DDX=11, so the red plane is sent inverted. +class EPaperInkplate2 : public EPaperBWR { public: EPaperInkplate2(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_COLOR, 1, 2) {} - - void fill(Color color) override; - void clear() override; - void draw_pixel_at(int x, int y, Color color) override; + : EPaperBWR(name, width, height, init_sequence, init_sequence_length, /*invert_red=*/true) {} protected: void refresh_screen(bool partial) override; void power_on() override; void power_off() override; void deep_sleep() override; - - bool transfer_data() override; - - // Streams buffer_[current_data_index_ .. end) in chunks; returns false if it yields on MAX_TRANSFER_TIME. - bool send_buffer_range_(size_t end, uint32_t start_time); }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_b.h b/esphome/components/epaper_spi/epaper_waveshare_b.h index 3a391731d8a..b4e5d391363 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_b.h +++ b/esphome/components/epaper_spi/epaper_waveshare_b.h @@ -9,11 +9,12 @@ namespace esphome::epaper_spi { */ class EpaperWaveshareB : public EPaperWeAct3C { public: - using EPaperWeAct3C::EPaperWeAct3C; + EpaperWaveshareB(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperWeAct3C(name, width, height, init_sequence, init_sequence_length, /*invert_red=*/true) {} protected: bool reset() override; - uint8_t transform_red_byte(uint8_t byte) const override { return static_cast(~byte); } }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp index ae77dd096a4..0aad33c8188 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.cpp @@ -1,124 +1,7 @@ #include "epaper_waveshare_bwr.h" -#include "esphome/core/helpers.h" - -#include namespace esphome::epaper_spi { -enum class BwrState : uint8_t { - BWR_BLACK, - BWR_WHITE, - BWR_RED, -}; - -static BwrState color_to_bwr(Color color) { - if (color.r > color.g + color.b && color.r > 127) { - return BwrState::BWR_RED; - } - if (color.r + color.g + color.b >= 382) { - return BwrState::BWR_WHITE; - } - return BwrState::BWR_BLACK; -} - -// 3-color display buffer layout (UC8179 and UC8151D): -// - 1 bit per pixel, 8 pixels per byte -// - Buffer first half: Black/White plane (1=black, 0=white) -// - Buffer second half: Red plane (1=red, 0=white) -// - Total: row_width * height * 2 bytes - -void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) { - if (!this->rotate_coordinates_(x, y)) - return; - - const uint32_t pos = (x / 8) + (y * this->row_width_); - const uint8_t bit = 0x80 >> (x & 0x07); - const uint32_t red_offset = this->buffer_length_ / 2u; - - const auto bwr = color_to_bwr(color); - - assign_bits(this->buffer_[pos], bit, bwr == BwrState::BWR_BLACK); - - assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrState::BWR_RED); -} - -void EPaperWaveshareBWR::fill(Color color) { - const size_t half_buffer = this->buffer_length_ / 2u; - const auto bwr = color_to_bwr(color); - - if (bwr == BwrState::BWR_BLACK) { - // Black plane: 0xFF (black), Red plane: 0x00 (no red) - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[i] = 0xFF; - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[half_buffer + i] = 0x00; - } else if (bwr == BwrState::BWR_RED) { - // Black plane: 0x00 (no black), Red plane: 0xFF (red) - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[i] = 0x00; - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[half_buffer + i] = 0xFF; - } else { - // Black plane: 0x00 (no black), Red plane: 0x00 (no red) - this->buffer_.fill(0x00); - } -} - -bool HOT EPaperWaveshareBWR::transfer_data() { - const uint32_t start_time = millis(); - const size_t buffer_length = this->buffer_length_; - const size_t half_buffer = buffer_length / 2u; - - uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; - - // Phase 1: send Black/White plane (first half) via command 0x10 (DTM1) - // DTM1 (0x10): inverted to get 0=black, 1=white - if (this->current_data_index_ < half_buffer) { - if (this->current_data_index_ == 0) { - this->command(0x10); // DATA START TRANSMISSION 1 (black channel) - } - this->start_data_(); - while (this->current_data_index_ < half_buffer) { - const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); - for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = ~this->buffer_[this->current_data_index_ + i]; - } - this->write_array(bytes_to_send, bytes_to_copy); - this->current_data_index_ += bytes_to_copy; - if (millis() - start_time > MAX_TRANSFER_TIME) { - this->disable(); - return false; - } - } - this->disable(); - } - - // Phase 2: send Red plane (second half) via command 0x13 (DTM2) - // DTM2 (0x13): 1=red, 0=white; on the UC8151D this relies on the 0x50 value in uc8151.py - if (this->current_data_index_ < buffer_length) { - if (this->current_data_index_ == half_buffer) { - this->command(0x13); // DATA START TRANSMISSION 2 (red channel) - } - this->start_data_(); - while (this->current_data_index_ < buffer_length) { - const size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, buffer_length - this->current_data_index_); - for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = this->buffer_[this->current_data_index_ + i]; - } - this->write_array(bytes_to_send, bytes_to_copy); - this->current_data_index_ += bytes_to_copy; - if (millis() - start_time > MAX_TRANSFER_TIME) { - this->disable(); - return false; - } - } - this->disable(); - } - - this->current_data_index_ = 0; - return true; -} - void EPaperWaveshareBWR::power_on() { this->command(0x04); // POWER ON } diff --git a/esphome/components/epaper_spi/epaper_waveshare_bwr.h b/esphome/components/epaper_spi/epaper_waveshare_bwr.h index f5cbb5fde47..52c0abd804b 100644 --- a/esphome/components/epaper_spi/epaper_waveshare_bwr.h +++ b/esphome/components/epaper_spi/epaper_waveshare_bwr.h @@ -1,34 +1,29 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_bwr.h" namespace esphome::epaper_spi { /** - * Waveshare 3-color e-paper displays (UC8179 and UC8151D controllers). - * Supports: 7.5" V2 BWR (EPD_7in5b_V2), 800x480 pixels; 2.13" B V3 (EPD_2in13b_V3), 104x212 pixels. + * 3-color e-paper displays using the UC8179 and UC8151D controllers. + * Supports: Waveshare 7.5" V2 BWR (EPD_7in5b_V2), 800x480 pixels; Waveshare 2.13" B V3 (EPD_2in13b_V3), + * 104x212 pixels; XSRUPB DEPG0750RWF86BF30 7.5" BWR, 800x480 pixels. * - * Color scheme: Black, White, Red (BWR) - * Buffer layout: 1 bit per pixel, separate planes - * - Buffer first half: Black/White plane (1=black, 0=white) - * - Buffer second half: Red plane (1=red, 0=no red) - * - Total buffer: width * height / 4 bytes (2 * width * height / 8) + * Buffer layout and data transfer: see EPaperBWR. + * The red plane is sent as is (1=red) unless invert_red is set; on the UC8151D this relies on the 0x50 value in + * uc8151.py, and the XSRUPB panel's init sequence sets DDX=11, which needs invert_red. * * The init sequence (INITIALISE state) sends the power and panel configuration. * Power-on (0x04) is sent in the POWER_ON state after data transfer; * the state machine then busy-waits before triggering REFRESH_SCREEN (0x12). */ -class EPaperWaveshareBWR : public EPaperBase { +class EPaperWaveshareBWR : public EPaperBWR { public: EPaperWaveshareBWR(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} - - void fill(Color color) override; + size_t init_sequence_length, bool invert_red = false) + : EPaperBWR(name, width, height, init_sequence, init_sequence_length, invert_red) {} protected: - void draw_pixel_at(int x, int y, Color color) override; - bool transfer_data() override; void refresh_screen(bool partial) override; void power_on() override; void power_off() override; diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index 90cf6af7b2f..56f101c5e06 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -1,99 +1,11 @@ #include "epaper_weact_3c.h" + #include "esphome/core/log.h" namespace esphome::epaper_spi { ESPHOME_LOG_TAG(TAG, "epaper_weact_3c"); -enum class BwrState : uint8_t { - BWR_BLACK, - BWR_WHITE, - BWR_RED, -}; - -static BwrState color_to_bwr(Color color) { - if (color.r > color.g + color.b && color.r > 127) { - return BwrState::BWR_RED; - } - if (color.r + color.g + color.b >= 382) { - return BwrState::BWR_WHITE; - } - return BwrState::BWR_BLACK; -} -// SSD1680 3-color display notes: -// - Buffer uses 1 bit per pixel, 8 pixels per byte -// - Buffer first half (black_offset): Black/White plane (0=black, 1=white) -// - Buffer second half (red_offset): Red plane (1=red, 0=no red) -// - Total buffer: width * height / 4 bytes = 2 * (width * height / 8) -// - For 128x296: 128*296/4 = 9472 bytes total (4736 per color) - -void EPaperWeAct3C::draw_pixel_at(int x, int y, Color color) { - if (!this->rotate_coordinates_(x, y)) - return; - - // Calculate position in the 1-bit buffer - const uint32_t pos = (x / 8) + (y * this->row_width_); - const uint8_t bit = 0x80 >> (x & 0x07); - const uint32_t red_offset = this->buffer_length_ / 2u; - - // Use luminance threshold for B/W mapping - // Split at halfway point (382 = (255*3)/2) - auto bwr = color_to_bwr(color); - - // Update black/white plane (first half of buffer) - if (bwr == BwrState::BWR_WHITE) { - // White pixel - set bit in black plane - this->buffer_[pos] |= bit; - } else { - // Black pixel - clear bit in black plane - this->buffer_[pos] &= ~bit; - } - - // Update red plane (second half of buffer) - // Red if red component is dominant (r > g+b) - if (bwr == BwrState::BWR_RED) { - // Red pixel - set bit in red plane - this->buffer_[red_offset + pos] |= bit; - } else { - // Not red - clear bit in red plane - this->buffer_[red_offset + pos] &= ~bit; - } -} - -void EPaperWeAct3C::fill(Color color) { - // For 3-color e-paper with 1-bit buffer format: - // - Black buffer: 1=black, 0=white - // - Red buffer: 1=red, 0=no red - // The buffer is stored as two halves: [black plane][red plane] - const size_t half_buffer = this->buffer_length_ / 2u; - - // Use luminance threshold for B/W mapping - auto bits = color_to_bwr(color); - - // Fill both planes - if (bits == BwrState::BWR_BLACK) { - // Black - both planes = 0x00 - this->buffer_.fill(0x00); - } else if (bits == BwrState::BWR_RED) { - // Red - black plane = 0x00, red plane = 0xFF - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[i] = 0x00; - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[half_buffer + i] = 0xFF; - } else { - // White - black plane = 0xFF, red plane = 0x00 - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[i] = 0xFF; - for (size_t i = 0; i < half_buffer; i++) - this->buffer_[half_buffer + i] = 0x00; - } -} - -void EPaperWeAct3C::clear() { - // Clear buffer to white, just like real paper. - this->fill(COLOR_ON); -} - void EPaperWeAct3C::set_window_() { // For full screen refresh, we always start from (0,0) // The y_low_/y_high_ values track the dirty region for optimization, @@ -120,83 +32,27 @@ void EPaperWeAct3C::set_window_() { bool HOT EPaperWeAct3C::transfer_data() { const uint32_t start_time = millis(); - const size_t buffer_length = this->buffer_length_; - const size_t half_buffer = buffer_length / 2u; + const size_t half_buffer = this->buffer_length_ / 2u; - ESP_LOGV(TAG, "transfer_data: buffer_length=%u, half_buffer=%u", buffer_length, half_buffer); - - // Use a local buffer for SPI transfers - uint8_t bytes_to_send[MAX_TRANSFER_SIZE]; - - // First, send the RED buffer (0x26 = WRITE_COLOR) - // The red plane is in the second half of our buffer - // NOTE: Must set RAM window first to reset address counters! - if (this->current_data_index_ < half_buffer) { - if (this->current_data_index_ == 0) { - ESP_LOGV(TAG, "transfer_data: sending RED buffer (0x26)"); - this->set_window_(); // Reset RAM X/Y counters to start position - this->command(0x26); - } - - this->start_data_(); - size_t red_offset = half_buffer; - while (this->current_data_index_ < half_buffer) { - size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, half_buffer - this->current_data_index_); - - for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = this->transform_red_byte(this->buffer_[red_offset + this->current_data_index_ + i]); - } - - this->write_array(bytes_to_send, bytes_to_copy); - - this->current_data_index_ += bytes_to_copy; - - if (millis() - start_time > MAX_TRANSFER_TIME) { - // Let the main loop run and come back next loop - this->disable(); - return false; - } - } - this->disable(); + // current_data_index_ is the position in buffer_: 0 before the push starts, then the red plane + // [half_buffer, buffer_length_), sent first, then the black/white plane (0, half_buffer). + // A push only yields after sending some data, so it never stops at exactly 0 or half_buffer. + if (this->current_data_index_ == 0) { + ESP_LOGV(TAG, "transfer_data: sending RED buffer (0x26)"); + this->set_window_(); // Reset RAM X/Y counters to start position + this->command(0x26); + this->current_data_index_ = half_buffer; } - - // Finished the red buffer, now send the BLACK buffer (0x24 = WRITE_BLACK) - // The black plane is in the first half of our buffer - if (this->current_data_index_ < buffer_length) { - if (this->current_data_index_ == half_buffer) { - ESP_LOGV(TAG, "transfer_data: finished red buffer, sending BLACK buffer (0x24)"); - - // Do NOT reset RAM counters here for WeAct displays (Reference implementation behavior) - // this->set_window(); - this->command(0x24); - // Continue using current_data_index_, but we need to map it to the start of the buffer - } - - this->start_data_(); - while (this->current_data_index_ < buffer_length) { - size_t remaining = buffer_length - this->current_data_index_; - size_t bytes_to_copy = std::min(MAX_TRANSFER_SIZE, remaining); - - // Calculate offset into the BLACK buffer (which is at the start of this->buffer_) - // current_data_index_ goes from half_buffer to buffer_length - size_t buffer_offset = this->current_data_index_ - half_buffer; - - for (size_t i = 0; i < bytes_to_copy; i++) { - bytes_to_send[i] = this->buffer_[buffer_offset + i]; - } - - this->write_array(bytes_to_send, bytes_to_copy); - - this->current_data_index_ += bytes_to_copy; - - if (millis() - start_time > MAX_TRANSFER_TIME) { - // Let the main loop run and come back next loop - this->disable(); - return false; - } - } - this->disable(); + if (this->current_data_index_ >= half_buffer) { + if (!this->send_buffer_range_(this->buffer_length_, this->invert_red_ ? 0xFF : 0x00, start_time)) + return false; + ESP_LOGV(TAG, "transfer_data: finished red buffer, sending BLACK buffer (0x24)"); + // Do NOT reset RAM counters here for WeAct displays (Reference implementation behavior) + this->command(0x24); + this->current_data_index_ = 0; } + if (!this->send_buffer_range_(half_buffer, 0xFF, start_time)) // the panel wants 1=white + return false; this->current_data_index_ = 0; ESP_LOGV(TAG, "transfer_data: completed (red=%u, black=%u bytes)", half_buffer, half_buffer); diff --git a/esphome/components/epaper_spi/epaper_weact_3c.h b/esphome/components/epaper_spi/epaper_weact_3c.h index d3c89851962..0c1d9f9a654 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.h +++ b/esphome/components/epaper_spi/epaper_weact_3c.h @@ -1,6 +1,6 @@ #pragma once -#include "epaper_spi.h" +#include "epaper_spi_bwr.h" namespace esphome::epaper_spi { @@ -8,20 +8,15 @@ namespace esphome::epaper_spi { * WeAct 3-color e-paper displays (SSD1683 controller). * Supports multiple sizes: 2.9" (128x296), 4.2" (400x300), etc. * - * Color scheme: Black, White, Red (BWR) - * Buffer layout: 1 bit per pixel, separate planes - * - Buffer first half: Black/White plane (1=black, 0=white) - * - Buffer second half: Red plane (1=red, 0=no red) - * - Total buffer: width * height / 4 bytes (2 * width * height / 8) + * Buffer layout: see EPaperBWR, with red_sets_black set so red pixels are sent as 0 in the black/white plane. + * The planes are sent in the opposite order: red with 0x26, then black/white with 0x24, each with + * send_buffer_range_(). */ -class EPaperWeAct3C : public EPaperBase { +class EPaperWeAct3C : public EPaperBWR { public: EPaperWeAct3C(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, - size_t init_sequence_length) - : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY, 1, 2) {} - - void fill(Color color) override; - void clear() override; + size_t init_sequence_length, bool invert_red = false) + : EPaperBWR(name, width, height, init_sequence, init_sequence_length, invert_red, /*red_sets_black=*/true) {} protected: void set_window_(); @@ -29,12 +24,8 @@ class EPaperWeAct3C : public EPaperBase { void power_on() override; void power_off() override; void deep_sleep() override; - void draw_pixel_at(int x, int y, Color color) override; bool transfer_data() override; - - // Hook for subclasses to transform red plane bytes before they go on the wire. - virtual uint8_t transform_red_byte(uint8_t byte) const { return byte; } }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/inkplate2.py b/esphome/components/epaper_spi/models/inkplate2.py index 91974027cab..71cf667aa2a 100644 --- a/esphome/components/epaper_spi/models/inkplate2.py +++ b/esphome/components/epaper_spi/models/inkplate2.py @@ -4,7 +4,7 @@ from . import EpaperModel class Inkplate2Model(EpaperModel): - source_files = ("epaper_spi_inkplate2.cpp",) + source_files = ("epaper_spi_bwr.cpp", "epaper_spi_inkplate2.cpp") def __init__(self, name, class_name="EPaperInkplate2", **kwargs): super().__init__(name, class_name, **kwargs) diff --git a/esphome/components/epaper_spi/models/uc8151.py b/esphome/components/epaper_spi/models/uc8151.py index 520dc82e144..57edd4c37ff 100644 --- a/esphome/components/epaper_spi/models/uc8151.py +++ b/esphome/components/epaper_spi/models/uc8151.py @@ -22,7 +22,7 @@ MAX_GATES = 296 class UC8151(EpaperModel): """EpaperModel class for black/white/red displays using the UC8151D controller.""" - source_files = ("epaper_waveshare_bwr.cpp",) + source_files = ("epaper_spi_bwr.cpp", "epaper_waveshare_bwr.cpp") def __init__(self, name: str, **defaults: Any) -> None: super().__init__(name, "EPaperWaveshareBWR", **defaults) diff --git a/esphome/components/epaper_spi/models/waveshare_b.py b/esphome/components/epaper_spi/models/waveshare_b.py index 1342eff585e..36daa92c932 100644 --- a/esphome/components/epaper_spi/models/waveshare_b.py +++ b/esphome/components/epaper_spi/models/waveshare_b.py @@ -3,6 +3,7 @@ from . import EpaperModel class WaveshareB(EpaperModel): source_files = ( + "epaper_spi_bwr.cpp", "epaper_waveshare_b.cpp", "epaper_weact_3c.cpp", ) diff --git a/esphome/components/epaper_spi/models/waveshare_bwr.py b/esphome/components/epaper_spi/models/waveshare_bwr.py index 0e239bbd3ef..f39ce598d8d 100644 --- a/esphome/components/epaper_spi/models/waveshare_bwr.py +++ b/esphome/components/epaper_spi/models/waveshare_bwr.py @@ -1,24 +1,44 @@ -"""Waveshare Black/White/Red e-paper displays using UC8179 controller. +"""Black/White/Red e-paper displays using UC8179 controller. Supported models: - waveshare-7.5in-bv2-bwr: 800x480 pixels (7.5" BWR display, EPD_7in5b_V2) +- XSRUPB-7.5in-bwr: 800x480 pixels (7.5" V3 BWR XSRUPB 2025 panel) These displays use the UC8179 controller. Power and panel configuration are sent during the INITIALISE state. Power-on is handled in the POWER_ON state, after data transfer, so the state machine's built-in busy wait covers the power-on delay. """ -from . import EpaperModel +import esphome.config_validation as cv +from esphome.const import CONF_DIMENSIONS +from esphome.types import ConfigType + +from . import EpaperModel, ultrachip_resolution + +# UC8179 resolution setting (0x61) limits; sources are set in steps of 8 +MAX_SOURCES = 800 +MAX_GATES = 600 class WaveshareBWR(EpaperModel): """EpaperModel class for Waveshare Black/White/Red displays using UC8179 controller.""" - source_files = ("epaper_waveshare_bwr.cpp",) + source_files = ("epaper_spi_bwr.cpp", "epaper_waveshare_bwr.cpp") def __init__(self, name, **defaults): + defaults.setdefault("minimum_update_interval", "30s") + defaults.setdefault("width_multiple", 8) super().__init__(name, "EPaperWaveshareBWR", **defaults) + def validate_config(self, config: ConfigType) -> ConfigType: + width, height = self.get_dimensions(config) + if width > MAX_SOURCES or height > MAX_GATES: + raise cv.Invalid( + f"{self.name} supports at most {MAX_SOURCES}x{MAX_GATES} pixels (width x height)", + path=[CONF_DIMENSIONS], + ) + return config + def get_init_sequence(self, config): """Generate initialization sequence for UC8179 BWR displays. @@ -30,13 +50,7 @@ class WaveshareBWR(EpaperModel): # PANEL SETTING (KWR mode) (0x00, 0x0F), # RESOLUTION SETTING (width x height) - ( - 0x61, - (width >> 8) & 0xFF, - width & 0xFF, - (height >> 8) & 0xFF, - height & 0xFF, - ), + ultrachip_resolution(width, height), # DUAL SPI MODE (disabled) (0x15, 0x00), # VCOM AND DATA INTERVAL SETTING @@ -56,5 +70,30 @@ WaveshareBWR( width=800, height=480, data_rate="10MHz", - minimum_update_interval="30s", +) + + +class XsrupbBWR(WaveshareBWR): + """WaveshareBWR with the XSRUPB init sequence, which sets DDX=11, so the red data is sent inverted.""" + + def get_init_sequence(self, config: ConfigType) -> tuple: + width, height = self.get_dimensions(config) + return ( + # Panel setting: BWR, LUT from OTP + (0x00, 0x0F), + # VCOM and data interval setting: BDV=11 (border uses LUTBD), DDX=11, which needs the red data inverted + (0x50, 0x33), + ultrachip_resolution(width, height), + # No power setting (0x01): with it, even the datasheet defaults, this panel does not refresh + ) + + def get_constructor_args(self, config: ConfigType) -> tuple: + return (True,) # invert_red, required by DDX=11 above + + +# Model: 7.5" V3 BWR XSRUPB 2025 - 800x480 pixels, UC8179 controller +XsrupbBWR( + "XSRUPB-7.5in-bwr", + width=800, + height=480, ) diff --git a/esphome/components/epaper_spi/models/weact_bwr.py b/esphome/components/epaper_spi/models/weact_bwr.py index 7cb0d797668..6c228514bd3 100644 --- a/esphome/components/epaper_spi/models/weact_bwr.py +++ b/esphome/components/epaper_spi/models/weact_bwr.py @@ -21,7 +21,7 @@ from . import EpaperModel class WeActBWR(EpaperModel): """Base EpaperModel class for WeAct Black/White/Red displays using SSD1683 controller.""" - source_files = ("epaper_weact_3c.cpp",) + source_files = ("epaper_spi_bwr.cpp", "epaper_weact_3c.cpp") def __init__( self, name: str, class_name: str = "EPaperWeAct3C", **defaults: object @@ -88,7 +88,11 @@ weact_4p2in3c = WeActBWR( class GoodDisplayBWR(WeActBWR): """Good Display Black/White/Red displays using SSD1680 controller.""" - source_files = ("epaper_weact_3c.cpp", "epaper_goodisplay_3c.cpp") + source_files = ( + "epaper_spi_bwr.cpp", + "epaper_weact_3c.cpp", + "epaper_goodisplay_3c.cpp", + ) def __init__(self, name: str, **defaults: object) -> None: super().__init__(name, "EPaperGoodDisplay3C", **defaults) diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index a8fc27b0b12..9b45adbbad8 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -621,6 +621,64 @@ def test_uc8279_width_must_be_a_multiple_of_8( ) +@pytest.mark.parametrize( + ("dimensions", "match"), + [ + ({"width": 794, "height": 480}, "multiple of 8"), + ({"width": 808, "height": 480}, "at most 800x600"), + ({"width": 800, "height": 601}, "at most 800x600"), + ], +) +@pytest.mark.parametrize("model", ["XSRUPB-7.5in-bwr", "waveshare-7.5in-bv2-bwr"]) +def test_uc8179_bwr_rejects_unsupported_dimensions( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + model: str, + dimensions: dict[str, int], + match: str, +) -> None: + """The UC8179 sets sources in steps of 8, up to 800 sources and 600 gates.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + with pytest.raises(cv.Invalid, match=match): + run_schema_validation( + { + "id": "test_display", + "model": model, + "dc_pin": 21, + "dimensions": dimensions, + } + ) + + +@pytest.mark.parametrize("model", ["XSRUPB-7.5in-bwr", "waveshare-7.5in-bv2-bwr"]) +def test_uc8179_bwr_accepts_largest_size( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], + model: str, +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + run_schema_validation( + { + "id": "test_display", + "model": model, + "dc_pin": 21, + "dimensions": {"width": 800, "height": 600}, + } + ) + + GDEY029Z95 = "goodisplay-gdey029z95-2.9" diff --git a/tests/components/epaper_spi/display/test_bwr.cpp b/tests/components/epaper_spi/display/test_bwr.cpp new file mode 100644 index 00000000000..28f6686d9e6 --- /dev/null +++ b/tests/components/epaper_spi/display/test_bwr.cpp @@ -0,0 +1,385 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_bwr.h" + +namespace esphome::epaper_spi::testing { +namespace { + +class TestableBWR : public EPaperBWR { + public: + TestableBWR(uint16_t width, uint16_t height, bool invert_red, bool red_sets_black = false) + : EPaperBWR("test", width, height, nullptr, 0, invert_red, red_sets_black) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + /// Like install(), but with the buffer split into parts of at most part_size bytes. + void install_split(spi::SPIDelegate *delegate, size_t part_size) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->buffer_.init(this->buffer_length_, part_size)); + ASSERT_GT(this->buffer_.get_buffer_count(), 1u); + } + + void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); } + uint8_t byte_at(size_t index) { return this->buffer_[index]; } + void set_byte(size_t index, uint8_t value) { this->buffer_[index] = value; } + size_t half_buffer() const { return this->buffer_length_ / 2; } + + bool send_range(size_t end, uint8_t invert_mask) { return this->send_buffer_range_(end, invert_mask, millis()); } + size_t data_index() const { return this->current_data_index_; } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + /// Pretend nothing has changed since the last push. + void clear_dirty() { + this->x_low_ = this->width_; + this->y_low_ = this->height_; + this->x_high_ = 0; + this->y_high_ = 0; + } + /// Pretend nothing has been drawn since the frame on the panel was hashed. + void clear_drawn_since_hash() { this->drawn_since_hash_ = false; } + bool drawn_since_hash() const { return this->drawn_since_hash_; } + bool whole_screen_dirty() const { + return this->x_low_ == 0 && this->y_low_ == 0 && this->x_high_ == this->width_ && this->y_high_ == this->height_; + } + + RecordingPin dc; + + protected: + void refresh_screen(bool partial) override {} + void power_on() override {} + void power_off() override {} + void deep_sleep() override {} +}; + +/// Also counts chip select transactions and the size of each data write. +class CountingDelegate : public RecordingDelegate { + public: + using RecordingDelegate::RecordingDelegate; + + void begin_transaction() override { this->begin_count++; } + void end_transaction() override { this->end_count++; } + void write_array(const uint8_t *ptr, size_t length) override { + if (this->dc_->level) + this->data_writes.push_back(length); + RecordingDelegate::write_array(ptr, length); + } + + int begin_count{0}; + int end_count{0}; + std::vector data_writes; +}; + +using Bytes = std::vector; + +const Color RED(255, 0, 0); + +// draw_pixel_at() + +/// The black/white plane holds 1=black and the red plane 1=red, MSB first. +TEST(EPaperBWR, DrawPixelSetsOneBitInEachPlane) { + TestableBWR display(16, 1, false); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(Color::WHITE); + + display.draw(0, 0, Color::BLACK); + display.draw(1, 0, RED); + display.draw(15, 0, Color::BLACK); + + EXPECT_EQ(display.byte_at(0), 0x80); // black/white plane + EXPECT_EQ(display.byte_at(1), 0x01); + EXPECT_EQ(display.byte_at(2), 0x40); // red plane + EXPECT_EQ(display.byte_at(3), 0x00); +} + +/// Drawing over a pixel clears whatever the other color set. +TEST(EPaperBWR, DrawPixelReplacesThePreviousColor) { + TestableBWR display(8, 1, false); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(Color::WHITE); + + display.draw(0, 0, RED); + display.draw(0, 0, Color::BLACK); + EXPECT_EQ(display.byte_at(0), 0x80); + EXPECT_EQ(display.byte_at(1), 0x00); + + display.draw(0, 0, RED); + EXPECT_EQ(display.byte_at(0), 0x00); + EXPECT_EQ(display.byte_at(1), 0x80); + + display.draw(0, 0, Color::WHITE); + EXPECT_EQ(display.byte_at(0), 0x00); + EXPECT_EQ(display.byte_at(1), 0x00); +} + +/// With red_sets_black, red pixels set their black/white bit too; black and white are unaffected. +TEST(EPaperBWR, DrawPixelRedSetsBlack) { + TestableBWR display(8, 1, false, true); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(Color::WHITE); + + display.draw(0, 0, Color::BLACK); + display.draw(1, 0, RED); + display.draw(2, 0, Color::WHITE); + + EXPECT_EQ(display.byte_at(0), 0xC0); + EXPECT_EQ(display.byte_at(1), 0x40); +} + +TEST(EPaperBWR, DrawPixelIgnoresPointsOutsideTheScreen) { + TestableBWR display(8, 1, false); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(Color::WHITE); + + display.draw(-1, 0, Color::BLACK); + display.draw(8, 0, Color::BLACK); + display.draw(0, 1, RED); + + EXPECT_EQ(display.byte_at(0), 0x00); + EXPECT_EQ(display.byte_at(1), 0x00); +} + +// fill() + +TEST(EPaperBWR, FillSetsBothPlanes) { + struct Case { + Color color; + bool red_sets_black; + uint8_t bw_byte; + uint8_t red_byte; + }; + const Case cases[] = { + {Color::WHITE, false, 0x00, 0x00}, {Color::BLACK, false, 0xFF, 0x00}, {RED, false, 0x00, 0xFF}, + {Color::WHITE, true, 0x00, 0x00}, {Color::BLACK, true, 0xFF, 0x00}, {RED, true, 0xFF, 0xFF}, + }; + for (const auto &c : cases) { + TestableBWR display(16, 2, false, c.red_sets_black); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.fill(c.color); + + for (size_t i = 0; i != display.half_buffer(); i++) { + EXPECT_EQ(display.byte_at(i), c.bw_byte) << "black/white byte " << i << ", red_sets_black " << c.red_sets_black; + EXPECT_EQ(display.byte_at(display.half_buffer() + i), c.red_byte) + << "red byte " << i << ", red_sets_black " << c.red_sets_black; + } + } +} + +TEST(EPaperBWR, FillMarksTheWholeScreenChanged) { + TestableBWR display(16, 2, false); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.clear_dirty(); + display.fill(Color::BLACK); + + EXPECT_TRUE(display.whole_screen_dirty()); +} + +/// fill() writes the buffer without drawing pixels, so it must flag the frame as drawn itself; +/// otherwise an update could skip the refresh as if the frame were unchanged. +TEST(EPaperBWR, FillMarksTheFrameDrawn) { + TestableBWR display(16, 2, false); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.clear_drawn_since_hash(); + display.fill(Color::BLACK); + + EXPECT_TRUE(display.drawn_since_hash()); +} + +/// With a clipping area set, only the pixels inside it change. +TEST(EPaperBWR, FillWithClippingOnlyFillsTheClippedArea) { + TestableBWR display(16, 1, false); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(Color::WHITE); + + display.start_clipping(0, 0, 8, 1); + display.fill(Color::BLACK); + display.end_clipping(); + + EXPECT_EQ(display.byte_at(0), 0xFF); + EXPECT_EQ(display.byte_at(1), 0x00); +} + +/// fill() sets each plane correctly when the parts of a split buffer do not line up with the planes. +TEST(EPaperBWR, FillAcrossSplitBufferParts) { + TestableBWR display(128, 20, false); // 320 bytes per plane, in parts of 50 bytes + RecordingDelegate bus(&display.dc); + display.install_split(&bus, 50); + + display.fill(RED); + + for (size_t i = 0; i != display.half_buffer(); i++) { + EXPECT_EQ(display.byte_at(i), 0x00) << "black/white byte " << i; + EXPECT_EQ(display.byte_at(display.half_buffer() + i), 0xFF) << "red byte " << i; + } +} + +// send_buffer_range_() + +/// Bytes are XORed with the mask and sent in chunks of up to MAX_TRANSFER_SIZE, with chip select held for the +/// whole range. +TEST(EPaperBWR, SendBufferRangeHoldsChipSelectForTheRange) { + TestableBWR display(128, 20, false); // 320 bytes per plane + CountingDelegate bus(&display.dc); + display.install(&bus); + for (size_t i = 0; i != display.half_buffer(); i++) + display.set_byte(i, (uint8_t) (i * 7)); + + EXPECT_TRUE(display.send_range(display.half_buffer(), 0xFF)); + + EXPECT_EQ(display.data_index(), display.half_buffer()); + EXPECT_EQ(bus.data_writes, (std::vector{MAX_TRANSFER_SIZE, MAX_TRANSFER_SIZE, 64})); + EXPECT_EQ(bus.begin_count, 1); + EXPECT_EQ(bus.end_count, 1); + const Bytes &sent = bus.data[0]; + ASSERT_EQ(sent.size(), display.half_buffer()); + for (size_t i = 0; i != sent.size(); i++) + EXPECT_EQ(sent[i], (uint8_t) ~(i * 7)) << "byte " << i; +} + +/// After MAX_TRANSFER_TIME it stops at the end of a full chunk with chip select released, and the next call carries +/// on from there. +TEST(EPaperBWR, SendBufferRangeYieldsAndResumes) { + TestableBWR display(128, 20, false); + CountingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + for (size_t i = 0; i != display.half_buffer(); i++) + display.set_byte(i, (uint8_t) i); + + EXPECT_FALSE(display.send_range(display.half_buffer(), 0x00)); + EXPECT_EQ(display.data_index(), MAX_TRANSFER_SIZE); + EXPECT_EQ(bus.begin_count, 1); + EXPECT_EQ(bus.end_count, 1); + + int calls = 1; + while (!display.send_range(display.half_buffer(), 0x00)) + calls++; + + EXPECT_EQ(calls, 2); // the second full chunk yields; the short last chunk does not check the time + EXPECT_EQ(display.data_index(), display.half_buffer()); + EXPECT_EQ(bus.begin_count, 3); // one per call + EXPECT_EQ(bus.end_count, 3); + const Bytes &sent = bus.data[0]; + ASSERT_EQ(sent.size(), display.half_buffer()); + for (size_t i = 0; i != sent.size(); i++) + EXPECT_EQ(sent[i], (uint8_t) i) << "byte " << i; +} + +/// Chunks that cross from one part of a split buffer to the next are sent whole and in order. +TEST(EPaperBWR, SendBufferRangeAcrossSplitBufferParts) { + TestableBWR display(128, 20, false); + CountingDelegate bus(&display.dc); + display.install_split(&bus, 50); + for (size_t i = 0; i != display.half_buffer(); i++) + display.set_byte(i, (uint8_t) (i * 7)); + + EXPECT_TRUE(display.send_range(display.half_buffer(), 0xFF)); + + EXPECT_EQ(bus.data_writes, (std::vector{MAX_TRANSFER_SIZE, MAX_TRANSFER_SIZE, 64})); + const Bytes &sent = bus.data[0]; + ASSERT_EQ(sent.size(), display.half_buffer()); + for (size_t i = 0; i != sent.size(); i++) + EXPECT_EQ(sent[i], (uint8_t) ~(i * 7)) << "byte " << i; +} + +// transfer_data() + +/// The black/white plane is always sent inverted; the red plane only when invert_red is set. +TEST(EPaperBWR, TransferDataInvertsTheRedPlaneOnlyWhenAsked) { + for (const bool invert_red : {false, true}) { + TestableBWR display(16, 1, invert_red); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.set_byte(0, 0x0F); + display.set_byte(1, 0x3C); + display.set_byte(2, 0x55); + display.set_byte(3, 0x81); + + display.run_push(); + + EXPECT_EQ(bus.commands, (Bytes{0x10, 0x13})) << "invert_red " << invert_red; + EXPECT_EQ(bus.data[0x10], (Bytes{0xF0, 0xC3})) << "invert_red " << invert_red; + EXPECT_EQ(bus.data[0x13], invert_red ? (Bytes{0xAA, 0x7E}) : (Bytes{0x55, 0x81})) << "invert_red " << invert_red; + } +} + +/// A finished push starts the next one from the beginning. +TEST(EPaperBWR, TransferDataStartsAgainAfterAPush) { + TestableBWR display(16, 1, false); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(Color::BLACK); + + display.run_push(); + EXPECT_EQ(display.data_index(), 0u); + bus.clear(); + display.run_push(); + + EXPECT_EQ(bus.commands, (Bytes{0x10, 0x13})); + EXPECT_EQ(bus.data[0x10], (Bytes{0x00, 0x00})); + EXPECT_EQ(bus.data[0x13], (Bytes{0x00, 0x00})); +} + +/// A push that yields in either plane sends each command once and every byte once. +TEST(EPaperBWR, TransferDataResumesWithoutRepeatingCommands) { + TestableBWR display(128, 20, false); + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + display.fill(RED); + + EXPECT_GT(display.run_push(), 2); + + EXPECT_EQ(bus.commands, (Bytes{0x10, 0x13})); + EXPECT_EQ(bus.data[0x10], Bytes(320, 0xFF)); + EXPECT_EQ(bus.data[0x13], Bytes(320, 0xFF)); +} + +/// A push from a split buffer sends the same bytes as from a buffer in one part. +TEST(EPaperBWR, TransferDataFromSplitBuffer) { + TestableBWR whole(128, 20, true); + RecordingDelegate whole_bus(&whole.dc); + whole.install(&whole_bus); + TestableBWR split(128, 20, true); + RecordingDelegate split_bus(&split.dc); + split.install_split(&split_bus, 50); + for (size_t i = 0; i != 2 * whole.half_buffer(); i++) { + whole.set_byte(i, (uint8_t) (i * 13)); + split.set_byte(i, (uint8_t) (i * 13)); + } + + whole.run_push(); + split.run_push(); + + EXPECT_EQ(split_bus.commands, (Bytes{0x10, 0x13})); + EXPECT_EQ(split_bus.data[0x10], whole_bus.data[0x10]); + EXPECT_EQ(split_bus.data[0x13], whole_bus.data[0x13]); + EXPECT_EQ(split_bus.data[0x13].size(), 320u); +} + +} // namespace +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_bwr_same_bytes.cpp b/tests/components/epaper_spi/display/test_bwr_same_bytes.cpp new file mode 100644 index 00000000000..bdb874f28ed --- /dev/null +++ b/tests/components/epaper_spi/display/test_bwr_same_bytes.cpp @@ -0,0 +1,179 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_spi_inkplate2.h" +#include "esphome/components/epaper_spi/epaper_waveshare_bwr.h" + +// What the Inkplate 2 and Waveshare BWR drivers send to the panel. These tests only use what the drivers had before +// they were built on EPaperBWR (the constructor, draw_pixel_at(), fill() and transfer_data()), and the expected bytes +// are the ones those drivers sent, so the same tests prove the shared code sends the same bytes. + +namespace esphome::epaper_spi::testing { +namespace { + +template class Testable : public Driver { + public: + Testable(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + RecordingPin dc; +}; + +using Bytes = std::vector; + +const Color RED(255, 0, 0); + +/// 16x2 pixels, so 2 bytes per row and 4 bytes per plane: white, with black at (1, 0) and (8, 1), red at (2, 0) +/// and (15, 1). +template void draw_test_pattern(Testable &display) { + display.fill(Color::WHITE); + display.draw(1, 0, Color::BLACK); + display.draw(2, 0, RED); + display.draw(8, 1, Color::BLACK); + display.draw(15, 1, RED); +} + +/// A pattern of all three colors over a display large enough that each plane takes several chunks. +template void draw_large_pattern(Testable &display, int width, int height) { + const Color colors[] = {Color::WHITE, Color::BLACK, RED}; + for (int y = 0; y != height; y++) { + for (int x = 0; x != width; x++) + display.draw(x, y, colors[(x * 7 + y * 3) % 3]); + } +} + +/// The black/white plane (0x10) goes out before the red plane (0x13), each command once per push. +void expect_bw_plane_first(const RecordingDelegate &bus) { + EXPECT_EQ(std::count(bus.commands.begin(), bus.commands.end(), 0x10), 1); + EXPECT_EQ(std::count(bus.commands.begin(), bus.commands.end(), 0x13), 1); + const auto bw = std::find(bus.commands.begin(), bus.commands.end(), 0x10); + const auto red = std::find(bus.commands.begin(), bus.commands.end(), 0x13); + EXPECT_LT(bw, red); +} + +// Inkplate 2: black/white plane 1=white, 0=black, with red pixels sent as 1; red plane 0=red. + +TEST(EPaperBWRSameBytes, Inkplate2Pixels) { + Testable display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_test_pattern(display); + display.run_push(); + + expect_bw_plane_first(bus); + EXPECT_EQ(bus.data[0x10], (Bytes{0xBF, 0xFF, 0xFF, 0x7F})); + EXPECT_EQ(bus.data[0x13], (Bytes{0xDF, 0xFF, 0xFF, 0xFE})); +} + +TEST(EPaperBWRSameBytes, Inkplate2Fills) { + Testable display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.fill(Color::WHITE); + display.run_push(); + EXPECT_EQ(bus.data[0x10], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + + bus.clear(); + display.fill(Color::BLACK); + display.run_push(); + EXPECT_EQ(bus.data[0x10], (Bytes{0x00, 0x00, 0x00, 0x00})); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + + bus.clear(); + display.fill(RED); + display.run_push(); + EXPECT_EQ(bus.data[0x10], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x13], (Bytes{0x00, 0x00, 0x00, 0x00})); +} + +// Waveshare BWR (UC8179 and UC8151D): black/white plane 1=white, 0=black, with red pixels sent as 1; red plane 1=red. + +TEST(EPaperBWRSameBytes, WaveshareBWRPixels) { + Testable display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + draw_test_pattern(display); + display.run_push(); + + expect_bw_plane_first(bus); + EXPECT_EQ(bus.data[0x10], (Bytes{0xBF, 0xFF, 0xFF, 0x7F})); + EXPECT_EQ(bus.data[0x13], (Bytes{0x20, 0x00, 0x00, 0x01})); +} + +TEST(EPaperBWRSameBytes, WaveshareBWRFills) { + Testable display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.fill(Color::WHITE); + display.run_push(); + EXPECT_EQ(bus.data[0x10], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x13], (Bytes{0x00, 0x00, 0x00, 0x00})); + + bus.clear(); + display.fill(Color::BLACK); + display.run_push(); + EXPECT_EQ(bus.data[0x10], (Bytes{0x00, 0x00, 0x00, 0x00})); + EXPECT_EQ(bus.data[0x13], (Bytes{0x00, 0x00, 0x00, 0x00})); + + bus.clear(); + display.fill(RED); + display.run_push(); + EXPECT_EQ(bus.data[0x10], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x13], (Bytes{0xFF, 0xFF, 0xFF, 0xFF})); +} + +/// A push that yields to the main loop part way through, and resumes over several calls, sends the same bytes and +/// each plane command once, the same as a push that runs to completion in one call. +template void expect_resumed_push_matches() { + // 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE. + constexpr int width = 128; + constexpr int height = 20; + + Testable reference(width, height); + RecordingDelegate reference_bus(&reference.dc); + reference.install(&reference_bus); + draw_large_pattern(reference, width, height); + EXPECT_EQ(reference.run_push(), 1); + + // Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk. + Testable display(width, height); + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + draw_large_pattern(display, width, height); + EXPECT_GT(display.run_push(), 1); + + expect_bw_plane_first(bus); + EXPECT_EQ(bus.data[0x10].size(), 320u); + EXPECT_EQ(bus.data[0x10], reference_bus.data[0x10]); + EXPECT_EQ(bus.data[0x13], reference_bus.data[0x13]); +} + +TEST(EPaperBWRSameBytes, Inkplate2ResumedPush) { expect_resumed_push_matches(); } + +TEST(EPaperBWRSameBytes, WaveshareBWRResumedPush) { expect_resumed_push_matches(); } + +} // namespace +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_colorconv_bwr.cpp b/tests/components/epaper_spi/display/test_colorconv_bwr.cpp new file mode 100644 index 00000000000..2d98c8639a7 --- /dev/null +++ b/tests/components/epaper_spi/display/test_colorconv_bwr.cpp @@ -0,0 +1,53 @@ +#include + +#include "esphome/components/epaper_spi/colorconv.h" + +namespace esphome::epaper_spi::testing { +namespace { + +BwrColor to_bwr(Color color) { + return color_to_bwr(color, BwrColor::BWR_COLOR_BLACK, BwrColor::BWR_COLOR_WHITE, BwrColor::BWR_COLOR_RED); +} + +TEST(ColorToBwr, PrimariesMapToThemselves) { + EXPECT_EQ(to_bwr(Color(0, 0, 0)), BwrColor::BWR_COLOR_BLACK); + EXPECT_EQ(to_bwr(Color(255, 255, 255)), BwrColor::BWR_COLOR_WHITE); + EXPECT_EQ(to_bwr(Color(255, 0, 0)), BwrColor::BWR_COLOR_RED); +} + +TEST(ColorToBwr, GraysMapToBlackOrWhiteByLuma) { + EXPECT_EQ(to_bwr(Color(100, 100, 100)), BwrColor::BWR_COLOR_BLACK); + EXPECT_EQ(to_bwr(Color(200, 200, 200)), BwrColor::BWR_COLOR_WHITE); +} + +TEST(ColorToBwr, OrangesMapToRed) { + EXPECT_EQ(to_bwr(Color(255, 165, 0)), BwrColor::BWR_COLOR_RED); // named orange + EXPECT_EQ(to_bwr(Color(255, 100, 0)), BwrColor::BWR_COLOR_RED); + EXPECT_EQ(to_bwr(Color(255, 69, 0)), BwrColor::BWR_COLOR_RED); // orange red +} + +TEST(ColorToBwr, MutedAndDarkRedsMapToRed) { + EXPECT_EQ(to_bwr(Color(200, 100, 100)), BwrColor::BWR_COLOR_RED); + EXPECT_EQ(to_bwr(Color(128, 0, 0)), BwrColor::BWR_COLOR_RED); // maroon +} + +TEST(ColorToBwr, VeryDarkRedsMapToBlack) { EXPECT_EQ(to_bwr(Color(100, 0, 0)), BwrColor::BWR_COLOR_BLACK); } + +TEST(ColorToBwr, PaleColorsMapToWhite) { + EXPECT_EQ(to_bwr(Color(255, 129, 129)), BwrColor::BWR_COLOR_WHITE); // pale pink + EXPECT_EQ(to_bwr(Color(0, 255, 255)), BwrColor::BWR_COLOR_WHITE); // cyan +} + +TEST(ColorToBwr, TiesGoToWhite) { + EXPECT_EQ(to_bwr(Color(255, 255, 0)), BwrColor::BWR_COLOR_WHITE); // yellow: equidistant from red and white +} + +TEST(ColorToBwr, DarkColorsMapToBlack) { + EXPECT_EQ(to_bwr(Color(0, 0, 255)), BwrColor::BWR_COLOR_BLACK); // blue + EXPECT_EQ(to_bwr(Color(0, 128, 0)), BwrColor::BWR_COLOR_BLACK); // dark green +} + +static_assert(color_to_bwr(Color(255, 165, 0), 0, 1, 2) == 2, "color_to_bwr must stay usable in constexpr"); + +} // namespace +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_weact_3c_transfer.cpp b/tests/components/epaper_spi/display/test_weact_3c_transfer.cpp new file mode 100644 index 00000000000..6be7ac7d206 --- /dev/null +++ b/tests/components/epaper_spi/display/test_weact_3c_transfer.cpp @@ -0,0 +1,165 @@ +#include + +#include +#include + +#include "../common.h" +#include "esphome/components/epaper_spi/epaper_waveshare_b.h" +#include "esphome/components/epaper_spi/epaper_weact_3c.h" + +namespace esphome::epaper_spi::testing { +namespace { + +template class Testable : public Driver { + public: + Testable(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {} + + void install(spi::SPIDelegate *delegate) { + this->delegate_ = delegate; + this->set_dc_pin(&this->dc); + ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); + } + + void draw(int x, int y, Color color) { this->draw_pixel_at(x, y, color); } + + /// Both planes of one push; returns how many calls it took. + int run_push() { + int calls = 1; + while (!this->transfer_data()) + calls++; + return calls; + } + + RecordingPin dc; +}; + +using Bytes = std::vector; + +const Color RED(255, 0, 0); + +/// The red plane (0x26) must go out before the black/white plane (0x24). +void expect_red_plane_first(const RecordingDelegate &bus) { + const auto red = std::find(bus.commands.begin(), bus.commands.end(), 0x26); + const auto bw = std::find(bus.commands.begin(), bus.commands.end(), 0x24); + ASSERT_NE(red, bus.commands.end()); + ASSERT_NE(bw, bus.commands.end()); + EXPECT_LT(red, bw); +} + +/// One row of 16 pixels: white, black, red, then white. The panel gets the black/white plane as +/// 1=white, 0=black or red, and the red plane as 1=red. +TEST(EPaperWeAct3CTransfer, PixelsReachThePanelUnchanged) { + Testable display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.fill(Color::WHITE); + display.draw(1, 0, Color::BLACK); + display.draw(2, 0, RED); + display.run_push(); + + expect_red_plane_first(bus); + EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x20, 0x00})); +} + +TEST(EPaperWeAct3CTransfer, FillsReachThePanelUnchanged) { + Testable display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.fill(Color::WHITE); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0xFF, 0xFF})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x00, 0x00})); + + bus.clear(); + display.fill(Color::BLACK); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x00, 0x00})); + + bus.clear(); + display.fill(RED); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF})); +} + +/// Waveshare (B) panels take the red plane as 0=red; the black/white plane is the same as WeAct's. +TEST(EPaperWeAct3CTransfer, WaveshareBInvertsOnlyTheRedPlane) { + Testable display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + + display.fill(Color::WHITE); + display.draw(1, 0, Color::BLACK); + display.draw(2, 0, RED); + display.run_push(); + + expect_red_plane_first(bus); + EXPECT_EQ(bus.data[0x24], (Bytes{0x9F, 0xFF})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xDF, 0xFF})); + + bus.clear(); + display.fill(RED); + display.run_push(); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x00, 0x00})); +} + +/// A push that yields to the main loop part way through each plane, and resumes over several calls, sends the same +/// commands and bytes as a push that runs to completion in one call. +template void expect_resumed_push_matches() { + // 128x20 pixels is 320 bytes per plane, so each plane takes three chunks of up to MAX_TRANSFER_SIZE. + constexpr int width = 128; + constexpr int height = 20; + const Color colors[] = {Color::WHITE, Color::BLACK, RED}; + + Testable reference(width, height); + RecordingDelegate reference_bus(&reference.dc); + reference.install(&reference_bus); + Testable display(width, height); + // Every data write takes longer than MAX_TRANSFER_TIME, so the push yields after each chunk. + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus); + for (int y = 0; y != height; y++) { + for (int x = 0; x != width; x++) { + reference.draw(x, y, colors[(x * 7 + y * 3) % 3]); + display.draw(x, y, colors[(x * 7 + y * 3) % 3]); + } + } + + EXPECT_EQ(reference.run_push(), 1); + EXPECT_GT(display.run_push(), 2); + + expect_red_plane_first(bus); + EXPECT_EQ(bus.commands, reference_bus.commands); + EXPECT_EQ(bus.data[0x26].size(), 320u); + EXPECT_EQ(bus.data[0x24], reference_bus.data[0x24]); + EXPECT_EQ(bus.data[0x26], reference_bus.data[0x26]); +} + +TEST(EPaperWeAct3CTransfer, WeActResumedPush) { expect_resumed_push_matches(); } + +TEST(EPaperWeAct3CTransfer, WaveshareBResumedPush) { expect_resumed_push_matches(); } + +/// A second push starts again from the window setup and the red plane. +TEST(EPaperWeAct3CTransfer, SecondPushStartsAgain) { + Testable display(16, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + display.fill(RED); + + display.run_push(); + const Bytes first_commands = bus.commands; + bus.clear(); + display.run_push(); + + EXPECT_EQ(bus.commands, first_commands); + EXPECT_EQ(bus.data[0x24], (Bytes{0x00, 0x00})); + EXPECT_EQ(bus.data[0x26], (Bytes{0xFF, 0xFF})); +} + +} // namespace +} // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 6f5a6b54daf..4abb91ad962 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -15,6 +15,26 @@ esphome: - component.update: epaper_partial display: + - platform: epaper_spi + spi_id: spi_bus + model: XSRUPB-7.5in-bwr + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + lambda: |- + it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE); + it.circle(it.get_width() / 2, it.get_height() / 2, 30, Color::BLACK); + it.circle(it.get_width() / 2, it.get_height() / 2, 20, Color(255, 0, 0)); - platform: epaper_spi spi_id: spi_bus model: spectra-e6 From 2cdcdd82f8e64f3be9b76eadfcadc4d390c7f79e Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sun, 1 Nov 2026 21:11:26 +0100 Subject: [PATCH 049/170] [nrf52] Deprecate framework versions older than 2.9.2 (#22122) Co-authored-by: Claude --- esphome/components/nrf52/__init__.py | 18 ++++++++ .../nrf52/test_framework_version_warning.py | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/unit_tests/components/nrf52/test_framework_version_warning.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7d5295fca59..e1bb01d103d 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -347,6 +347,23 @@ def _validate_open_dfu_toolchain(config: ConfigType) -> None: ) +def _warn_old_framework_version(config: ConfigType) -> None: + # The PlatformIO toolchain already warns that it is deprecated as a whole + if CORE.using_toolchain_platformio: + return + if (version := config[CONF_FRAMEWORK].get(CONF_VERSION)) is None: + return + # Remove before 2027.5.0 + if cv.Version.parse(cv.version_number(version)) < cv.Version(2, 9, 2): + _LOGGER.warning( + "nRF52 framework version %s is deprecated and will be removed in ESPHome 2027.5.0. " + "Please use version 2.9.2 or newer, or remove 'version:' under 'framework:' " + "to use the recommended version (%s).", + version, + RECOMMENDED_SDK_NRF_VERSION, + ) + + def _final_validate(config): # Remove before 2027.2.0 @@ -355,6 +372,7 @@ def _final_validate(config): "The 'platformio' toolchain for nRF52 is deprecated and will be removed in ESPHome 2027.2.0. " "Please use 'toolchain: sdk-nrf' instead." ) + _warn_old_framework_version(config) _validate_open_dfu_toolchain(config) if CONF_DFU in config: diff --git a/tests/unit_tests/components/nrf52/test_framework_version_warning.py b/tests/unit_tests/components/nrf52/test_framework_version_warning.py new file mode 100644 index 00000000000..c3ca6435be7 --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_framework_version_warning.py @@ -0,0 +1,42 @@ +"""The deprecation warning for nRF52 framework versions older than 2.9.2.""" + +import logging +from unittest.mock import PropertyMock, patch + +import pytest + +from esphome.components import nrf52 +from esphome.const import CONF_FRAMEWORK, CONF_VERSION +from esphome.core import EsphomeCore + + +@pytest.mark.parametrize( + ("version", "platformio", "warned"), + [ + ("2.6.1", False, True), + ("2.9.1", False, True), + ("2.9.2", False, False), + ("3.2.0", False, False), + ("2.6.1-b", True, False), + (None, False, False), + ], +) +def test_warn_old_framework_version( + caplog: pytest.LogCaptureFixture, + version: str | None, + platformio: bool, + warned: bool, +) -> None: + framework = {} if version is None else {CONF_VERSION: version} + with ( + patch.object( + EsphomeCore, + "using_toolchain_platformio", + new_callable=PropertyMock, + return_value=platformio, + ), + caplog.at_level(logging.WARNING), + ): + nrf52._warn_old_framework_version({CONF_FRAMEWORK: framework}) + + assert ("is deprecated" in caplog.text) is warned From 4219a48408fdb9b84e2a90ce0ff1c596aced0361 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:25:17 -0500 Subject: [PATCH 050/170] [host] Add use_address so OTA and logs can reach a host device (#22115) --- esphome/components/host/__init__.py | 2 ++ esphome/core/__init__.py | 5 +++++ tests/components/host/common.yaml | 1 + tests/unit_tests/test_core.py | 13 +++++++++++++ 4 files changed, 21 insertions(+) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 10927192e5f..d9e400cf1df 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -5,6 +5,7 @@ from esphome.components import network import esphome.config_validation as cv from esphome.const import ( CONF_MAC_ADDRESS, + CONF_USE_ADDRESS, KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, @@ -39,6 +40,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address, + cv.Optional(CONF_USE_ADDRESS): cv.string_strict, } ), # The host builds with the machine's own compiler through ninja; there diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index fda33a4bebc..e00fbe9dfc9 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -791,6 +791,11 @@ class EsphomeCore: if CONF_OPENTHREAD in self.config: return f"{self.name}.local" + if PLATFORM_HOST in self.config: + return self.config[PLATFORM_HOST].get( + CONF_USE_ADDRESS, f"{self.name}.local" + ) + return None @property diff --git a/tests/components/host/common.yaml b/tests/components/host/common.yaml index d5c8446ae8a..250355c9056 100644 --- a/tests/components/host/common.yaml +++ b/tests/components/host/common.yaml @@ -8,6 +8,7 @@ logger: host: mac_address: "62:23:45:AF:B3:DD" + use_address: 10.0.0.1 esphome: on_boot: diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 6b399186535..a1b18846120 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -628,6 +628,19 @@ class TestEsphomeCore: assert target.address == "test-device.local" + def test_address__host(self, target): + target.config = {} + target.config[const.PLATFORM_HOST] = {const.CONF_USE_ADDRESS: "10.0.0.100"} + + assert target.address == "10.0.0.100" + + def test_address__host_default(self, target): + target.config = {} + target.config[const.PLATFORM_HOST] = {} + target.name = "test-device" + + assert target.address == "test-device.local" + def test_is_esp32(self, target): target.data[const.KEY_CORE] = {const.KEY_TARGET_PLATFORM: "esp32"} From 19ea0095462b27b7442bbe911c1d54aafcc71940 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:25:54 -0500 Subject: [PATCH 051/170] [host] Offer the built program as a download in the Device Builder (#22114) --- esphome/components/host/__init__.py | 17 +++++++++++++++++ tests/unit_tests/test_download_types.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index d9e400cf1df..79485ce9e22 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( Toolchain, ) from esphome.core import CORE, EsphomeError +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from .const import KEY_HOST @@ -63,6 +64,22 @@ async def to_code(config: ConfigType) -> None: cg.add_define(ThreadModel.MULTI_ATOMICS) +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: + """Download entries for a built host program; same contract as + ``esp32.get_download_types`` (title / description / file / download).""" + if storage_json.firmware_bin_path is None: + return [] + return [ + { + "title": "Executable", + "description": "Native program for the machine this was built on; " + "copy it to the host and run it.", + "file": "program", + "download": storage_json.name, + } + ] + + # Called by __main__.compile_program; True means this platform built the # program itself instead of falling through to the PlatformIO toolchain. def run_compile(args: object, config: ConfigType) -> bool: diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index b694ce74533..720f33d8dc6 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -15,7 +15,7 @@ import pytest from esphome.storage_json import StorageJSON -PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"] +PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52", "host"] def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]: From 5c780ed5f561e0c08ddfa0586f79fd3fc7b494f2 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:19:26 +1300 Subject: [PATCH 052/170] [mipi_spi] Remove redundant pca9554 requires from Waveshare SPD2010 model (#22125) --- esphome/components/mipi_spi/models/spd2010.py | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/mipi_spi/models/spd2010.py b/esphome/components/mipi_spi/models/spd2010.py index c8ed4cb4bf7..06066350fa8 100644 --- a/esphome/components/mipi_spi/models/spd2010.py +++ b/esphome/components/mipi_spi/models/spd2010.py @@ -418,7 +418,6 @@ SPD2010.extend( "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46", cs_pin=21, reset_pin={"pca9554": None, "number": 1}, - requires={"pca9554"}, ) # The panel reset is wired to the chip reset, so only the software reset is available. From 506cafd7efcc956bbab2f7f5374ec0de81e79674 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:24:02 -1000 Subject: [PATCH 053/170] [core] Build the follow entity publish statement from codegen expressions (#22103) --- esphome/core/entity_helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index e7a14c43554..d499c0143cc 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -535,7 +535,9 @@ class FollowEntity: def __call__(self, entity: MockObj, source: MockObj, *args: SafeExpType) -> None: """Make ``entity`` publish every state of ``source``; ``args`` precede the callback.""" - publish = f"{parent_ref(entity).publish_state(RawExpression(self.value))};" + publish = cg.statement( + parent_ref(entity).publish_state(RawExpression(self.value)) + ) callback = LambdaExpression( [publish], list(self.params), capture="", return_type=cg.void ) From f3c828b71ff2d3dbda1ac76bd5422c4c8111d6a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:29:11 -1000 Subject: [PATCH 054/170] [socket] Size Zephyr's poll table from the socket budget (#22089) --- esphome/components/socket/__init__.py | 14 +++++++ tests/component_tests/socket/__init__.py | 0 .../socket/config/esp32_udp.yaml | 13 +++++++ .../socket/config/nrf52_udp.yaml | 16 ++++++++ .../socket/test_zephyr_poll_max.py | 39 +++++++++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 tests/component_tests/socket/__init__.py create mode 100644 tests/component_tests/socket/config/esp32_udp.yaml create mode 100644 tests/component_tests/socket/config/nrf52_udp.yaml create mode 100644 tests/component_tests/socket/test_zephyr_poll_max.py diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index 1163e2aa426..ef6da5249d6 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -129,6 +129,15 @@ def get_socket_counts() -> SocketCounts: ) +def zephyr_poll_max(counts: SocketCounts) -> int: + """Zephyr poll table size: the socket budget with the usual minimums.""" + return ( + max(counts.tcp, MIN_TCP_SOCKETS) + + max(counts.udp, MIN_UDP_SOCKETS) + + max(counts.tcp_listen, MIN_TCP_LISTEN_SOCKETS) + ) + + def require_wake_loop_threadsafe() -> None: """Deprecated: wake loop support is now always available on all platforms. @@ -281,6 +290,11 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_SOCKETS", True) zephyr_add_prj_conf("POSIX_API", True) + # Zephyr's default of 3 poll entries is too few once API, OTA, mDNS and UDP + # run together + zephyr_add_prj_conf( + "NET_SOCKETS_POLL_MAX", zephyr_poll_max(get_socket_counts()) + ) # ESP32 and LibreTiny both have LwIP >= 2.1.3 with lwip_socket_dbg_get_socket() # and FreeRTOS task notifications — enable fast select to bypass lwip_select(). # Only when not using lwip_tcp, which does not provide select() support. diff --git a/tests/component_tests/socket/__init__.py b/tests/component_tests/socket/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/socket/config/esp32_udp.yaml b/tests/component_tests/socket/config/esp32_udp.yaml new file mode 100644 index 00000000000..0c191e009b0 --- /dev/null +++ b/tests/component_tests/socket/config/esp32_udp.yaml @@ -0,0 +1,13 @@ +esphome: + name: sockettest + +esp32: + board: esp32dev + +wifi: + ssid: test + password: password1 + +udp: + addresses: + - 10.0.0.1 diff --git a/tests/component_tests/socket/config/nrf52_udp.yaml b/tests/component_tests/socket/config/nrf52_udp.yaml new file mode 100644 index 00000000000..0c41d979583 --- /dev/null +++ b/tests/component_tests/socket/config/nrf52_udp.yaml @@ -0,0 +1,16 @@ +esphome: + name: sockettest + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +network: + enable_ipv6: true + +openthread: + tlv: 0E080000000000010000 + +udp: + addresses: + - "ff12::cafe" diff --git a/tests/component_tests/socket/test_zephyr_poll_max.py b/tests/component_tests/socket/test_zephyr_poll_max.py new file mode 100644 index 00000000000..be217fb3e37 --- /dev/null +++ b/tests/component_tests/socket/test_zephyr_poll_max.py @@ -0,0 +1,39 @@ +"""Tests for the Zephyr poll table size the socket component writes to prj.conf.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.components.socket import SocketCounts, zephyr_poll_max +from esphome.components.zephyr import zephyr_data +from esphome.components.zephyr.const import KEY_PRJ_CONF, KEY_ZEPHYR +from esphome.core import CORE + +POLL_MAX = "CONFIG_NET_SOCKETS_POLL_MAX" + + +def _counts(tcp: int, udp: int, tcp_listen: int) -> SocketCounts: + return SocketCounts(tcp, udp, tcp_listen, "", "", "") + + +def test_small_budget_uses_the_minimums() -> None: + assert zephyr_poll_max(_counts(0, 1, 0)) == 16 + + +def test_large_budget_is_summed() -> None: + assert zephyr_poll_max(_counts(10, 7, 3)) == 20 + + +def test_nrf52_build_sets_the_poll_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("nrf52_udp.yaml")) + assert zephyr_data()[KEY_PRJ_CONF][""][POLL_MAX][0] == 16 + + +def test_other_platforms_leave_it_unset( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("esp32_udp.yaml")) + assert KEY_ZEPHYR not in CORE.data From c22f1d64c5cc599ed5c71111a9c780b9ec0a7c7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:29:41 -1000 Subject: [PATCH 055/170] [components] Use the global App in the remaining test fixtures (#22085) --- .../filter_lifetime/test_filter_lifetime.cpp | 10 ---------- tests/components/i2c/test_i2c_chip_id.cpp | 9 --------- tests/components/panasonic_ir/test_panasonic_ir.cpp | 8 -------- tests/components/vnc/test_input_host.cpp | 3 --- 4 files changed, 30 deletions(-) diff --git a/tests/components/filter_lifetime/test_filter_lifetime.cpp b/tests/components/filter_lifetime/test_filter_lifetime.cpp index 812475c4452..5918b2e6abe 100644 --- a/tests/components/filter_lifetime/test_filter_lifetime.cpp +++ b/tests/components/filter_lifetime/test_filter_lifetime.cpp @@ -39,10 +39,6 @@ class FilterLifetimeTest : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - // The test main keeps App alive; this test replaces it with a fresh one it owns - App.~Application(); - new (&App) Application(); - App.pre_setup("filter-lifetime-tests", 21, "Filter lifetime tests", 21); host::setup_preferences(); host::host_preferences->reset(); g_is_on = true; @@ -54,12 +50,6 @@ class FilterLifetimeTest : public ::testing::Test { }); } - void TearDown() override { - host::host_preferences->reset(); - App.~Application(); - new (&App) Application(); // later tests and the test listener use App - } - void attach(FilterLifetime &filter) { EntityIdentity::configure(filter, "Filter", FILTER_HASH); filter.set_max_lifetime(MAX_MINUTES); diff --git a/tests/components/i2c/test_i2c_chip_id.cpp b/tests/components/i2c/test_i2c_chip_id.cpp index a3b567d917d..4a8a5be2cc6 100644 --- a/tests/components/i2c/test_i2c_chip_id.cpp +++ b/tests/components/i2c/test_i2c_chip_id.cpp @@ -1,9 +1,6 @@ #include -#include - #include "esphome/components/i2c/i2c.h" -#include "esphome/core/application.h" #include "esphome/core/component.h" namespace esphome::i2c::testing { @@ -33,12 +30,6 @@ class ChipDevice : public Component, public I2CDevice {}; class I2CChipIdTest : public ::testing::Test { protected: void SetUp() override { - // The test main keeps App alive; this test replaces it with a fresh one it owns - App.~Application(); - new (&App) Application(); - static char name[] = "i2c-chip-id-tests"; - static char friendly_name[] = "I2C chip ID tests"; - App.pre_setup(name, sizeof(name) - 1, friendly_name, sizeof(friendly_name) - 1); this->device_.set_i2c_bus(&this->bus_); this->device_.set_i2c_address(0x40); } diff --git a/tests/components/panasonic_ir/test_panasonic_ir.cpp b/tests/components/panasonic_ir/test_panasonic_ir.cpp index b55b96ea5e0..f8b996f1953 100644 --- a/tests/components/panasonic_ir/test_panasonic_ir.cpp +++ b/tests/components/panasonic_ir/test_panasonic_ir.cpp @@ -1,13 +1,11 @@ #include #include #include -#include #include #include #include "esphome/components/panasonic_ir/panasonic_ir.h" #include "esphome/components/remote_base/remote_base.h" -#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" @@ -418,12 +416,6 @@ struct Fixture { explicit Fixture(Model model = Model::PKE, bool restore = false) { if (!restore && global_preferences != nullptr) global_preferences->reset(); - // The test main does not construct App as generated code does - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; - static char name[] = "test_panasonic_ir"; - static char friendly_name[] = ""; - App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); this->climate.set_model(model); this->climate.set_transmitter(&this->transmitter); static_cast(this->climate).setup(); diff --git a/tests/components/vnc/test_input_host.cpp b/tests/components/vnc/test_input_host.cpp index a00f5e77636..19216ed4584 100644 --- a/tests/components/vnc/test_input_host.cpp +++ b/tests/components/vnc/test_input_host.cpp @@ -510,9 +510,6 @@ static uint16_t mapped(int raw, int raw_max, int size) { class VncTouchscreen : public VncInput { protected: void SetUp() override { - // The test main does not construct App; the touchscreen defers its listener calls to the scheduler. - static const bool APP_CONSTRUCTED = (new (&App) Application(), true); - (void) APP_CONSTRUCTED; this->begin(960, 540, 960, 540); this->ts_.set_calibration(0, 960, 0, 540); this->ts_.attach(this->d_.get()); From df519ca14c2e63062d029c9900bf462660c0043d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:31:34 -1000 Subject: [PATCH 056/170] [pipsolar] Publish an unknown state instead of a bogus value for missing fields (#22084) --- esphome/components/pipsolar/pipsolar.cpp | 26 +-- tests/components/pipsolar/pipsolar_test.cpp | 181 ++++++++++++++++++++ 2 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 tests/components/pipsolar/pipsolar_test.cpp diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index 19d4df9aa18..6e3b158f2f6 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -6,6 +6,9 @@ namespace esphome::pipsolar { ESPHOME_LOG_TAG(TAG, "pipsolar"); +// value_or(NAN) on an integer optional converts NaN to int, which is undefined +static float int_or_nan(optional value) { return value.has_value() ? static_cast(*value) : NAN; } + void Pipsolar::setup() { this->state_ = STATE_IDLE; this->command_start_millis_ = 0; @@ -323,7 +326,7 @@ void Pipsolar::handle_qpiri_(const char *message) { esphome::optional pv_power_balance = parse_number(this->read_field_(message, &pos)); if (this->input_voltage_range_) { - this->input_voltage_range_->publish_state(input_voltage_range.value_or(NAN)); + this->input_voltage_range_->publish_state(int_or_nan(input_voltage_range)); } // special for input voltage range switch if (this->input_voltage_range_switch_ && input_voltage_range.has_value()) { @@ -331,7 +334,7 @@ void Pipsolar::handle_qpiri_(const char *message) { } if (this->output_source_priority_) { - this->output_source_priority_->publish_state(output_source_priority.value_or(NAN)); + this->output_source_priority_->publish_state(int_or_nan(output_source_priority)); } // special for output source priority switches if (this->output_source_priority_utility_switch_ && output_source_priority.has_value()) { @@ -348,7 +351,7 @@ void Pipsolar::handle_qpiri_(const char *message) { } if (this->pv_ok_condition_for_parallel_) { - this->pv_ok_condition_for_parallel_->publish_state(pv_ok_condition_for_parallel.value_or(NAN)); + this->pv_ok_condition_for_parallel_->publish_state(int_or_nan(pv_ok_condition_for_parallel)); } // special for pv ok condition switch if (this->pv_ok_condition_for_parallel_switch_ && pv_ok_condition_for_parallel.has_value()) { @@ -356,7 +359,7 @@ void Pipsolar::handle_qpiri_(const char *message) { } if (this->pv_power_balance_) { - this->pv_power_balance_->publish_state(pv_power_balance.value_or(NAN)); + this->pv_power_balance_->publish_state(int_or_nan(pv_power_balance)); } // special for power balance switch if (this->pv_power_balance_switch_ && pv_power_balance.has_value()) { @@ -406,7 +409,7 @@ void Pipsolar::handle_qpigs_(const char *message) { esphome::optional battery_voltage_offset_for_fans_on = parse_number(this->read_field_(message, &pos)); if (this->battery_voltage_offset_for_fans_on_) { - this->battery_voltage_offset_for_fans_on_->publish_state(battery_voltage_offset_for_fans_on.value_or(NAN) / 10.0f); + this->battery_voltage_offset_for_fans_on_->publish_state(int_or_nan(battery_voltage_offset_for_fans_on) / 10.0f); } this->read_int_sensor_(message, &pos, this->eeprom_version_); this->read_int_sensor_(message, &pos, this->pv_charging_power_); @@ -655,12 +658,12 @@ void Pipsolar::handle_qpiws_(const char *message) { this->publish_binary_sensor_(value_faults_present, this->faults_present_); if (this->fault_code_) { - if (flags.length() < 33) { - this->fault_code_->publish_state(NAN); - } else { - std::string fc(flags, 31, 2); - this->fault_code_->publish_state(parse_number(fc).value_or(NAN)); + optional fault; + if (flags.length() >= 33) { + if (auto code = parse_number(std::string(flags, 31, 2))) + fault = *code != 0; } + this->publish_binary_sensor_(fault, this->fault_code_); } } @@ -733,8 +736,7 @@ void Pipsolar::read_float_sensor_(const char *message, size_t *pos, sensor::Sens void Pipsolar::read_int_sensor_(const char *message, size_t *pos, sensor::Sensor *sensor) { if (sensor != nullptr) { std::string field = this->read_field_(message, pos); - esphome::optional parsed = parse_number(field); - sensor->publish_state(parsed.has_value() ? parsed.value() : NAN); + sensor->publish_state(int_or_nan(parse_number(field))); } else { this->skip_field_(message, pos); } diff --git a/tests/components/pipsolar/pipsolar_test.cpp b/tests/components/pipsolar/pipsolar_test.cpp new file mode 100644 index 00000000000..828c98f69ca --- /dev/null +++ b/tests/components/pipsolar/pipsolar_test.cpp @@ -0,0 +1,181 @@ +#include +#include + +#include +#include +#include +#include + +#include "esphome/components/pipsolar/pipsolar.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/helpers.h" + +namespace esphome::pipsolar::testing { + +namespace { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector rx; + + void write_array(const uint8_t *data, size_t len) override {} + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) + return false; + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t *data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +// Payload plus the inverter's CRC (bytes 0x28, 0x0D and 0x0A bumped by one) and CR +std::vector frame(const std::string &payload, bool corrupt_crc = false) { + std::vector out(payload.begin(), payload.end()); + uint16_t crc = crc16be(out.data(), out.size()); + uint8_t hi = crc >> 8, lo = crc & 0xFF; + for (uint8_t *b : {&hi, &lo}) { + if (*b == 0x28 || *b == 0x0D || *b == 0x0A) + (*b)++; + } + if (corrupt_crc) + lo ^= 0x01; + out.insert(out.end(), {hi, lo, 0x0D}); + return out; +} + +class PipsolarTest : public ::testing::Test { + protected: + void SetUp() override { this->sut_.set_uart_parent(&this->uart_); } + + // Runs one poll of the single enabled command and decodes the answer + void poll_(const std::vector &answer) { + this->sut_.setup(); + this->sut_.update(); + this->sut_.loop(); // sends the poll + this->uart_.rx.insert(this->uart_.rx.end(), answer.begin(), answer.end()); + this->sut_.loop(); // reads the answer + this->sut_.loop(); // checks the CRC + this->sut_.loop(); // decodes + } + + MockUARTComponent uart_; + Pipsolar sut_; +}; + +constexpr const char *QPIRI_PREFIX = "(230.0 21.7 230.0 50.0 21.7 5000 4000 48.0 46.0 42.0 56.4 54.0 1 10 010 "; +constexpr const char *QPIRI_GOOD_TAIL = "1 2 1 9 01 0 0 54.0 0 1"; +// Ends just before the fan offset field +constexpr const char *QPIGS_PREFIX = + "(230.0 50.0 230.0 50.0 0161 0105 003 397 52.70 000 100 0035 0000 000.0 00.00 00000 00010101"; + +class PipsolarQpiriTest : public PipsolarTest { + protected: + void SetUp() override { + PipsolarTest::SetUp(); + this->sut_.set_battery_type(&this->battery_type_); + this->sut_.set_input_voltage_range(&this->input_voltage_range_); + this->sut_.set_output_source_priority(&this->output_source_priority_); + this->sut_.set_pv_ok_condition_for_parallel(&this->pv_ok_condition_for_parallel_); + this->sut_.set_pv_power_balance(&this->pv_power_balance_); + } + + // The four QPIRI fields that used to publish INT_MIN when missing + void expect_optional_fields_nan_() { + EXPECT_THAT(this->input_voltage_range_.state, ::testing::IsNan()); + EXPECT_THAT(this->output_source_priority_.state, ::testing::IsNan()); + EXPECT_THAT(this->pv_ok_condition_for_parallel_.state, ::testing::IsNan()); + EXPECT_THAT(this->pv_power_balance_.state, ::testing::IsNan()); + } + + sensor::Sensor battery_type_; + sensor::Sensor input_voltage_range_; + sensor::Sensor output_source_priority_; + sensor::Sensor pv_ok_condition_for_parallel_; + sensor::Sensor pv_power_balance_; +}; + +class PipsolarQpigsTest : public PipsolarTest { + protected: + void SetUp() override { + PipsolarTest::SetUp(); + this->sut_.set_battery_voltage_offset_for_fans_on(&this->offset_); + } + + sensor::Sensor offset_; +}; + +class PipsolarQpiwsTest : public PipsolarTest { + protected: + void SetUp() override { + PipsolarTest::SetUp(); + this->sut_.set_fault_code(&this->fault_code_); + } + + binary_sensor::BinarySensor fault_code_; +}; + +} // namespace + +TEST_F(PipsolarQpiriTest, IntegerFieldsPublishValues) { + this->poll_(frame(std::string(QPIRI_PREFIX) + QPIRI_GOOD_TAIL)); + EXPECT_FLOAT_EQ(this->battery_type_.state, 1.0f); + EXPECT_FLOAT_EQ(this->input_voltage_range_.state, 1.0f); + EXPECT_FLOAT_EQ(this->output_source_priority_.state, 2.0f); + EXPECT_FLOAT_EQ(this->pv_ok_condition_for_parallel_.state, 0.0f); + EXPECT_FLOAT_EQ(this->pv_power_balance_.state, 1.0f); +} + +TEST_F(PipsolarQpiriTest, UnparseableFieldsPublishNan) { + this->poll_(frame(std::string(QPIRI_PREFIX) + "x x 1 9 01 0 0 54.0 x x")); + EXPECT_FLOAT_EQ(this->battery_type_.state, 1.0f); + this->expect_optional_fields_nan_(); +} + +// A failed poll decodes an empty answer, so every field is missing +TEST_F(PipsolarQpiriTest, FailedPollPublishesNan) { + this->poll_(frame(std::string(QPIRI_PREFIX) + QPIRI_GOOD_TAIL, true)); + EXPECT_THAT(this->battery_type_.state, ::testing::IsNan()); + this->expect_optional_fields_nan_(); +} + +TEST_F(PipsolarQpigsTest, FanOffsetPublishesValue) { + this->poll_(frame(std::string(QPIGS_PREFIX) + " 03 00 00000 010")); + EXPECT_FLOAT_EQ(this->offset_.state, 0.3f); +} + +TEST_F(PipsolarQpigsTest, MissingFanOffsetPublishesNan) { + this->poll_(frame(QPIGS_PREFIX)); + EXPECT_THAT(this->offset_.state, ::testing::IsNan()); +} + +TEST_F(PipsolarQpiwsTest, FaultCodePublishesOn) { + this->poll_(frame("(0000000000000000000000000000000050000")); + EXPECT_TRUE(this->fault_code_.has_state()); + EXPECT_TRUE(this->fault_code_.state); +} + +TEST_F(PipsolarQpiwsTest, ZeroFaultCodePublishesOff) { + this->poll_(frame("(0000000000000000000000000000000000000")); + EXPECT_TRUE(this->fault_code_.has_state()); + EXPECT_FALSE(this->fault_code_.state); +} + +TEST_F(PipsolarQpiwsTest, MissingFaultCodePublishesUnknown) { + this->poll_(frame("(0000000000000000000000000000000")); + EXPECT_FALSE(this->fault_code_.has_state()); +} + +TEST_F(PipsolarQpiwsTest, UnparseableFaultCodePublishesUnknown) { + this->poll_(frame("(0000000000000000000000000000000xx0000")); + EXPECT_FALSE(this->fault_code_.has_state()); +} + +} // namespace esphome::pipsolar::testing From 2bf52e72ecad4d3c1ee362ae7ef1d878064beb57 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:31:59 -1000 Subject: [PATCH 057/170] [tmp102][packet_interface] Use the global App in the test fixtures (#22083) --- .../packet_interface/hdlc_reliability.cpp | 19 +++++++------------ .../packet_interface_transport.cpp | 11 ----------- .../packet_interface/xxtea_encryption.cpp | 17 +---------------- tests/components/tmp102/test_tmp102.cpp | 8 +------- 4 files changed, 9 insertions(+), 46 deletions(-) diff --git a/tests/components/packet_interface/hdlc_reliability.cpp b/tests/components/packet_interface/hdlc_reliability.cpp index 1fdd119c035..5abd671da04 100644 --- a/tests/components/packet_interface/hdlc_reliability.cpp +++ b/tests/components/packet_interface/hdlc_reliability.cpp @@ -56,6 +56,11 @@ struct Peer { [this](const PacketBuffer &data, const PacketMetaData &) { this->received.push_back(to_vector(data)); }); this->hdlc->add_on_session_state_callback([this](bool connected) { this->states.push_back(connected); }); } + // Drop the layer's pending timers (ids 0 to 2) so none outlives it + ~Peer() { + for (uint32_t id = 0; id < 3; ++id) + App.scheduler.cancel_timeout(this->hdlc.get(), id); + } bool send(const Frame &data) { return this->hdlc->send_to_interface(PacketBuffer(data), {}); } MockLink link; @@ -108,20 +113,10 @@ static Frame make_frame(uint8_t control, const Frame &payload) { return frame; } -// Each test replaces App with a fresh one; the retransmit timer needs its scheduler class HdlcReliabilityTest : public ::testing::Test { protected: - void SetUp() override { - App.~Application(); - new (&App) Application(); - static char name[] = "hdlc-tests"; - static char friendly_name[] = ""; - App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); - } - void TearDown() override { - App.~Application(); - new (&App) Application(); // later tests and the test listener use App - } + // Free the timers each Peer cancelled, so none is left in the scheduler for a later test + void TearDown() override { App.scheduler.call(millis()); } }; TEST_F(HdlcReliabilityTest, MaxPacketSizeAllowsForOverhead) { diff --git a/tests/components/packet_interface/packet_interface_transport.cpp b/tests/components/packet_interface/packet_interface_transport.cpp index ae98dcaaecf..27a6fdd50c1 100644 --- a/tests/components/packet_interface/packet_interface_transport.cpp +++ b/tests/components/packet_interface/packet_interface_transport.cpp @@ -3,7 +3,6 @@ #include #include #include "esphome/components/packet_interface/packet_transport/packet_interface_transport.h" -#include "esphome/core/application.h" namespace esphome::packet_interface::transport_testing { @@ -48,22 +47,12 @@ static Packet ping_packet(const char *hostname, uint32_t key) { return packet; } -// Each test replaces App with a fresh one, which the transport's setup() reads the device name from class PacketInterfaceTransportTest : public ::testing::Test { protected: void SetUp() override { - App.~Application(); - new (&App) Application(); - static char name[] = "bridge-tests"; - static char friendly_name[] = ""; - App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); this->bridge_.set_encryption_key(std::vector(32, 0xAA)); this->bridge_.setup(); } - void TearDown() override { - App.~Application(); - new (&App) Application(); // later tests and the test listener use App - } FakeInterface link_; FakeLayer layer_{&this->link_}; diff --git a/tests/components/packet_interface/xxtea_encryption.cpp b/tests/components/packet_interface/xxtea_encryption.cpp index 09e206e64df..2e3b68602e0 100644 --- a/tests/components/packet_interface/xxtea_encryption.cpp +++ b/tests/components/packet_interface/xxtea_encryption.cpp @@ -4,7 +4,6 @@ #include #include #include "esphome/components/packet_interface/xxtea_encryption.h" -#include "esphome/core/application.h" namespace esphome::packet_interface::encryption_testing { @@ -74,21 +73,7 @@ struct Peer { std::vector received; }; -// Each test replaces App with a fresh one -class XxteaEncryptionTest : public ::testing::Test { - protected: - void SetUp() override { - App.~Application(); - new (&App) Application(); - static char name[] = "xxtea-tests"; - static char friendly_name[] = ""; - App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); - } - void TearDown() override { - App.~Application(); - new (&App) Application(); // later tests and the test listener use App - } -}; +class XxteaEncryptionTest : public ::testing::Test {}; TEST_F(XxteaEncryptionTest, MaxPacketSizeAllowsForTrailerAndWords) { Peer a; diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index 081dfba8d2c..7cfa424e50d 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -71,10 +71,6 @@ class TMP102Test : public ::testing::Test { unsigned publications_{0}; void SetUp() override { - // The test main keeps App alive; this test replaces it with a fresh one it owns - App.~Application(); - new (&App) Application(); - App.pre_setup("tmp102-regression-tests", 23, "TMP102 tests", 12); host::setup_preferences(); host::host_preferences->reset(); this->chip_.set_i2c_bus(&this->bus_); @@ -90,12 +86,10 @@ class TMP102Test : public ::testing::Test { } } + // Drop the chip's pending read so it does not outlive the chip into a later test void TearDown() override { App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); App.scheduler.call(millis()); - host::host_preferences->reset(); - App.~Application(); - new (&App) Application(); // later tests and the test listener use App } static void run_timers(uint32_t duration) { From 12ae7a24870410d4de4ec2072d54994f601375cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:33:35 -1000 Subject: [PATCH 058/170] [api] Add tests pinning homeassistant.action and event code generation (#22030) --- .../api/test_homeassistant_send.py | 125 ++++++++++++++++++ .../api/test_homeassistant_send.yaml | 53 ++++++++ tests/components/homeassistant/common.yaml | 16 +++ 3 files changed, 194 insertions(+) create mode 100644 tests/component_tests/api/test_homeassistant_send.py create mode 100644 tests/component_tests/api/test_homeassistant_send.yaml diff --git a/tests/component_tests/api/test_homeassistant_send.py b/tests/component_tests/api/test_homeassistant_send.py new file mode 100644 index 00000000000..d1e1bd03057 --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_send.py @@ -0,0 +1,125 @@ +"""Tests for the code generated for homeassistant.action, homeassistant.event and tag_scanned.""" + +from collections.abc import Callable +from pathlib import Path +import re + +import pytest + +CONFIG = "tests/component_tests/api/test_homeassistant_send.yaml" + + +@pytest.fixture(name="main_cpp") +def fixture_main_cpp(generate_main: Callable[[str | Path], str]) -> str: + return generate_main(CONFIG) + + +def _table(main_cpp: str, name: str) -> str: + match = re.search(rf"\b{name}\[\] PROGMEM = \{{\n(.*?)\n\}};", main_cpp, re.DOTALL) + assert match is not None, name + return match.group(1) + + +def test_field_order(main_cpp: str) -> None: + """The name comes first, then data, data_template and variables, in that order.""" + table = _table(main_cpp, "ha_action_fields") + assert re.fullmatch( + r" \{nullptr, progmem_str, nullptr\},\n" + r" \{progmem_str_2, progmem_str_3, nullptr\},\n" + r" \{progmem_str_4, progmem_str_5, nullptr\},\n" + r" api::HomeAssistantField<>::from_lambda\(progmem_str_6, \[\]\(\) \{\n" + r".*return App\.get_name\(\);\n.*\n\}\),", + table, + re.DOTALL, + ), table + assert 'progmem_str[] PROGMEM = "notify.notify";' in main_cpp + assert 'progmem_str_2[] PROGMEM = "message";' in main_cpp + assert 'progmem_str_4[] PROGMEM = "title";' in main_cpp + assert 'progmem_str_6[] PROGMEM = "name";' in main_cpp + + +def test_action_without_response(main_cpp: str) -> None: + """An action with no response handling is sent with the counts of each map.""" + assert ( + "api::HomeAssistantServiceCallAction<>" + "(api_apiserver_id, false, ha_action_fields, 1, 1, 1);" + ) in main_cpp + + +def test_event_and_tag_scanned(main_cpp: str) -> None: + """Events and scanned tags are sent as events.""" + assert ( + "api::HomeAssistantServiceCallAction<>" + "(api_apiserver_id, true, ha_action_fields_5, 0, 0, 0);" + ) in main_cpp + assert ( + "api::HomeAssistantServiceCallAction<>" + "(api_apiserver_id, true, ha_action_fields_6, 1, 0, 0);" + ) in main_cpp + assert " {progmem_str_13, progmem_str_14, nullptr}," in _table( + main_cpp, "ha_action_fields_6" + ) + + +@pytest.mark.parametrize( + ("table", "body"), + [ + ("ha_action_fields_2", "level *= 2; return level;"), + ("ha_action_fields_2", "return level *= 3;"), + ("ha_action_fields_3", "level *= 2; return level;"), + ], +) +def test_lambdas_take_args_by_value(main_cpp: str, table: str, body: str) -> None: + """A field lambda gets the trigger args by value, so its body may modify them.""" + assert re.search( + r"from_lambda\(\w+, \[\]\(int level\) \{\n.*\n " + re.escape(body), + _table(main_cpp, table), + ) + + +def test_script_actions(main_cpp: str) -> None: + """Script actions take the script parameters.""" + assert ( + "api::HomeAssistantServiceCallAction" + "(api_apiserver_id, false, ha_action_fields_2, 2, 0, 0);" + ) in main_cpp + assert ( + "api::HomeAssistantServiceCallAction" + "(api_apiserver_id, true, ha_action_fields_3, 1, 0, 0);" + ) in main_cpp + + +def test_action_with_response_is_an_object(main_cpp: str) -> None: + """on_error waits for the response, so the action keeps its object and trigger.""" + match = re.search( + r"new\((\w+)\) api::HomeAssistantServiceCallAction<>" + r"\(api_apiserver_id, false, ha_action_fields_4, 0, 0, 0\);", + main_cpp, + ) + assert match is not None + action = match.group(1) + assert f"{action}->set_wants_status();" in main_cpp + assert f"Automation({action}->get_error_trigger());" in main_cpp + + +def test_action_then_log(main_cpp: str) -> None: + """The action sends before the action after it runs.""" + actions = re.findall(r"\bautomation_id->add_action\((\w+)\);", main_cpp) + assert actions == ["api_homeassistantservicecallaction_id", "lambdaaction_id"] + + +def test_button_on_press(main_cpp: str) -> None: + """A button sends its event and scanned tag from its press callback.""" + assert re.search( + r"add_on_press_callback\(TriggerForwarder<>\{automation_id_5\}\);", main_cpp + ) + sends = { + match.group(2): match.group(1) + for match in re.finditer( + r"new\((\w+)\) api::HomeAssistantServiceCallAction<>" + r"\(api_apiserver_id, true, (ha_action_fields_[56]), [01], 0, 0\);", + main_cpp, + ) + } + actions = re.findall(r"\bautomation_id_5->add_action\((\w+)\);", main_cpp) + assert actions == [sends["ha_action_fields_5"], sends["ha_action_fields_6"]] diff --git a/tests/component_tests/api/test_homeassistant_send.yaml b/tests/component_tests/api/test_homeassistant_send.yaml new file mode 100644 index 00000000000..dd0d088813f --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_send.yaml @@ -0,0 +1,53 @@ +esphome: + name: test + on_boot: + then: + - homeassistant.action: + action: notify.notify + data: + message: Booted + data_template: + title: "{{ name }}" + variables: + name: !lambda 'return App.get_name();' + - logger.log: after action + +esp8266: + board: esp01_1m + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + +script: + - id: ha_by_value + parameters: + level: int + then: + - homeassistant.service: + service: light.turn_on + data: + doubled: !lambda 'level *= 2; return level;' + tripled: !lambda 'return level *= 3;' + - homeassistant.event: + event: esphome.level + data: + doubled: !lambda 'level *= 2; return level;' + - id: ha_response + then: + - homeassistant.action: + action: notify.notify + on_error: + - logger.log: failed + +button: + - platform: template + name: Ring + on_press: + - homeassistant.event: + event: esphome.ring + - homeassistant.tag_scanned: front_door diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index f39b3e1b2d2..d9b68f0f1d5 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -28,6 +28,22 @@ esphome: api: +# Field lambdas get the script parameters by value, so they may modify them +script: + - id: ha_by_value + parameters: + level: int + then: + - homeassistant.action: + action: light.turn_on + data: + doubled: !lambda "level *= 2; return level;" + tripled: !lambda "return level *= 3;" + - homeassistant.event: + event: esphome.level + data: + doubled: !lambda "level *= 2; return level;" + switch: - platform: homeassistant entity_id: automation.my_cool_automation From efe7a5a7de085113716e2bb4a9823176a99653f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:33:51 -1000 Subject: [PATCH 059/170] [binary_sensor] Add tests for the click and double click triggers (#22031) --- .../binary_sensor/test_click.py | 25 +++ .../binary_sensor/test_click.yaml | 27 +++ tests/components/binary_sensor/__init__.py | 7 +- .../binary_sensor/click_trigger_test.cpp | 201 ++++++++++++++++++ tests/components/binary_sensor/common.yaml | 14 +- 5 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 tests/component_tests/binary_sensor/test_click.py create mode 100644 tests/component_tests/binary_sensor/test_click.yaml create mode 100644 tests/components/binary_sensor/click_trigger_test.cpp diff --git a/tests/component_tests/binary_sensor/test_click.py b/tests/component_tests/binary_sensor/test_click.py new file mode 100644 index 00000000000..b01513e2dff --- /dev/null +++ b/tests/component_tests/binary_sensor/test_click.py @@ -0,0 +1,25 @@ +"""Tests for the click and double click trigger codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from esphome.core import CORE + + +def test_click_triggers(generate_main: Callable[[str | Path], str]) -> None: + """Each trigger gets its timing in milliseconds, defaults 50 and 350, and its own automation.""" + main_cpp = generate_main("tests/component_tests/binary_sensor/test_click.yaml") + + triggers = re.findall( + r"new\((\w+)\) binary_sensor::(Click|DoubleClick)Trigger\(button_a, (\d+), (\d+)\);", + main_cpp, + ) + assert [trigger[1:] for trigger in triggers] == [ + ("Click", "50", "350"), + ("Click", "1000", "3000"), + ("DoubleClick", "20", "400"), + ] + for trigger_id, *_ in triggers: + assert f"Automation<>({trigger_id});" in main_cpp + assert "USE_BINARY_SENSOR_CLICK_TRIGGER" in {define.name for define in CORE.defines} diff --git a/tests/component_tests/binary_sensor/test_click.yaml b/tests/component_tests/binary_sensor/test_click.yaml new file mode 100644 index 00000000000..b744d1323d6 --- /dev/null +++ b/tests/component_tests/binary_sensor/test_click.yaml @@ -0,0 +1,27 @@ +--- +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +binary_sensor: + - platform: gpio + id: button_a + pin: GPIO32 + on_click: + - then: + - logger.log: a click + - min_length: 1s + max_length: 3s + then: + - logger.log: a long click + - delay: 1s + - logger.log: a long click done + on_double_click: + - min_length: 20ms + max_length: 400ms + then: + - logger.log: a double click diff --git a/tests/components/binary_sensor/__init__.py b/tests/components/binary_sensor/__init__.py index 9578a7c5d55..86046ccf6db 100644 --- a/tests/components/binary_sensor/__init__.py +++ b/tests/components/binary_sensor/__init__.py @@ -2,7 +2,10 @@ from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code def override_manifest(manifest: ComponentManifestOverride) -> None: - # filter.cpp only compiles with the define the real to_code emits for configured filters + # filter.cpp and automation.cpp only compile with the defines the real to_code emits add_defines_to_code( - manifest, "USE_BINARY_SENSOR_FILTER", "USE_BINARY_SENSOR_RAW_CALLBACK" + manifest, + "USE_BINARY_SENSOR_FILTER", + "USE_BINARY_SENSOR_RAW_CALLBACK", + "USE_BINARY_SENSOR_CLICK_TRIGGER", ) diff --git a/tests/components/binary_sensor/click_trigger_test.cpp b/tests/components/binary_sensor/click_trigger_test.cpp new file mode 100644 index 00000000000..09138cd08c7 --- /dev/null +++ b/tests/components/binary_sensor/click_trigger_test.cpp @@ -0,0 +1,201 @@ +#include +#include + +#include "esphome/components/binary_sensor/automation.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/binary_sensor/filter.h" +#include "esphome/core/automation.h" +#include "esphome/core/base_automation.h" +#include "esphome/core/hal.h" + +namespace esphome::binary_sensor::testing { + +// The triggers read the real millis(), so every wait is at least 80 ms away from a window edge +static constexpr uint32_t MIN_MS = 100; +static constexpr uint32_t MAX_MS = 300; +static constexpr uint32_t SHORT_MS = 20; +static constexpr uint32_t INSIDE_MS = 200; +static constexpr uint32_t LONG_MS = 450; + +static int fired = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +class ClickTriggerTest : public ::testing::Test { + protected: + void SetUp() override { + fired = 0; + this->sensor_.publish_state(false); + this->automation_.add_action(&this->action_); + } + + // As codegen builds on_click and on_double_click + void add_click_(uint32_t min_length = MIN_MS, uint32_t max_length = MAX_MS, BinarySensor *sensor = nullptr) { + this->click_ = std::make_unique(sensor != nullptr ? sensor : &this->sensor_, min_length, max_length); + this->click_->set_automation_parent(&this->automation_); + } + void add_double_click_() { + this->double_click_ = std::make_unique(&this->sensor_, MIN_MS, MAX_MS); + this->double_click_->set_automation_parent(&this->automation_); + } + + void press_for_(uint32_t length) { + this->sensor_.publish_state(true); + delay(length); + this->sensor_.publish_state(false); + } + + BinarySensor sensor_; + StatelessLambdaAction<> action_{[]() { fired++; }}; + Automation<> automation_; + std::unique_ptr click_; + std::unique_ptr double_click_; +}; + +TEST(ClickMatchInterval, BoundsAreInclusive) { + EXPECT_FALSE(match_interval(50, 350, 49)); + EXPECT_TRUE(match_interval(50, 350, 50)); + EXPECT_TRUE(match_interval(50, 350, 350)); + EXPECT_FALSE(match_interval(50, 350, 351)); + // A max of 0 means no maximum + EXPECT_TRUE(match_interval(50, 0, UINT32_MAX)); + EXPECT_FALSE(match_interval(50, 0, 49)); +} + +TEST_F(ClickTriggerTest, ClickInsideWindowFiresOnRelease) { + this->add_click_(); + this->sensor_.publish_state(true); + delay(INSIDE_MS); + EXPECT_EQ(fired, 0); + this->sensor_.publish_state(false); + EXPECT_EQ(fired, 1); + this->press_for_(INSIDE_MS); + EXPECT_EQ(fired, 2); +} + +TEST_F(ClickTriggerTest, ClickOutsideWindowDoesNotFire) { + this->add_click_(); + this->press_for_(SHORT_MS); + this->press_for_(LONG_MS); + EXPECT_EQ(fired, 0); +} + +TEST_F(ClickTriggerTest, ClickWithoutMaximumFiresOnLongPress) { + this->add_click_(0, 0); + this->press_for_(LONG_MS); + EXPECT_EQ(fired, 1); +} + +TEST_F(ClickTriggerTest, RepeatedPressDoesNotRestartClick) { + this->add_click_(); + this->sensor_.publish_state(true); + delay(INSIDE_MS); + // Not a state change, so the click is now 400 ms long + this->sensor_.publish_state(true); + delay(INSIDE_MS); + this->sensor_.publish_state(false); + EXPECT_EQ(fired, 0); +} + +TEST_F(ClickTriggerTest, ClickSeesFilteredState) { + InvertFilter invert; + this->sensor_.add_filter(&invert); + this->add_click_(); + // Inverted: false presses and true releases + this->sensor_.publish_state(false); + delay(INSIDE_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 1); + this->sensor_.publish_state(false); + delay(LONG_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 1); +} + +TEST_F(ClickTriggerTest, InitialPressIsNotAClick) { + BinarySensor sensor; + sensor.set_trigger_on_initial_state(false); + this->add_click_(MIN_MS, MAX_MS, &sensor); + // As codegen sets it: the first state skips the callback, so the click is timed from 0 + sensor.publish_state(true); + delay(INSIDE_MS); + sensor.publish_state(false); + EXPECT_EQ(fired, 0); +} + +TEST_F(ClickTriggerTest, DoubleClickFiresOnSecondPress) { + this->add_double_click_(); + this->press_for_(INSIDE_MS); + delay(INSIDE_MS); + EXPECT_EQ(fired, 0); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 1); + // The second press is not timed + delay(LONG_MS); + this->sensor_.publish_state(false); + EXPECT_EQ(fired, 1); +} + +TEST_F(ClickTriggerTest, DoubleClickNeedsFirstPressInWindow) { + this->add_double_click_(); + this->press_for_(SHORT_MS); + delay(INSIDE_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 0); + this->sensor_.publish_state(false); + this->press_for_(LONG_MS); + delay(INSIDE_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 0); +} + +TEST_F(ClickTriggerTest, DoubleClickNeedsGapInWindow) { + this->add_double_click_(); + this->press_for_(INSIDE_MS); + delay(SHORT_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 0); + this->sensor_.publish_state(false); + this->press_for_(INSIDE_MS); + delay(LONG_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 0); +} + +TEST_F(ClickTriggerTest, DoubleClickWindowSlidesOverEdges) { + this->add_double_click_(); + // A gap too long, then a press and gap in the window: the last release and press match + this->press_for_(INSIDE_MS); + delay(LONG_MS); + this->press_for_(INSIDE_MS); + delay(INSIDE_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 1); +} + +TEST_F(ClickTriggerTest, DoubleClickResetsAfterFiring) { + this->add_double_click_(); + this->press_for_(INSIDE_MS); + delay(INSIDE_MS); + this->press_for_(INSIDE_MS); + EXPECT_EQ(fired, 1); + // The release after a double click does not pair with its edges: the next press only starts one + delay(INSIDE_MS); + this->press_for_(INSIDE_MS); + EXPECT_EQ(fired, 1); + delay(INSIDE_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 2); +} + +TEST_F(ClickTriggerTest, DoubleClickDoesNotFireTwiceWithoutReset) { + this->add_double_click_(); + this->press_for_(INSIDE_MS); + delay(INSIDE_MS); + this->press_for_(SHORT_MS); + EXPECT_EQ(fired, 1); + // Without the reset, the first release and the short second press would pair with this gap + delay(INSIDE_MS); + this->sensor_.publish_state(true); + EXPECT_EQ(fired, 1); +} + +} // namespace esphome::binary_sensor::testing diff --git a/tests/components/binary_sensor/common.yaml b/tests/components/binary_sensor/common.yaml index 453be5cdec6..91952ff539e 100644 --- a/tests/components/binary_sensor/common.yaml +++ b/tests/components/binary_sensor/common.yaml @@ -164,10 +164,16 @@ binary_sensor: id: click_triggers name: "Click Triggers" on_click: - min_length: 50ms - max_length: 350ms - then: - - logger.log: "Clicked" + - min_length: 50ms + max_length: 350ms + then: + - logger.log: "Clicked" + # delay is not an inline action + - min_length: 1s + max_length: 3s + then: + - delay: 10ms + - logger.log: "Long clicked" on_double_click: min_length: 50ms max_length: 350ms From d85b41be44cf27a3c5b2a15384d620069866fdc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:34:08 -1000 Subject: [PATCH 060/170] [script] Add tests for script code generation and synchronous scripts (#22036) --- tests/component_tests/script/__init__.py | 0 .../script/test_script_codegen.py | 160 ++++++++++++++ .../script/test_script_codegen.yaml | 105 +++++++++ tests/components/script/common.yaml | 30 +++ .../fixtures/script_synchronous.yaml | 209 ++++++++++++++++++ tests/integration/test_script_synchronous.py | 127 +++++++++++ 6 files changed, 631 insertions(+) create mode 100644 tests/component_tests/script/__init__.py create mode 100644 tests/component_tests/script/test_script_codegen.py create mode 100644 tests/component_tests/script/test_script_codegen.yaml create mode 100644 tests/integration/fixtures/script_synchronous.yaml create mode 100644 tests/integration/test_script_synchronous.py diff --git a/tests/component_tests/script/__init__.py b/tests/component_tests/script/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/script/test_script_codegen.py b/tests/component_tests/script/test_script_codegen.py new file mode 100644 index 00000000000..d9a03332fa3 --- /dev/null +++ b/tests/component_tests/script/test_script_codegen.py @@ -0,0 +1,160 @@ +"""Code generation of scripts. + +Objects are found from the script ids and the links between them, not from auto-numbered +ids, so adding or changing one script does not break the others. +""" + +import re + +CONFIG = "tests/component_tests/script/test_script_codegen.yaml" + + +def _constructed(main_cpp: str, id_: str) -> str: + match = re.search(rf"\bnew\({id_}\) (.*?);\n", main_cpp, re.DOTALL) + assert match is not None, id_ + return match.group(1) + + +def _function_body(main_cpp: str, name: str) -> str: + match = re.search( + rf"static void {name}\([^;{{]*\) \{{(.*?)\n\}}\n", main_cpp, re.DOTALL + ) + assert match is not None, name + return match.group(1) + + +def _automation(main_cpp: str, trigger: str) -> str: + match = re.search(rf"\bnew\((\w+)\) Automation<.*?>\({trigger}\);", main_cpp) + assert match is not None, trigger + return match.group(1) + + +def _actions_of(main_cpp: str, automation: str) -> list[str]: + return re.findall(rf"\b{automation}->add_action\((\w+)\);", main_cpp) + + +def _single_action(main_cpp: str, script: str) -> str: + (action,) = _actions_of(main_cpp, _automation(main_cpp, script)) + return action + + +def _run_function(main_cpp: str, script: str) -> str: + """Return the function of the one merged action the script's Automation runs.""" + constructed = _constructed(main_cpp, _single_action(main_cpp, script)) + match = re.match(r"ApplyRunAction<(\w+)[,>]", constructed) + assert match is not None, constructed + return match.group(1) + + +def test_inline_actions_merge_into_one_action(generate_main) -> None: + """A script of inline actions runs them as one merged action of an Automation.""" + main_cpp = generate_main(CONFIG) + assert _constructed(main_cpp, "inline_single") == "script::SingleScript<>()" + body = _function_body(main_cpp, _run_function(main_cpp, "inline_single")) + assert '"INLINE first"' in body + assert "counter->value() += 1;" in body + # A script can be stopped from inside, so the rest of the run checks for it + assert "if (!esphome__running)\n return;" in body + + +def test_one_lambda_action(generate_main) -> None: + """A single inline action keeps its own action object.""" + main_cpp = generate_main(CONFIG) + constructed = _constructed(main_cpp, _single_action(main_cpp, "one_action")) + assert constructed.startswith("StatelessLambdaAction<>(") + assert '"ONE action"' in constructed + + +def test_modes(generate_main) -> None: + """Each mode has its own class; max_runs is set on queued and parallel scripts.""" + main_cpp = generate_main(CONFIG) + assert _constructed(main_cpp, "inline_restart") == "script::RestartScript<>()" + assert _constructed(main_cpp, "inline_queued") == "script::QueueingScript<>()" + assert _constructed(main_cpp, "inline_parallel") == "script::ParallelScript<>()" + assert "inline_queued->set_max_runs(3);" in main_cpp + assert "inline_parallel->set_max_runs(2);" in main_cpp + assert re.search(r"App\.register_component_\(inline_queued, \d+\);", main_cpp) + for script in ("inline_queued", "inline_parallel"): + constructed = _constructed(main_cpp, _single_action(main_cpp, script)) + assert constructed.startswith("StatelessLambdaAction<>(") + + +def test_restart_executes_itself(generate_main) -> None: + """A script can execute itself; on_stop has its own Automation.""" + main_cpp = generate_main(CONFIG) + body = _function_body(main_cpp, _run_function(main_cpp, "inline_restart")) + assert "::inline_restart->execute();" in body + stop = _automation(main_cpp, r"inline_restart->get_stop_trigger\(\)") + (action,) = _actions_of(main_cpp, stop) + assert '"RESTART stopped"' in _constructed(main_cpp, action) + + +def test_parameters_by_value(generate_main) -> None: + """Lambdas take the parameters by value, so ``x *= 2`` compiles in every form.""" + main_cpp = generate_main(CONFIG) + assert ( + _constructed(main_cpp, "with_params") + == "script::SingleScript()" + ) + name = _run_function(main_cpp, "with_params") + assert re.search( + rf"static void {name}\(const int & esphome__running, " + r"const std::remove_cvref_t & x, " + r"const std::remove_cvref_t & s\)", + main_cpp, + ) + body = _function_body(main_cpp, name) + assert body.count("[](int x, std::string s) -> void {") == 2 + assert body.count("[](int x, std::string s) -> int {") == 2 + assert "x *= 2;\n ESP_LOGI(" in body + assert '"BY_VALUE again %d", x *= 2' in body + assert "return x *= 2;" in body + assert "x *= 2;\n return x;" in body + assert body.count("::sink->execute(std::move(esphome__arg_0));") == 2 + + +def test_script_stop_inside(generate_main) -> None: + """``script.stop`` without an id stops the script it is in; the next action checks.""" + main_cpp = generate_main(CONFIG) + body = _function_body(main_cpp, _run_function(main_cpp, "stops_itself")) + stop, rest = body.split("::stops_itself->stop();") + assert '"STOPS start"' in stop + assert rest.lstrip("\n }").startswith("if (!esphome__running)\n return;") + assert '"STOPS not reached"' in rest + + +def test_calls_itself_with_parameter(generate_main) -> None: + """A parallel script with a parameter can execute itself from an ``if``.""" + main_cpp = generate_main(CONFIG) + assert _constructed(main_cpp, "calls_itself") == "script::ParallelScript()" + body = _function_body(main_cpp, _run_function(main_cpp, "calls_itself")) + assert "return n > 0;" in body + assert "::calls_itself->execute(std::move(esphome__arg_0));" in body + + +def test_delay_keeps_separate_actions(generate_main) -> None: + """A delay keeps the Automation and its separate actions.""" + main_cpp = generate_main(CONFIG) + assert _constructed(main_cpp, "with_delay") == "script::SingleScript<>()" + before, delay, after = _actions_of(main_cpp, _automation(main_cpp, "with_delay")) + assert _constructed(main_cpp, delay) == "DelayAction<>()" + assert '"DELAY before"' in _constructed(main_cpp, before) + assert '"DELAY after"' in _constructed(main_cpp, after) + + +def test_manual_automation_id(generate_main) -> None: + """An automation_id set by hand names the Automation, so a lambda can reach it.""" + main_cpp = generate_main(CONFIG) + assert _automation(main_cpp, "manual_automation") == "manual_automation_id" + (action,) = _actions_of(main_cpp, "manual_automation_id") + assert '"MANUAL"' in _constructed(main_cpp, action) + + +def test_script_wait(generate_main) -> None: + """``script.wait`` is a component templated on the class of the script it waits for.""" + main_cpp = generate_main(CONFIG) + (action,) = _actions_of(main_cpp, _automation(main_cpp, "waits")) + assert ( + _constructed(main_cpp, action) + == "script::ScriptWaitAction>(inline_single)" + ) diff --git a/tests/component_tests/script/test_script_codegen.yaml b/tests/component_tests/script/test_script_codegen.yaml new file mode 100644 index 00000000000..c45aed5f852 --- /dev/null +++ b/tests/component_tests/script/test_script_codegen.yaml @@ -0,0 +1,105 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +globals: + - id: counter + type: int + +script: + # Inline actions only + - id: inline_single + then: + - logger.log: "INLINE first" + - lambda: id(counter) += 1; + + - id: one_action + then: + - logger.log: "ONE action" + + - id: inline_restart + mode: restart + on_stop: + - logger.log: "RESTART stopped" + then: + - logger.log: "RESTART run" + - script.execute: inline_restart + + - id: inline_queued + mode: queued + max_runs: 3 + then: + - logger.log: "QUEUED run" + + - id: inline_parallel + mode: parallel + max_runs: 2 + then: + - logger.log: "PARALLEL run" + + - id: with_params + parameters: + x: int + s: string + then: + - lambda: |- + x *= 2; + ESP_LOGI("test", "BY_VALUE %d %s", x, s.c_str()); + - logger.log: + format: "BY_VALUE again %d" + args: [x *= 2] + - script.execute: + id: sink + y: !lambda return x *= 2; + - script.execute: + id: sink + y: !lambda |- + x *= 2; + return x; + + - id: sink + parameters: + y: int + then: + - logger.log: + format: "SINK %d" + args: [y] + + - id: stops_itself + then: + - logger.log: "STOPS start" + - script.stop + - logger.log: "STOPS not reached" + + - id: calls_itself + mode: parallel + parameters: + n: int + then: + - if: + condition: + lambda: return n > 0; + then: + - script.execute: + id: calls_itself + n: !lambda return n - 1; + + # Not all inline + - id: with_delay + then: + - logger.log: "DELAY before" + - delay: 1s + - logger.log: "DELAY after" + + - id: manual_automation + automation_id: manual_automation_id + then: + - logger.log: "MANUAL" + + - id: waits + then: + - script.wait: inline_single diff --git a/tests/components/script/common.yaml b/tests/components/script/common.yaml index 4f73ce2ed9a..e7edd8c6642 100644 --- a/tests/components/script/common.yaml +++ b/tests/components/script/common.yaml @@ -1,6 +1,18 @@ esphome: on_boot: then: + # External components keep scripts as script::Script<...> pointers + - lambda: |- + script::Script *by_base = id(my_script_by_value_params); + by_base->execute(1); + if (by_base->is_running()) + by_base->stop(); + script::Script<> *single = id(my_script); + single->execute(); + single->stop(); + script::Script<> *queued = id(my_script_queued); + queued->execute(); + queued->stop(); - script.execute: my_script - script.execute: id: my_script_with_params @@ -143,3 +155,21 @@ script: value: !lambda |- int total = iteration; return total; + # Lambdas take the parameters by value, so each form can change its own copy + - id: my_script_by_value_params + parameters: + x: int + then: + - lambda: |- + x *= 2; + ESP_LOGD("main", "x=%d", x); + - logger.log: + format: "again %d" + args: [x *= 2] + - script.execute: + id: my_script_with_params + prefix: !lambda |- + x *= 2; + return std::to_string(x); + param2: !lambda return x *= 2; + param3: true diff --git a/tests/integration/fixtures/script_synchronous.yaml b/tests/integration/fixtures/script_synchronous.yaml new file mode 100644 index 00000000000..a462e0033d8 --- /dev/null +++ b/tests/integration/fixtures/script_synchronous.yaml @@ -0,0 +1,209 @@ +esphome: + name: test-script-synchronous + +host: + +api: + actions: + - action: run_single + then: + - globals.set: + id: depth + value: "0" + - script.execute: single_script + - action: run_restart + then: + - globals.set: + id: depth + value: "0" + - script.execute: restart_script + - action: run_queued + then: + - script.execute: + id: queued_script + n: 1 + - action: run_parallel + then: + - script.execute: + id: parallel_script + n: 1 + - action: run_params + variables: + value: int + label: string + then: + - script.execute: + id: params_script + x: !lambda return value; + s: !lambda return label; + - action: run_stop + then: + - script.execute: stop_script + - action: run_wait + then: + - script.execute: sync_script + - script.wait: sync_script + - logger.log: "WAIT done" + - action: run_outside + then: + - script.execute: sync_script + - if: + condition: + script.is_running: sync_script + then: + - logger.log: "OUTSIDE running" + else: + - logger.log: "OUTSIDE idle" + - action: run_recursive + then: + - script.execute: + id: recursive_script + n: 3 + +logger: + level: DEBUG + +globals: + - id: depth + type: int + +script: + # Executing a running single script from its own body is refused with a warning + - id: single_script + then: + - logger.log: "SINGLE start" + - if: + condition: + lambda: return id(depth)++ == 0; + then: + - script.execute: single_script + - logger.log: "SINGLE end" + + # Executing a running restart script from its own body stops the outer run + - id: restart_script + mode: restart + on_stop: + - logger.log: "RESTART on_stop" + then: + - logger.log: "RESTART start" + - if: + condition: + lambda: return id(depth)++ == 0; + then: + - script.execute: restart_script + - logger.log: "RESTART end" + + # max_runs counts the running instance and the queued ones + - id: queued_script + mode: queued + max_runs: 2 + parameters: + n: int + then: + - logger.log: + format: "QUEUED run %d" + args: [n] + - if: + condition: + lambda: return n == 1; + then: + - script.execute: + id: queued_script + n: 2 + - script.execute: + id: queued_script + n: 3 + + # Runs nested in the body count against max_runs + - id: parallel_script + mode: parallel + max_runs: 2 + parameters: + n: int + then: + - logger.log: + format: "PARALLEL run %d" + args: [n] + - if: + condition: + lambda: return n < 3; + then: + - script.execute: + id: parallel_script + n: !lambda return n + 1; + - logger.log: + format: "PARALLEL end %d" + args: [n] + + # Lambdas get the parameters by value, so each one can change its own copy + - id: params_script + parameters: + x: int + s: string + then: + - lambda: |- + x *= 2; + ESP_LOGI("test", "PARAMS lambda %d %s", x, s.c_str()); + - logger.log: + format: "PARAMS again %d %s" + args: [x *= 2, s.c_str()] + - script.execute: + id: params_sink + label: first + y: !lambda return x *= 2; + - script.execute: + id: params_sink + label: second + y: !lambda |- + x *= 2; + return x; + + - id: params_sink + parameters: + label: string + y: int + then: + - logger.log: + format: "PARAMS %s %d" + args: [label.c_str(), y] + + - id: stop_script + on_stop: + - logger.log: "STOP on_stop" + then: + - logger.log: "STOP start" + - script.stop + - logger.log: "STOP not reached" + + # Runs a script that waits for this one, which is running until its body ends + - id: sync_script + then: + - logger.log: "SYNC start" + - if: + condition: + script.is_running: sync_script + then: + - logger.log: "SYNC running inside" + - script.execute: waiter_script + - logger.log: "SYNC end" + + - id: waiter_script + then: + - script.wait: sync_script + - logger.log: "WAITER done" + + - id: recursive_script + mode: parallel + parameters: + n: int + then: + - if: + condition: + lambda: return n > 0; + then: + - script.execute: + id: recursive_script + n: !lambda return n - 1; + - logger.log: + format: "RECURSIVE %d" + args: [n] diff --git a/tests/integration/test_script_synchronous.py b/tests/integration/test_script_synchronous.py new file mode 100644 index 00000000000..a8233ba4c37 --- /dev/null +++ b/tests/integration/test_script_synchronous.py @@ -0,0 +1,127 @@ +"""Integration test for scripts whose actions all finish before execute() returns.""" + +from __future__ import annotations + +import asyncio +import re + +from aioesphomeapi import UserService +import pytest + +from .log_utils import LineWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_synchronous( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Script modes, stop, wait and parameters behave the same for a synchronous body.""" + waiter = LineWaiter() + + count = waiter.count + + def in_order(*texts: str) -> bool: + """Whether the first log lines containing ``texts`` come in that order.""" + first: list[int] = [] + for text in texts: + index = next( + (i for i, line in enumerate(waiter.lines) if text in line), None + ) + assert index is not None, f"{text!r} not logged" + first.append(index) + return first == sorted(first) + + async with ( + run_compiled(yaml_config, line_callback=waiter.callback), + api_client_connected() as client, + ): + _, services = await client.list_entities_services() + service: dict[str, UserService] = {s.name: s for s in services} + + # single: executing itself from its body is refused, the body still finishes + await client.execute_service(service["run_single"], {}) + await waiter.wait_for("SINGLE end", timeout=5.0) + assert count("SINGLE start") == 1 + assert count("Script 'single_script' is already running! (mode: single)") == 1 + + # restart: the nested run stops the outer one, which skips its remaining actions + await client.execute_service(service["run_restart"], {}) + await waiter.wait_for("RESTART end", timeout=5.0) + assert count("Script 'restart_script' restarting (mode: restart)") == 1 + assert count("RESTART start") == 2 + assert count("RESTART on_stop") == 1 + + # queued: the run executed from the body waits for it, the third is refused + await client.execute_service(service["run_queued"], {}) + await waiter.wait_for("QUEUED run 2", timeout=5.0) + assert count("Script 'queued_script' queueing new instance (mode: queued)") == 1 + assert ( + count("Script 'queued_script' max instances (running + queued) reached!") + == 1 + ) + assert in_order("QUEUED run 1", "queueing new instance", "QUEUED run 2") + + # parallel: a run nested in the body counts against max_runs + await client.execute_service(service["run_parallel"], {}) + await waiter.wait_for("PARALLEL end 1", timeout=5.0) + assert count("PARALLEL run 1") == 1 + assert count("PARALLEL run 2") == 1 + assert count("PARALLEL run 3") == 0 + assert count("PARALLEL end 2") == 1 + assert ( + count("Script 'parallel_script' maximum number of parallel runs exceeded!") + == 1 + ) + + # parameters: each lambda doubles its own copy of x + await client.execute_service( + service["run_params"], {"value": 5, "label": "hello"} + ) + await waiter.wait_for("PARAMS second", timeout=5.0) + assert count("PARAMS lambda 10 hello") == 1 + assert count("PARAMS again 10 hello") == 1 + assert count("PARAMS first 10") == 1 + assert count("PARAMS second 10") == 1 + + # script.stop in the body skips the rest and fires on_stop + await client.execute_service(service["run_stop"], {}) + await waiter.wait_for("STOP on_stop", timeout=5.0) + + # running while its body runs, so a script.wait from inside waits for the end; + # script.wait after execute() returns does not wait at all + await client.execute_service(service["run_wait"], {}) + await waiter.wait_for("WAITER done", timeout=5.0) + assert in_order( + "SYNC start", "SYNC running inside", "SYNC end", "WAIT done", "WAITER done" + ) + + # not running once execute() returns + await client.execute_service(service["run_outside"], {}) + await waiter.wait_for("OUTSIDE idle", timeout=5.0) + await waiter.wait_count("WAITER done", 2, timeout=5.0) + assert count("OUTSIDE idle") == 1 + assert count("OUTSIDE running") == 0 + + # a parallel script can call itself; the innermost run ends first + await client.execute_service(service["run_recursive"], {}) + await waiter.wait_for("RECURSIVE 3", timeout=5.0) + recursive = [ + match.group(1) + for line in waiter.lines + if (match := re.search(r"RECURSIVE (\d)", line)) + ] + assert recursive == ["0", "1", "2", "3"] + + await asyncio.sleep(0.2) + assert count("STOP start") == 1 + assert count("STOP not reached") == 0 + assert count("STOP on_stop") == 1 + assert count("SINGLE start") == 1 + assert count("RESTART end") == 1 + assert count("QUEUED run 1") == 1 + assert count("QUEUED run 2") == 1 + assert count("QUEUED run 3") == 0 + assert count("WAITER done") == 2 From 87b720539755513c528bd673195463d77050ec29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:34:32 -1000 Subject: [PATCH 061/170] [thermopro_ble] Add parser unit tests (#22047) --- tests/components/thermopro_ble/common.h | 45 +++++++ .../sensor/test_thermopro_ble.cpp | 116 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 tests/components/thermopro_ble/common.h create mode 100644 tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp diff --git a/tests/components/thermopro_ble/common.h b/tests/components/thermopro_ble/common.h new file mode 100644 index 00000000000..673bb0aed13 --- /dev/null +++ b/tests/components/thermopro_ble/common.h @@ -0,0 +1,45 @@ +#pragma once + +#include "esphome/components/ble_device_base/ble_device.h" + +#include +#include + +namespace esphome::thermopro_ble::testing { + +// Least significant byte first, as from_scan_result() takes them +inline constexpr uint8_t MAC[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; +inline constexpr uint8_t OTHER_MAC[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x77}; +inline constexpr int RSSI = -60; + +// Manufacturer data records (company ID little-endian, then payload) from the +// Bluetooth-Devices/thermopro-ble test suite. +inline const std::vector TP357 = {0xC2, 0xF1, 0x00, 0x1D, 0x02, 0x2C}; +inline const std::vector TP357S = {0xC2, 0xEE, 0x00, 0x29, 0x22, 0x0B, 0x01}; +inline const std::vector TP393 = {0xC2, 0xF2, 0x00, 0x35, 0x02, 0x2C}; +inline const std::vector TP960R = {0x00, 0x3A, 0x00, 0x30, 0x08, 0x38, 0x00}; +inline const std::vector TP970R = {0x00, 0x35, 0x00, 0x46, 0x0B, 0x35, 0x00}; +inline const std::vector TP972S = {0x00, 0x72, 0x00, 0x6A, 0x0A, 0x33, 0xD3, 0xB8, 0x42, 0x00, 0x40, 0xAE, + 0x42, 0x66, 0x06, 0xAE, 0x42, 0x6C, 0x54, 0x73, 0x77, 0x44, 0xF8}; +inline const std::vector INVALID_TP972 = {0xC2, 0xC7, 0x01, 0x8D, 0x00, 0xE1, 0x0A, 0x33, 0x13, + 0xFA, 0x42, 0x00, 0xC0, 0xFA, 0x42, 0x9A, 0xF9, 0xFA, + 0x42, 0x6C, 0x54, 0x73, 0x77, 0x44, 0xF8}; + +inline ble_device_base::ESPBTDevice device_from(const uint8_t *mac, const char *name, + const std::vector &record) { + std::vector adv; + if (name != nullptr) { + size_t len = std::strlen(name); + adv.push_back(static_cast(len + 1)); + adv.push_back(0x09); + adv.insert(adv.end(), name, name + len); + } + adv.push_back(static_cast(record.size() + 1)); + adv.push_back(0xFF); + adv.insert(adv.end(), record.begin(), record.end()); + ble_device_base::ESPBTDevice device; + device.from_scan_result(mac, RSSI, 0, adv.data(), static_cast(adv.size())); + return device; +} + +} // namespace esphome::thermopro_ble::testing diff --git a/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp b/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp new file mode 100644 index 00000000000..d5705ee0df2 --- /dev/null +++ b/tests/components/thermopro_ble/sensor/test_thermopro_ble.cpp @@ -0,0 +1,116 @@ +#include "../common.h" +#include "esphome/components/thermopro_ble/thermopro_ble.h" + +#include + +#include + +namespace esphome::thermopro_ble::testing { + +class ThermoProBLETest : public ::testing::Test { + protected: + void SetUp() override { + this->hub_.set_address(device_from(MAC, nullptr, {}).address_uint64()); + this->hub_.set_temperature_sensor(&this->temperature_); + this->hub_.set_external_temperature_sensor(&this->external_temperature_); + this->hub_.set_humidity_sensor(&this->humidity_); + this->hub_.set_battery_level_sensor(&this->battery_level_); + this->hub_.set_signal_strength_sensor(&this->signal_strength_); + } + + bool parse_(const char *name, const std::vector &record, const uint8_t *mac = MAC) { + return this->hub_.parse_device(device_from(mac, name, record)); + } + + bool nothing_published_() const { + return !this->temperature_.has_state() && !this->external_temperature_.has_state() && + !this->humidity_.has_state() && !this->battery_level_.has_state(); + } + + ThermoProBLE hub_; + sensor::Sensor temperature_; + sensor::Sensor external_temperature_; + sensor::Sensor humidity_; + sensor::Sensor battery_level_; + sensor::Sensor signal_strength_; +}; + +TEST_F(ThermoProBLETest, TP357) { + EXPECT_TRUE(this->parse_("TP357 (2142)", TP357)); + EXPECT_FLOAT_EQ(this->temperature_.state, 24.1f); + EXPECT_FLOAT_EQ(this->humidity_.state, 29.0f); + EXPECT_FLOAT_EQ(this->battery_level_.state, 100.0f); + EXPECT_FLOAT_EQ(this->signal_strength_.state, RSSI); + EXPECT_FALSE(this->external_temperature_.has_state()); +} + +TEST_F(ThermoProBLETest, TP357SSevenByteFrame) { + EXPECT_TRUE(this->parse_("TP357S (2142)", TP357S)); + EXPECT_FLOAT_EQ(this->temperature_.state, 23.8f); + EXPECT_FLOAT_EQ(this->humidity_.state, 41.0f); + EXPECT_FLOAT_EQ(this->battery_level_.state, 100.0f); +} + +TEST_F(ThermoProBLETest, TP393) { + EXPECT_TRUE(this->parse_("TP393 (9376)", TP393)); + EXPECT_FLOAT_EQ(this->temperature_.state, 24.2f); + EXPECT_FLOAT_EQ(this->humidity_.state, 53.0f); + EXPECT_FLOAT_EQ(this->battery_level_.state, 100.0f); +} + +TEST_F(ThermoProBLETest, TP3ShortFrameRejected) { + EXPECT_FALSE(this->parse_("TP357 (2142)", {0xC2, 0xF1, 0x00, 0x1D, 0x02})); + EXPECT_TRUE(this->nothing_published_()); +} + +TEST_F(ThermoProBLETest, TP960R) { + EXPECT_TRUE(this->parse_("TP960R", TP960R)); + EXPECT_FLOAT_EQ(this->temperature_.state, 28.0f); + EXPECT_FLOAT_EQ(this->external_temperature_.state, 26.0f); + EXPECT_NEAR(this->battery_level_.state, 9.10f, 0.01f); + EXPECT_FALSE(this->humidity_.has_state()); +} + +TEST_F(ThermoProBLETest, TP970RBatteryClampedTo100) { + EXPECT_TRUE(this->parse_("TP970R", TP970R)); + EXPECT_FLOAT_EQ(this->temperature_.state, 23.0f); + EXPECT_FLOAT_EQ(this->external_temperature_.state, 23.0f); + EXPECT_FLOAT_EQ(this->battery_level_.state, 100.0f); +} + +TEST_F(ThermoProBLETest, TP96WrongSizeRejected) { + EXPECT_FALSE(this->parse_("TP960R", {0x00, 0x3A, 0x00, 0x30, 0x08, 0x38})); + EXPECT_TRUE(this->nothing_published_()); +} + +TEST_F(ThermoProBLETest, TP972Battery) { + EXPECT_TRUE(this->parse_("TP972S", TP972S)); + EXPECT_NEAR(this->battery_level_.state, 90.43f, 0.01f); +} + +TEST_F(ThermoProBLETest, LargestFrameAccepted) { + std::vector record = TP357; + record.resize(24); + EXPECT_TRUE(this->parse_("TP357 (2142)", record)); + EXPECT_FLOAT_EQ(this->temperature_.state, 24.1f); +} + +TEST_F(ThermoProBLETest, OversizedFrameRejected) { + EXPECT_FALSE(this->parse_("TP972S", INVALID_TP972)); + EXPECT_TRUE(this->nothing_published_()); +} + +TEST_F(ThermoProBLETest, OtherMacIgnored) { + EXPECT_FALSE(this->parse_("TP357 (2142)", TP357, OTHER_MAC)); + EXPECT_TRUE(this->nothing_published_()); + EXPECT_FALSE(this->signal_strength_.has_state()); +} + +TEST_F(ThermoProBLETest, UnknownNameIgnored) { + EXPECT_FALSE(this->parse_("TP25", TP357)); + EXPECT_FALSE(this->parse_(nullptr, TP357)); + EXPECT_TRUE(this->nothing_published_()); + EXPECT_FALSE(this->signal_strength_.has_state()); +} + +} // namespace esphome::thermopro_ble::testing From 3a5cbb423cd783bc22628b607438a668b72775bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:34:52 -1000 Subject: [PATCH 062/170] [light] Use cg.add_optional for optional setters in cwww, rgbww and tuya (#22048) --- esphome/components/cwww/light.py | 10 ++++++---- esphome/components/rgbww/light.py | 14 ++++++-------- esphome/components/tuya/light/__init__.py | 15 +++++++-------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/esphome/components/cwww/light.py b/esphome/components/cwww/light.py index 3ea6a85923a..8b134d5dad0 100644 --- a/esphome/components/cwww/light.py +++ b/esphome/components/cwww/light.py @@ -37,11 +37,13 @@ async def to_code(config: ConfigType) -> None: await light.register_light(var, config) await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) - if cold_white_color_temperature := config.get(CONF_COLD_WHITE_COLOR_TEMPERATURE): - cg.add(var.set_cold_white_temperature(cold_white_color_temperature)) + cg.add_optional( + var.set_cold_white_temperature, config.get(CONF_COLD_WHITE_COLOR_TEMPERATURE) + ) await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) - if warm_white_color_temperature := config.get(CONF_WARM_WHITE_COLOR_TEMPERATURE): - cg.add(var.set_warm_white_temperature(warm_white_color_temperature)) + cg.add_optional( + var.set_warm_white_temperature, config.get(CONF_WARM_WHITE_COLOR_TEMPERATURE) + ) cg.add(var.set_constant_brightness(config[CONF_CONSTANT_BRIGHTNESS])) diff --git a/esphome/components/rgbww/light.py b/esphome/components/rgbww/light.py index 8881af6af45..b74fab2f03e 100644 --- a/esphome/components/rgbww/light.py +++ b/esphome/components/rgbww/light.py @@ -49,16 +49,14 @@ async def to_code(config: ConfigType) -> None: await cg.add_variable(var.set_green, config[CONF_GREEN]) await cg.add_variable(var.set_blue, config[CONF_BLUE]) await cg.add_variable(var.set_cold_white, config[CONF_COLD_WHITE]) - if CONF_COLD_WHITE_COLOR_TEMPERATURE in config: - cg.add( - var.set_cold_white_temperature(config[CONF_COLD_WHITE_COLOR_TEMPERATURE]) - ) + cg.add_optional( + var.set_cold_white_temperature, config.get(CONF_COLD_WHITE_COLOR_TEMPERATURE) + ) await cg.add_variable(var.set_warm_white, config[CONF_WARM_WHITE]) - if CONF_WARM_WHITE_COLOR_TEMPERATURE in config: - cg.add( - var.set_warm_white_temperature(config[CONF_WARM_WHITE_COLOR_TEMPERATURE]) - ) + cg.add_optional( + var.set_warm_white_temperature, config.get(CONF_WARM_WHITE_COLOR_TEMPERATURE) + ) cg.add(var.set_constant_brightness(config[CONF_CONSTANT_BRIGHTNESS])) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) diff --git a/esphome/components/tuya/light/__init__.py b/esphome/components/tuya/light/__init__.py index e4f41cf6e13..32357b4268b 100644 --- a/esphome/components/tuya/light/__init__.py +++ b/esphome/components/tuya/light/__init__.py @@ -81,8 +81,9 @@ async def to_code(config): await light.register_light(var, config) cg.add_optional(var.set_dimmer_id, config.get(CONF_DIMMER_DATAPOINT)) - if CONF_MIN_VALUE_DATAPOINT in config: - cg.add(var.set_min_value_datapoint_id(config[CONF_MIN_VALUE_DATAPOINT])) + cg.add_optional( + var.set_min_value_datapoint_id, config.get(CONF_MIN_VALUE_DATAPOINT) + ) cg.add_optional(var.set_switch_id, config.get(CONF_SWITCH_DATAPOINT)) if CONF_COLOR_DATAPOINT in config: cg.add(var.set_color_id(config[CONF_COLOR_DATAPOINT])) @@ -100,12 +101,10 @@ async def to_code(config): ) cg.add_optional(var.set_min_value, config.get(CONF_MIN_VALUE)) cg.add_optional(var.set_max_value, config.get(CONF_MAX_VALUE)) - if CONF_COLOR_TEMPERATURE_MAX_VALUE in config: - cg.add( - var.set_color_temperature_max_value( - config[CONF_COLOR_TEMPERATURE_MAX_VALUE] - ) - ) + cg.add_optional( + var.set_color_temperature_max_value, + config.get(CONF_COLOR_TEMPERATURE_MAX_VALUE), + ) cg.add(var.set_color_interlock(config[CONF_COLOR_INTERLOCK])) await cg.add_variable(var.set_tuya_parent, config[CONF_TUYA_ID]) From 5ebf08d70e33eb40eeee39f52829d66f6eaccfa1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:35:08 -1000 Subject: [PATCH 063/170] [thermostat] Add tests pinning validation messages and codegen order (#22049) --- .../thermostat/config/all_triggers.yaml | 106 +++++ .../thermostat/config/dehumidify.yaml | 31 ++ .../thermostat/config/heat_cool_true.yaml | 31 ++ .../thermostat/test_validation.py | 415 ++++++++++++++++++ 4 files changed, 583 insertions(+) create mode 100644 tests/component_tests/thermostat/config/all_triggers.yaml create mode 100644 tests/component_tests/thermostat/config/dehumidify.yaml create mode 100644 tests/component_tests/thermostat/config/heat_cool_true.yaml diff --git a/tests/component_tests/thermostat/config/all_triggers.yaml b/tests/component_tests/thermostat/config/all_triggers.yaml new file mode 100644 index 00000000000..29c9b765232 --- /dev/null +++ b/tests/component_tests/thermostat/config/all_triggers.yaml @@ -0,0 +1,106 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo + name: Thermo + sensor: temp_sensor + humidity_sensor: temp_sensor + idle_action: + - logger.log: idle_action + cool_action: + - logger.log: cool_action + supplemental_cooling_action: + - logger.log: supplemental_cooling_action + heat_action: + - logger.log: heat_action + supplemental_heating_action: + - logger.log: supplemental_heating_action + dry_action: + - logger.log: dry_action + fan_only_action: + - logger.log: fan_only_action + auto_mode: + - logger.log: auto_mode + off_mode: + - logger.log: off_mode + heat_mode: + - logger.log: heat_mode + cool_mode: + - logger.log: cool_mode + dry_mode: + - logger.log: dry_mode + fan_only_mode: + - logger.log: fan_only_mode + heat_cool_mode: + - logger.log: heat_cool_mode + fan_mode_on_action: + - logger.log: fan_mode_on_action + fan_mode_off_action: + - logger.log: fan_mode_off_action + fan_mode_auto_action: + - logger.log: fan_mode_auto_action + fan_mode_low_action: + - logger.log: fan_mode_low_action + fan_mode_medium_action: + - logger.log: fan_mode_medium_action + fan_mode_high_action: + - logger.log: fan_mode_high_action + fan_mode_middle_action: + - logger.log: fan_mode_middle_action + fan_mode_focus_action: + - logger.log: fan_mode_focus_action + fan_mode_diffuse_action: + - logger.log: fan_mode_diffuse_action + fan_mode_quiet_action: + - logger.log: fan_mode_quiet_action + swing_off_action: + - logger.log: swing_off_action + swing_horizontal_action: + - logger.log: swing_horizontal_action + swing_vertical_action: + - logger.log: swing_vertical_action + swing_both_action: + - logger.log: swing_both_action + target_humidity_change_action: + - logger.log: target_humidity_change_action + target_temperature_change_action: + - logger.log: target_temperature_change_action + humidity_control_humidify_action: + - logger.log: humidity_control_humidify_action + humidity_control_off_action: + - logger.log: humidity_control_off_action + preset_change: + - logger.log: preset_change + humidity_hysteresis: 0.5 + startup_delay: true + supplemental_cooling_delta: 2.0 + supplemental_heating_delta: 3.0 + cool_deadband: 0.6 + cool_overrun: 0.7 + heat_deadband: 0.8 + heat_overrun: 0.9 + min_cooling_off_time: 301s + min_cooling_run_time: 302s + max_cooling_run_time: 601s + min_heating_off_time: 303s + min_heating_run_time: 304s + max_heating_run_time: 602s + min_fanning_off_time: 31s + min_fanning_run_time: 32s + min_fan_mode_switching_time: 15s + min_idle_time: 30s + fan_only_action_uses_fan_mode_timer: true + fan_with_cooling: true diff --git a/tests/component_tests/thermostat/config/dehumidify.yaml b/tests/component_tests/thermostat/config/dehumidify.yaml new file mode 100644 index 00000000000..92bd91f54c2 --- /dev/null +++ b/tests/component_tests/thermostat/config/dehumidify.yaml @@ -0,0 +1,31 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo + name: Thermo + sensor: temp_sensor + humidity_sensor: temp_sensor + min_idle_time: 30s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + idle_action: + - logger.log: idle + cool_action: + - logger.log: cool + humidity_control_dehumidify_action: + - logger.log: dehumidify + humidity_control_off_action: + - logger.log: humidity off diff --git a/tests/component_tests/thermostat/config/heat_cool_true.yaml b/tests/component_tests/thermostat/config/heat_cool_true.yaml new file mode 100644 index 00000000000..40dd73eede4 --- /dev/null +++ b/tests/component_tests/thermostat/config/heat_cool_true.yaml @@ -0,0 +1,31 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo + name: Thermo + sensor: temp_sensor + min_idle_time: 30s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + min_heating_off_time: 300s + min_heating_run_time: 300s + idle_action: + - logger.log: idle + cool_action: + - logger.log: cool + heat_action: + - logger.log: heat + heat_cool_mode: true diff --git a/tests/component_tests/thermostat/test_validation.py b/tests/component_tests/thermostat/test_validation.py index c737c33b0bf..4c33792fb9d 100644 --- a/tests/component_tests/thermostat/test_validation.py +++ b/tests/component_tests/thermostat/test_validation.py @@ -2,6 +2,8 @@ from collections.abc import Callable from pathlib import Path +import re +import textwrap import pytest @@ -33,3 +35,416 @@ def test_cooling_timer_needs_cool_or_dry_action( assert ( "'min_cooling_off_time' requires 'cool_action' or 'dry_action'" in captured.out ) + + +_BASE = """\ +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo + name: Thermo + sensor: temp_sensor + min_idle_time: 30s + min_cooling_off_time: 300s + min_cooling_run_time: 300s + idle_action: + - logger.log: idle + cool_action: + - logger.log: cool +""" + + +def _read_error(tmp_path: Path, extra: str, capsys: pytest.CaptureFixture[str]) -> str: + """Validate the base cooling thermostat plus *extra* and return the error output.""" + path = tmp_path / "test.yaml" + path.write_text( + _BASE + textwrap.indent(textwrap.dedent(extra), " "), encoding="utf-8" + ) + CORE.config_path = path + assert read_config({}) is None + return re.sub(r"\x1b\[[0-9;]*m", "", capsys.readouterr().out) + + +@pytest.mark.parametrize( + ("extra", "message"), + [ + pytest.param( + """\ + preset: + - name: Away + default_target_temperature_high: 24°C + mode: HEAT + """, + "mode is set to HEAT for Away but heat_action is not present in the configuration", + id="preset_mode", + ), + pytest.param( + """\ + preset: + - name: Away + default_target_temperature_high: 24°C + mode: FAN_ONLY + """, + "mode is set to FAN_ONLY for Away but fan_only_action is not present in the configuration", + id="preset_mode_fan_only", + ), + pytest.param( + """\ + preset: + - name: Away + default_target_temperature_high: 24°C + fan_mode: LOW + """, + "fan_mode is set to LOW for Away but fan_mode_low_action is not present in the configuration", + id="preset_fan_mode", + ), + pytest.param( + """\ + fan_mode_low_action: + - logger.log: low + min_fan_mode_switching_time: 15s + preset: + - name: Away + default_target_temperature_high: 24°C + fan_mode: QUIET + """, + "fan_mode is set to QUIET for Away but fan_mode_quiet_action is not present in the configuration", + id="preset_fan_mode_other_action_set", + ), + pytest.param( + """\ + preset: + - name: Away + default_target_temperature_high: 24°C + swing_mode: VERTICAL + """, + "swing_mode is set to VERTICAL for Away but swing_vertical_action is not present in the configuration", + id="preset_swing_mode", + ), + pytest.param( + """\ + min_fan_mode_switching_time: 15s + """, + "At least one of fan_mode_on_action, fan_mode_off_action, fan_mode_auto_action, " + "fan_mode_low_action, fan_mode_medium_action, fan_mode_high_action, " + "fan_mode_middle_action, fan_mode_focus_action, fan_mode_diffuse_action, " + "fan_mode_quiet_action must be defined to use min_fan_mode_switching_time", + id="fan_mode_switching_time_without_fan_action", + ), + pytest.param( + """\ + fan_mode_diffuse_action: + - logger.log: diffuse + """, + "'fan_mode_diffuse_action' requires 'min_fan_mode_switching_time'", + id="fan_action_without_switching_time", + ), + pytest.param( + """\ + fan_with_cooling: true + """, + "fan_only_action must be defined to use fan_with_cooling", + id="fan_with_cooling", + ), + pytest.param( + """\ + fan_with_heating: true + """, + "fan_only_action must be defined to use fan_with_heating", + id="fan_with_heating", + ), + pytest.param( + """\ + fan_only_action: + - logger.log: fan + min_fanning_off_time: 30s + """, + "min_fanning_run_time must be defined to use fan_only_action", + id="fan_only_action_timers", + ), + pytest.param( + """\ + default_target_temperature_low: 18°C + """, + "default_target_temperature_low is no longer valid.", + id="legacy_low", + ), + pytest.param( + """\ + default_target_temperature_high: 24°C + """, + "default_target_temperature_high is no longer valid.", + id="legacy_high", + ), + pytest.param( + """\ + heat_cool_mode: false + """, + "heat_cool_mode cannot be 'false'", + id="heat_cool_mode_false", + ), + pytest.param( + """\ + humidity_sensor: temp_sensor + humidity_control_off_action: + - logger.log: humidity off + humidity_control_humidify_action: + - logger.log: humidify + humidity_control_dehumidify_action: + - logger.log: dehumidify + """, + "two or more values in the same group of exclusion 'humidity_control'", + id="humidity_control_exclusive", + ), + pytest.param( + """\ + preset: + - name: Away + default_target_temperature_high: 40°C + """, + "default_target_temperature_high for Away is set to 40.0 which is more than " + "the visual maximum temperature of 30.0", + id="preset_above_visual_max", + ), + pytest.param( + """\ + heat_action: + - logger.log: heat + min_heating_off_time: 300s + min_heating_run_time: 300s + preset: + - name: Away + default_target_temperature_high: 24°C + default_target_temperature_low: 5°C + """, + "default_target_temperature_low for Away is set to 5.0 which is less than " + "the visual minimum temperature of 10.0", + id="preset_below_visual_min", + ), + pytest.param( + """\ + fan_only_action: + - logger.log: fan + fan_only_action_uses_fan_mode_timer: true + """, + "min_fan_mode_switching_time must be defined to use fan_only_action", + id="fan_only_action_uses_fan_mode_timer", + ), + pytest.param( + """\ + on_boot_restore_from: default_preset + """, + "default_preset must be defined to use on_boot_restore_from in DEFAULT_PRESET mode", + id="restore_default_preset_without_default", + ), + pytest.param( + """\ + away_config: + default_target_temperature_high: 26°C + """, + "away_config is no longer valid. Please switch to using a preset named Away", + id="legacy_away_config", + ), + pytest.param( + """\ + default_mode: cool + """, + "default_mode is no longer valid. Please switch to using presets and specify a default_preset", + id="legacy_default_mode", + ), + pytest.param( + """\ + default_preset: Home + preset: + - name: Away + default_target_temperature_high: 24°C + """, + "default_preset set to 'Home' but no such preset has been defined. " + "Available presets: ['Away']", + id="default_preset_missing", + ), + ], +) +def test_invalid_configs( + tmp_path: Path, capsys: pytest.CaptureFixture[str], extra: str, message: str +) -> None: + """Each rule rejects the config with its own message.""" + assert message in _read_error(tmp_path, extra, capsys) + + +def test_legacy_low_suggests_preset( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The legacy target temperatures error prints an equivalent preset.""" + out = _read_error( + tmp_path, + """\ + default_target_temperature_low: 18°C + default_target_temperature_high: 24°C + """, + capsys, + ) + assert ( + " preset:\n" + " - name: Your new preset\n" + " default_target_temperature_low: 18.0\n" + " default_target_temperature_high: 24.0." + ) in out + + +def _assert_thermo_calls(main_cpp: str, expected: list[str]) -> None: + """The thermostat's calls are exactly *expected*, in order, with nothing extra.""" + calls = [line.strip() for line in main_cpp.splitlines() if "thermo->" in line] + assert len(calls) == len(expected), calls + for call, line in zip(calls, expected, strict=True): + assert call.endswith(line), f"{call!r} is not {line!r}" + + +def test_all_triggers_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Every trigger, supports flag and setting is generated in a fixed order.""" + main_cpp = generate_main(component_config_path("all_triggers.yaml")) + + expected = [ + "thermo->set_supports_two_points(true);", + "thermo->set_set_point_minimum_differential(0.5f);", + "thermo->set_sensor(temp_sensor);", + "thermo->set_humidity_sensor(temp_sensor);", + "thermo->set_cool_deadband(0.6f);", + "thermo->set_cool_overrun(0.7f);", + "thermo->set_heat_deadband(0.8f);", + "thermo->set_heat_overrun(0.9f);", + "thermo->set_cooling_maximum_run_time_in_sec(601);", + "thermo->set_heating_maximum_run_time_in_sec(602);", + "thermo->set_cooling_minimum_off_time_in_sec(301);", + "thermo->set_cooling_minimum_run_time_in_sec(302);", + "thermo->set_fan_mode_minimum_switching_time_in_sec(15);", + "thermo->set_fanning_minimum_off_time_in_sec(31);", + "thermo->set_fanning_minimum_run_time_in_sec(32);", + "thermo->set_heating_minimum_off_time_in_sec(303);", + "thermo->set_heating_minimum_run_time_in_sec(304);", + "thermo->set_supplemental_cool_delta(2.0f);", + "thermo->set_supplemental_heat_delta(3.0f);", + "thermo->set_idle_minimum_time_in_sec(30);", + "thermo->set_supports_fan_only_action_uses_fan_mode_timer(true);", + "thermo->set_supports_fan_only_cooling(false);", + "thermo->set_supports_fan_with_cooling(true);", + "thermo->set_supports_fan_with_heating(false);", + "thermo->set_use_startup_delay(true);", + "(thermo->get_idle_action_trigger());", + "(thermo->get_cool_action_trigger());", + "thermo->set_supports_cool(true);", + "(thermo->get_supplemental_cool_action_trigger());", + "(thermo->get_dry_action_trigger());", + "thermo->set_supports_dry(true);", + "(thermo->get_fan_only_action_trigger());", + "thermo->set_supports_fan_only(true);", + "(thermo->get_heat_action_trigger());", + "thermo->set_supports_heat(true);", + "(thermo->get_supplemental_heat_action_trigger());", + "(thermo->get_auto_mode_trigger());", + "thermo->set_supports_auto(true);", + "(thermo->get_cool_mode_trigger());", + "thermo->set_supports_cool(true);", + "(thermo->get_dry_mode_trigger());", + "thermo->set_supports_dry(true);", + "(thermo->get_fan_only_mode_trigger());", + "thermo->set_supports_fan_only(true);", + "(thermo->get_heat_mode_trigger());", + "thermo->set_supports_heat(true);", + "(thermo->get_heat_cool_mode_trigger());", + "thermo->set_supports_heat_cool(true);", + "(thermo->get_off_mode_trigger());", + ] + for mode in ( + "on", + "off", + "auto", + "low", + "medium", + "high", + "middle", + "focus", + "diffuse", + "quiet", + ): + expected += [ + f"(thermo->get_fan_mode_{mode}_trigger());", + f"thermo->set_supports_fan_mode_{mode}(true);", + ] + for mode in ("both", "horizontal", "off", "vertical"): + expected += [ + f"(thermo->get_swing_mode_{mode}_trigger());", + f"thermo->set_supports_swing_mode_{mode}(true);", + ] + expected += [ + "(thermo->get_humidity_change_trigger());", + "(thermo->get_temperature_change_trigger());", + "thermo->set_supports_humidification(true);", + "(thermo->get_humidity_control_humidify_action_trigger());", + "(thermo->get_humidity_control_off_action_trigger());", + "thermo->set_humidity_hysteresis(0.5f);", + "(thermo->get_preset_change_trigger());", + ] + _assert_thermo_calls(main_cpp, expected) + + +def test_heat_cool_mode_true_sets_support_only( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """heat_cool_mode: true enables the mode without building an automation.""" + main_cpp = generate_main(component_config_path("heat_cool_true.yaml")) + + assert "thermo->set_supports_heat_cool(true);" in main_cpp + assert "get_heat_cool_mode_trigger" not in main_cpp + + +def test_dehumidify_codegen( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Dehumidification sets its supports flag before building its automation.""" + main_cpp = generate_main(component_config_path("dehumidify.yaml")) + + _assert_thermo_calls( + main_cpp, + [ + "thermo->set_set_point_minimum_differential(0.5f);", + "thermo->set_sensor(temp_sensor);", + "thermo->set_humidity_sensor(temp_sensor);", + "thermo->set_cool_deadband(0.5f);", + "thermo->set_cool_overrun(0.5f);", + "thermo->set_heat_deadband(0.5f);", + "thermo->set_heat_overrun(0.5f);", + "thermo->set_cooling_minimum_off_time_in_sec(300);", + "thermo->set_cooling_minimum_run_time_in_sec(300);", + "thermo->set_idle_minimum_time_in_sec(30);", + "thermo->set_supports_fan_only_action_uses_fan_mode_timer(false);", + "thermo->set_supports_fan_only_cooling(false);", + "thermo->set_supports_fan_with_cooling(false);", + "thermo->set_supports_fan_with_heating(false);", + "thermo->set_use_startup_delay(false);", + "(thermo->get_idle_action_trigger());", + "(thermo->get_cool_action_trigger());", + "thermo->set_supports_cool(true);", + "thermo->set_supports_dehumidification(true);", + "(thermo->get_humidity_control_dehumidify_action_trigger());", + "(thermo->get_humidity_control_off_action_trigger());", + "thermo->set_humidity_hysteresis(1.0f);", + ], + ) From e4715443b02cc513a567c3ccc98bd487557a0ab9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:35:47 -1000 Subject: [PATCH 064/170] [components] Add tests for the touchscreen key binary sensors (#22063) --- tests/components/cst226/__init__.py | 6 + .../binary_sensor/cst226_button_test.cpp | 138 ++++++++++++++++ tests/components/cst226/common.h | 55 +++++++ .../touchscreen/cst226_touchscreen_test.cpp | 26 +++ tests/components/cst328/__init__.py | 6 + .../binary_sensor/cst328_button_test.cpp | 134 +++++++++++++++ tests/components/cst328/common.h | 53 ++++++ .../touchscreen/cst328_touchscreen_test.cpp | 36 ++++ tests/components/gt911/__init__.py | 6 + .../gt911/binary_sensor/gt911_button_test.cpp | 155 ++++++++++++++++++ tests/components/gt911/common.h | 52 ++++++ .../touchscreen/gt911_touchscreen_test.cpp | 30 ++++ tests/components/tt21100/__init__.py | 6 + .../binary_sensor/tt21100_button_test.cpp | 117 +++++++++++++ tests/components/tt21100/common.h | 30 ++++ .../touchscreen/tt21100_touchscreen_test.cpp | 17 ++ 16 files changed, 867 insertions(+) create mode 100644 tests/components/cst226/__init__.py create mode 100644 tests/components/cst226/binary_sensor/cst226_button_test.cpp create mode 100644 tests/components/cst226/common.h create mode 100644 tests/components/cst226/touchscreen/cst226_touchscreen_test.cpp create mode 100644 tests/components/cst328/__init__.py create mode 100644 tests/components/cst328/binary_sensor/cst328_button_test.cpp create mode 100644 tests/components/cst328/common.h create mode 100644 tests/components/cst328/touchscreen/cst328_touchscreen_test.cpp create mode 100644 tests/components/gt911/__init__.py create mode 100644 tests/components/gt911/binary_sensor/gt911_button_test.cpp create mode 100644 tests/components/gt911/common.h create mode 100644 tests/components/gt911/touchscreen/gt911_touchscreen_test.cpp create mode 100644 tests/components/tt21100/__init__.py create mode 100644 tests/components/tt21100/binary_sensor/tt21100_button_test.cpp create mode 100644 tests/components/tt21100/common.h create mode 100644 tests/components/tt21100/touchscreen/tt21100_touchscreen_test.cpp diff --git a/tests/components/cst226/__init__.py b/tests/components/cst226/__init__.py new file mode 100644 index 00000000000..36f88499552 --- /dev/null +++ b/tests/components/cst226/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # touchscreen.h includes display.h + manifest.dependencies = manifest.dependencies + ["display"] diff --git a/tests/components/cst226/binary_sensor/cst226_button_test.cpp b/tests/components/cst226/binary_sensor/cst226_button_test.cpp new file mode 100644 index 00000000000..b3015082e25 --- /dev/null +++ b/tests/components/cst226/binary_sensor/cst226_button_test.cpp @@ -0,0 +1,138 @@ +#include + +#include + +#include "esphome/components/cst226/binary_sensor/cs226_button.h" +#include "esphome/components/cst226/touchscreen/cst226_touchscreen.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "../common.h" + +namespace esphome::cst226::testing { + +class CST226ButtonTest : public ::testing::Test { + protected: + void SetUp() override { + this->hub_.set_i2c_bus(&this->bus_); + this->hub_.set_i2c_address(0x5A); + } + + // A touch loop defers work on the hub; a fresh App drops it before the hub goes away + void TearDown() override { + App.~Application(); + new (&App) Application(); + } + + void add_button_(CST226Button &button) { + button.set_parent(&this->hub_); + this->buttons_.push_back(&button); + } + + void setup_() { + this->hub_.setup(); + for (auto *button : this->buttons_) + button->setup(); + } + + void key_(bool pressed) { + this->bus_.set_key(pressed); + this->hub_.update(); + this->hub_.loop(); + } + + // Runs the scheduler until done() holds, so setup timeouts that chain fire in turn; gives up after 2 s + template void run_timers_until_(F done) { + const uint32_t start = millis(); + while (!done() && millis() - start < 2000) { + delay(5); + App.scheduler.call(millis()); + } + } + + StatusBus bus_; + CST226Touchscreen hub_; + std::vector buttons_; +}; + +TEST_F(CST226ButtonTest, SetupPublishesOff) { + CST226Button button; + this->add_button_(button); + std::vector> published; + button.add_full_state_callback([&published](optional, optional state) { published.push_back(state); }); + EXPECT_FALSE(button.has_state()); + this->setup_(); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); + EXPECT_EQ(published, (std::vector>{false})); +} + +TEST_F(CST226ButtonTest, ResetPinSetupPublishesOffBeforeItFinishes) { + FakePin reset; + this->hub_.set_reset_pin(&reset); + CST226Button button; + this->add_button_(button); + this->setup_(); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); + EXPECT_FALSE(this->hub_.can_proceed()); + this->run_timers_until_([this] { return this->hub_.can_proceed(); }); + EXPECT_TRUE(this->hub_.can_proceed()); + this->key_(true); + EXPECT_TRUE(button.state); +} + +TEST_F(CST226ButtonTest, SetupPublishesOffWhenTheHubFails) { + this->bus_.fail = true; + CST226Button button; + this->add_button_(button); + this->setup_(); + EXPECT_TRUE(this->hub_.is_failed()); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); +} + +TEST_F(CST226ButtonTest, KeyReportDrivesState) { + CST226Button button; + this->add_button_(button); + this->setup_(); + std::vector states; + button.add_on_state_callback([&states](bool state) { states.push_back(state); }); + + this->key_(true); + EXPECT_TRUE(button.state); + this->key_(true); + EXPECT_TRUE(button.state); + this->key_(false); + EXPECT_FALSE(button.state); + this->key_(true); + EXPECT_TRUE(button.state); + EXPECT_EQ(states, (std::vector{true, false, true})); +} + +TEST_F(CST226ButtonTest, FailedReadKeepsState) { + CST226Button button; + this->add_button_(button); + this->setup_(); + this->key_(true); + this->bus_.fail = true; + this->hub_.update(); + this->hub_.loop(); + EXPECT_TRUE(button.state); +} + +TEST_F(CST226ButtonTest, EveryButtonOfAHubFollowsTheKey) { + CST226Button first; + CST226Button second; + this->add_button_(first); + this->add_button_(second); + this->setup_(); + + this->key_(true); + EXPECT_TRUE(first.state); + EXPECT_TRUE(second.state); + this->key_(false); + EXPECT_FALSE(first.state); + EXPECT_FALSE(second.state); +} + +} // namespace esphome::cst226::testing diff --git a/tests/components/cst226/common.h b/tests/components/cst226/common.h new file mode 100644 index 00000000000..8369354303b --- /dev/null +++ b/tests/components/cst226/common.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +#include "esphome/core/gpio.h" +#include "esphome/components/cst226/touchscreen/cst226_touchscreen.h" + +namespace esphome::cst226::testing { + +// A reset pin that only records its level. +class FakePin final : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::FLAG_OUTPUT; } + bool digital_read() override { return this->level; } + void digital_write(bool value) override { this->level = value; } + bool level{false}; +}; + +// Answers the 8-bit status read with a set report and 16-bit setup reads with zeros. +class StatusBus final : public i2c::I2CBus { + public: + std::array status{}; + bool fail{false}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + if (this->fail) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (out_size == 0) + return i2c::ERROR_OK; + if (size == 1 && data[0] == CST226_REG_STATUS) { + std::memcpy(out, this->status.data(), std::min(out_size, this->status.size())); + } else { + std::memset(out, 0, out_size); + } + return i2c::ERROR_OK; + } + + void set_key(bool pressed) { + this->status = {}; + if (pressed) { + this->status[0] = 0x83; + this->status[1] = 0x17; + this->status[5] = 0x80; + } else { + this->status[6] = 0xAB; + } + } +}; + +} // namespace esphome::cst226::testing diff --git a/tests/components/cst226/touchscreen/cst226_touchscreen_test.cpp b/tests/components/cst226/touchscreen/cst226_touchscreen_test.cpp new file mode 100644 index 00000000000..453c0dffe16 --- /dev/null +++ b/tests/components/cst226/touchscreen/cst226_touchscreen_test.cpp @@ -0,0 +1,26 @@ +#include + +#include "esphome/components/cst226/touchscreen/cst226_touchscreen.h" +#include "../common.h" + +namespace esphome::cst226::testing { + +TEST(CST226TouchscreenTest, SetupCompletes) { + StatusBus bus; + CST226Touchscreen hub; + hub.set_i2c_bus(&bus); + hub.setup(); + EXPECT_TRUE(hub.can_proceed()); + EXPECT_FALSE(hub.is_failed()); +} + +TEST(CST226TouchscreenTest, SetupFailsWithoutTheChip) { + StatusBus bus; + bus.fail = true; + CST226Touchscreen hub; + hub.set_i2c_bus(&bus); + hub.setup(); + EXPECT_TRUE(hub.is_failed()); +} + +} // namespace esphome::cst226::testing diff --git a/tests/components/cst328/__init__.py b/tests/components/cst328/__init__.py new file mode 100644 index 00000000000..36f88499552 --- /dev/null +++ b/tests/components/cst328/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # touchscreen.h includes display.h + manifest.dependencies = manifest.dependencies + ["display"] diff --git a/tests/components/cst328/binary_sensor/cst328_button_test.cpp b/tests/components/cst328/binary_sensor/cst328_button_test.cpp new file mode 100644 index 00000000000..b01bb169f32 --- /dev/null +++ b/tests/components/cst328/binary_sensor/cst328_button_test.cpp @@ -0,0 +1,134 @@ +#include + +#include + +#include "esphome/components/cst328/binary_sensor/cst328_button.h" +#include "esphome/components/cst328/touchscreen/cst328_touchscreen.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "../common.h" + +namespace esphome::cst328::testing { + +class CST328ButtonTest : public ::testing::Test { + protected: + void SetUp() override { + // Firmware CRC 0xCACA in bytes 2 and 3, and a 320x240 panel + this->bus_.registers[CST_REG_FW_CRC_AND_BOOT_TIME] = {0, 0, 0xCA, 0xCA}; + this->bus_.registers[CST_REG_X_Y_RESOLUTION] = {0x40, 0x01, 0xF0, 0x00}; + this->hub_.set_i2c_bus(&this->bus_); + this->hub_.set_i2c_address(0x1A); + } + + // A touch loop defers work on the hub; a fresh App drops it before the hub goes away + void TearDown() override { + App.~Application(); + new (&App) Application(); + } + + void add_button_(CST328Button &button) { + button.set_parent(&this->hub_); + this->buttons_.push_back(&button); + } + + void setup_() { + this->hub_.setup(); + for (auto *button : this->buttons_) + button->setup(); + } + + void touch_(uint8_t fingers) { + this->bus_.set_finger_count(fingers); + this->hub_.update(); + this->hub_.loop(); + } + + // Runs the scheduler until done() holds, so setup timeouts that chain fire in turn; gives up after 2 s + template void run_timers_until_(F done) { + const uint32_t start = millis(); + while (!done() && millis() - start < 2000) { + delay(5); + App.scheduler.call(millis()); + } + } + + RegisterBus bus_; + CST328Touchscreen hub_; + std::vector buttons_; +}; + +TEST_F(CST328ButtonTest, SetupPublishesOff) { + CST328Button button; + this->add_button_(button); + std::vector> published; + button.add_full_state_callback([&published](optional, optional state) { published.push_back(state); }); + EXPECT_FALSE(button.has_state()); + this->setup_(); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); + EXPECT_EQ(published, (std::vector>{false})); +} + +TEST_F(CST328ButtonTest, ResetPinSetupPublishesOffBeforeItFinishes) { + FakePin reset; + this->hub_.set_reset_pin(&reset); + CST328Button button; + this->add_button_(button); + this->setup_(); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); + // Setup finishes from timeouts; until then touches are skipped + this->touch_(1); + EXPECT_FALSE(button.state); + this->run_timers_until_([this] { return this->bus_.last_write == CST_REG_TOUCH_INFORMATION; }); + this->touch_(1); + EXPECT_TRUE(button.state); +} + +TEST_F(CST328ButtonTest, SetupPublishesOffWhenTheHubFails) { + this->bus_.registers.clear(); + CST328Button button; + this->add_button_(button); + this->setup_(); + EXPECT_TRUE(this->hub_.is_failed()); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); +} + +TEST_F(CST328ButtonTest, FingerCountDrivesState) { + CST328Button button; + this->add_button_(button); + this->setup_(); + std::vector states; + button.add_on_state_callback([&states](bool state) { states.push_back(state); }); + + this->touch_(1); + EXPECT_TRUE(button.state); + this->touch_(5); + EXPECT_TRUE(button.state); + this->touch_(0); + EXPECT_FALSE(button.state); + this->touch_(2); + EXPECT_TRUE(button.state); + // More than five fingers is a bad report and releases the button + this->touch_(6); + EXPECT_FALSE(button.state); + EXPECT_EQ(states, (std::vector{true, false, true, false})); +} + +TEST_F(CST328ButtonTest, EveryButtonOfAHubFollowsTheTouch) { + CST328Button first; + CST328Button second; + this->add_button_(first); + this->add_button_(second); + this->setup_(); + + this->touch_(1); + EXPECT_TRUE(first.state); + EXPECT_TRUE(second.state); + this->touch_(0); + EXPECT_FALSE(first.state); + EXPECT_FALSE(second.state); +} + +} // namespace esphome::cst328::testing diff --git a/tests/components/cst328/common.h b/tests/components/cst328/common.h new file mode 100644 index 00000000000..763f70ea78a --- /dev/null +++ b/tests/components/cst328/common.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/core/gpio.h" +#include "esphome/components/cst328/touchscreen/cst328_touchscreen.h" +#include "esphome/core/helpers.h" + +namespace esphome::cst328::testing { + +// A reset pin that only records its level. +class FakePin final : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::FLAG_OUTPUT; } + bool digital_read() override { return this->level; } + void digital_write(bool value) override { this->level = value; } + bool level{false}; +}; + +// Answers 16-bit register reads from a map; every write succeeds and records its register. +class RegisterBus final : public i2c::I2CBus { + public: + std::map> registers; + uint16_t last_write{0}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + if (out_size == 0) { + if (size >= 2) + this->last_write = encode_uint16(data[0], data[1]); + return i2c::ERROR_OK; + } + if (size < 2) + return i2c::ERROR_NOT_ACKNOWLEDGED; + auto &value = this->registers[encode_uint16(data[0], data[1])]; + value.resize(std::max(value.size(), out_size)); + std::memcpy(out, value.data(), out_size); + return i2c::ERROR_OK; + } + + void set_finger_count(uint8_t count) { + std::vector data(CST328_TOUCH_DATA_SIZE); + data[CST_REG_FINGER_COUNT_IDX] = count; + this->registers[CST_REG_TOUCH_INFORMATION] = data; + } +}; + +} // namespace esphome::cst328::testing diff --git a/tests/components/cst328/touchscreen/cst328_touchscreen_test.cpp b/tests/components/cst328/touchscreen/cst328_touchscreen_test.cpp new file mode 100644 index 00000000000..50773c82b73 --- /dev/null +++ b/tests/components/cst328/touchscreen/cst328_touchscreen_test.cpp @@ -0,0 +1,36 @@ +#include + +#include "esphome/components/cst328/touchscreen/cst328_touchscreen.h" +#include "../common.h" + +namespace esphome::cst328::testing { + +class TestableCST328 : public CST328Touchscreen { + public: + using CST328Touchscreen::setup_complete_; + using CST328Touchscreen::x_raw_max_; + using CST328Touchscreen::y_raw_max_; +}; + +TEST(CST328TouchscreenTest, SetupReadsTheResolution) { + RegisterBus bus; + bus.registers[CST_REG_FW_CRC_AND_BOOT_TIME] = {0, 0, 0xCA, 0xCA}; + bus.registers[CST_REG_X_Y_RESOLUTION] = {0x40, 0x01, 0xF0, 0x00}; + TestableCST328 hub; + hub.set_i2c_bus(&bus); + hub.setup(); + EXPECT_TRUE(hub.setup_complete_); + EXPECT_EQ(hub.x_raw_max_, 320); + EXPECT_EQ(hub.y_raw_max_, 240); +} + +TEST(CST328TouchscreenTest, SetupFailsOnAFirmwareCrcMismatch) { + RegisterBus bus; + TestableCST328 hub; + hub.set_i2c_bus(&bus); + hub.setup(); + EXPECT_FALSE(hub.setup_complete_); + EXPECT_TRUE(hub.is_failed()); +} + +} // namespace esphome::cst328::testing diff --git a/tests/components/gt911/__init__.py b/tests/components/gt911/__init__.py new file mode 100644 index 00000000000..36f88499552 --- /dev/null +++ b/tests/components/gt911/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # touchscreen.h includes display.h + manifest.dependencies = manifest.dependencies + ["display"] diff --git a/tests/components/gt911/binary_sensor/gt911_button_test.cpp b/tests/components/gt911/binary_sensor/gt911_button_test.cpp new file mode 100644 index 00000000000..3b2377d69dd --- /dev/null +++ b/tests/components/gt911/binary_sensor/gt911_button_test.cpp @@ -0,0 +1,155 @@ +#include + +#include + +#include "esphome/components/gt911/binary_sensor/gt911_button.h" +#include "esphome/components/gt911/touchscreen/gt911_touchscreen.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "../common.h" + +namespace esphome::gt911::testing { + +class GT911ButtonTest : public ::testing::Test { + protected: + void SetUp() override { + this->bus_.registers[0x8048] = {0x40, 0x01, 0xF0, 0x00}; + this->hub_.set_i2c_bus(&this->bus_); + this->hub_.set_i2c_address(0x5D); + } + + // A touch loop defers work on the hub; a fresh App drops it before the hub goes away + void TearDown() override { + App.~Application(); + new (&App) Application(); + } + + void add_button_(GT911Button &button, uint8_t index) { + button.set_parent(&this->hub_); + button.set_index(index); + this->buttons_.push_back(&button); + } + + void setup_() { + this->hub_.setup(); + for (auto *button : this->buttons_) + button->setup(); + } + + void keys_(uint8_t keys) { + this->bus_.set_keys(keys); + this->hub_.update(); + this->hub_.loop(); + } + + // Runs the scheduler until done() holds, so setup timeouts that chain fire in turn; gives up after 2 s + template void run_timers_until_(F done) { + const uint32_t start = millis(); + while (!done() && millis() - start < 2000) { + delay(5); + App.scheduler.call(millis()); + } + } + + RegisterBus bus_; + GT911Touchscreen hub_; + std::vector buttons_; +}; + +TEST_F(GT911ButtonTest, SetupPublishesOff) { + GT911Button button; + this->add_button_(button, 0); + std::vector> published; + button.add_full_state_callback([&published](optional, optional state) { published.push_back(state); }); + EXPECT_FALSE(button.has_state()); + this->setup_(); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); + EXPECT_EQ(published, (std::vector>{false})); +} + +TEST_F(GT911ButtonTest, ResetPinSetupPublishesOffBeforeItFinishes) { + FakePin reset; + this->hub_.set_reset_pin(&reset); + GT911Button button; + this->add_button_(button, 0); + this->setup_(); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); + EXPECT_FALSE(this->hub_.can_proceed()); + this->run_timers_until_([this] { return this->hub_.can_proceed(); }); + EXPECT_TRUE(this->hub_.can_proceed()); + this->keys_(0b0001); + EXPECT_TRUE(button.state); +} + +TEST_F(GT911ButtonTest, SetupPublishesOffWhenTheHubFails) { + this->bus_.fail = true; + GT911Button button; + this->add_button_(button, 0); + this->setup_(); + EXPECT_TRUE(this->hub_.is_failed()); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); +} + +TEST_F(GT911ButtonTest, EachButtonFollowsItsKeyBit) { + GT911Button key0; + GT911Button key2; + GT911Button key3; + this->add_button_(key0, 0); + this->add_button_(key2, 2); + this->add_button_(key3, 3); + this->setup_(); + std::vector states; + key2.add_on_state_callback([&states](bool state) { states.push_back(state); }); + + this->keys_(0b0100); + EXPECT_FALSE(key0.state); + EXPECT_TRUE(key2.state); + EXPECT_FALSE(key3.state); + this->keys_(0b1001); + EXPECT_TRUE(key0.state); + EXPECT_FALSE(key2.state); + EXPECT_TRUE(key3.state); + // Bits above the four keys are ignored + this->keys_(0b11110000); + EXPECT_FALSE(key0.state); + EXPECT_FALSE(key2.state); + EXPECT_FALSE(key3.state); + EXPECT_EQ(states, (std::vector{true, false})); +} + +TEST_F(GT911ButtonTest, KeysPublishInIndexOrder) { + GT911Button key3; + GT911Button key0; + GT911Button key0_again; + this->add_button_(key3, 3); + this->add_button_(key0, 0); + this->add_button_(key0_again, 0); + this->setup_(); + std::vector order; + key3.add_on_state_callback([&order](bool) { order.push_back(3); }); + key0.add_on_state_callback([&order](bool) { order.push_back(0); }); + key0_again.add_on_state_callback([&order](bool) { order.push_back(10); }); + + this->keys_(0b1001); + EXPECT_EQ(order, (std::vector{0, 10, 3})); +} + +TEST_F(GT911ButtonTest, ButtonsSharingAnIndexBothFollowIt) { + GT911Button first; + GT911Button second; + this->add_button_(first, 1); + this->add_button_(second, 1); + this->setup_(); + + this->keys_(0b0010); + EXPECT_TRUE(first.state); + EXPECT_TRUE(second.state); + this->keys_(0); + EXPECT_FALSE(first.state); + EXPECT_FALSE(second.state); +} + +} // namespace esphome::gt911::testing diff --git a/tests/components/gt911/common.h b/tests/components/gt911/common.h new file mode 100644 index 00000000000..7160b002167 --- /dev/null +++ b/tests/components/gt911/common.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include "esphome/core/gpio.h" +#include "esphome/components/gt911/touchscreen/gt911_touchscreen.h" +#include "esphome/core/helpers.h" + +namespace esphome::gt911::testing { + +// A reset pin that only records its level. +class FakePin final : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::FLAG_OUTPUT; } + bool digital_read() override { return this->level; } + void digital_write(bool value) override { this->level = value; } + bool level{false}; +}; + +// A 2-byte write selects a register that the next plain read returns; longer writes are ignored. +class RegisterBus final : public i2c::I2CBus { + public: + std::map> registers; + uint16_t pointer{0}; + bool fail{false}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + if (this->fail) + return i2c::ERROR_NOT_ACKNOWLEDGED; + if (size == 2) + this->pointer = encode_uint16(data[0], data[1]); + if (out_size != 0) { + auto &value = this->registers[this->pointer]; + value.resize(std::max(value.size(), out_size)); + std::memcpy(out, value.data(), out_size); + } + return i2c::ERROR_OK; + } + + // A ready report with no touch points and the given key bits + void set_keys(uint8_t keys) { + this->registers[0x814E] = {0x80}; + this->registers[0x814F] = {keys}; + } +}; + +} // namespace esphome::gt911::testing diff --git a/tests/components/gt911/touchscreen/gt911_touchscreen_test.cpp b/tests/components/gt911/touchscreen/gt911_touchscreen_test.cpp new file mode 100644 index 00000000000..7f965c59127 --- /dev/null +++ b/tests/components/gt911/touchscreen/gt911_touchscreen_test.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/components/gt911/touchscreen/gt911_touchscreen.h" +#include "../common.h" + +namespace esphome::gt911::testing { + +TEST(GT911TouchscreenTest, SetupCompletes) { + RegisterBus bus; + bus.registers[0x8048] = {0x40, 0x01, 0xF0, 0x00}; + GT911Touchscreen hub; + hub.set_i2c_bus(&bus); + hub.set_i2c_address(0x5D); + hub.setup(); + EXPECT_TRUE(hub.can_proceed()); + EXPECT_FALSE(hub.is_failed()); +} + +TEST(GT911TouchscreenTest, SetupFailsWithoutTheChip) { + RegisterBus bus; + bus.fail = true; + GT911Touchscreen hub; + hub.set_i2c_bus(&bus); + hub.set_i2c_address(0x5D); + hub.setup(); + EXPECT_FALSE(hub.can_proceed()); + EXPECT_TRUE(hub.is_failed()); +} + +} // namespace esphome::gt911::testing diff --git a/tests/components/tt21100/__init__.py b/tests/components/tt21100/__init__.py new file mode 100644 index 00000000000..36f88499552 --- /dev/null +++ b/tests/components/tt21100/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # touchscreen.h includes display.h + manifest.dependencies = manifest.dependencies + ["display"] diff --git a/tests/components/tt21100/binary_sensor/tt21100_button_test.cpp b/tests/components/tt21100/binary_sensor/tt21100_button_test.cpp new file mode 100644 index 00000000000..8e3a085cbd6 --- /dev/null +++ b/tests/components/tt21100/binary_sensor/tt21100_button_test.cpp @@ -0,0 +1,117 @@ +#include + +#include + +#include "esphome/components/tt21100/binary_sensor/tt21100_button.h" +#include "esphome/components/tt21100/touchscreen/tt21100.h" +#include "esphome/core/application.h" +#include "../common.h" + +namespace esphome::tt21100::testing { + +class TT21100ButtonTest : public ::testing::Test { + protected: + void SetUp() override { + this->hub_.set_i2c_bus(&this->bus_); + this->hub_.set_i2c_address(0x24); + this->hub_.set_interrupt_pin(nullptr); + } + + // A touch loop defers work on the hub; a fresh App drops it before the hub goes away + void TearDown() override { + App.~Application(); + new (&App) Application(); + } + + void add_button_(TT21100Button &button, uint8_t index) { + button.set_parent(&this->hub_); + button.set_index(index); + this->buttons_.push_back(&button); + } + + void setup_() { + this->hub_.setup(); + for (auto *button : this->buttons_) + button->setup(); + } + + void signals_(uint16_t s0, uint16_t s1, uint16_t s2, uint16_t s3) { + this->bus_.set_signals(s0, s1, s2, s3); + this->hub_.update(); + this->hub_.loop(); + } + + ReportBus bus_; + TT21100Touchscreen hub_; + std::vector buttons_; +}; + +TEST_F(TT21100ButtonTest, SetupPublishesOff) { + TT21100Button button; + this->add_button_(button, 0); + std::vector> published; + button.add_full_state_callback([&published](optional, optional state) { published.push_back(state); }); + EXPECT_FALSE(button.has_state()); + this->setup_(); + ASSERT_TRUE(button.has_state()); + EXPECT_FALSE(button.state); + EXPECT_EQ(published, (std::vector>{false})); +} + +TEST_F(TT21100ButtonTest, EachButtonFollowsItsSignal) { + TT21100Button key0; + TT21100Button key1; + TT21100Button key3; + this->add_button_(key0, 0); + this->add_button_(key1, 1); + this->add_button_(key3, 3); + this->setup_(); + std::vector states; + key1.add_on_state_callback([&states](bool state) { states.push_back(state); }); + + this->signals_(0, 0x0120, 0x0001, 0); + EXPECT_FALSE(key0.state); + EXPECT_TRUE(key1.state); + EXPECT_FALSE(key3.state); + this->signals_(0, 0x0001, 0, 0); + EXPECT_TRUE(key1.state); + this->signals_(0x8000, 0, 0, 0x0002); + EXPECT_TRUE(key0.state); + EXPECT_FALSE(key1.state); + EXPECT_TRUE(key3.state); + EXPECT_EQ(states, (std::vector{true, false})); +} + +TEST_F(TT21100ButtonTest, KeysPublishInIndexOrder) { + TT21100Button key3; + TT21100Button key0; + TT21100Button key0_again; + this->add_button_(key3, 3); + this->add_button_(key0, 0); + this->add_button_(key0_again, 0); + this->setup_(); + std::vector order; + key3.add_on_state_callback([&order](bool) { order.push_back(3); }); + key0.add_on_state_callback([&order](bool) { order.push_back(0); }); + key0_again.add_on_state_callback([&order](bool) { order.push_back(10); }); + + this->signals_(1, 0, 0, 1); + EXPECT_EQ(order, (std::vector{0, 10, 3})); +} + +TEST_F(TT21100ButtonTest, ButtonsSharingAnIndexBothFollowIt) { + TT21100Button first; + TT21100Button second; + this->add_button_(first, 2); + this->add_button_(second, 2); + this->setup_(); + + this->signals_(0, 0, 1, 0); + EXPECT_TRUE(first.state); + EXPECT_TRUE(second.state); + this->signals_(0, 0, 0, 0); + EXPECT_FALSE(first.state); + EXPECT_FALSE(second.state); +} + +} // namespace esphome::tt21100::testing diff --git a/tests/components/tt21100/common.h b/tests/components/tt21100/common.h new file mode 100644 index 00000000000..af0dbacae00 --- /dev/null +++ b/tests/components/tt21100/common.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/tt21100/touchscreen/tt21100.h" + +namespace esphome::tt21100::testing { + +// Every read returns the start of a 14-byte button report, so the 2-byte length read sees 14. +class ReportBus final : public i2c::I2CBus { + public: + std::array report{14, 0, 0x03}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + if (out_size == 0) + return i2c::ERROR_OK; + std::memcpy(out, this->report.data(), std::min(out_size, this->report.size())); + return i2c::ERROR_OK; + } + + void set_signals(uint16_t s0, uint16_t s1, uint16_t s2, uint16_t s3) { + const uint16_t signals[4] = {s0, s1, s2, s3}; + std::memcpy(&this->report[6], signals, sizeof(signals)); + } +}; + +} // namespace esphome::tt21100::testing diff --git a/tests/components/tt21100/touchscreen/tt21100_touchscreen_test.cpp b/tests/components/tt21100/touchscreen/tt21100_touchscreen_test.cpp new file mode 100644 index 00000000000..0a4b85030ba --- /dev/null +++ b/tests/components/tt21100/touchscreen/tt21100_touchscreen_test.cpp @@ -0,0 +1,17 @@ +#include + +#include "esphome/components/tt21100/touchscreen/tt21100.h" +#include "../common.h" + +namespace esphome::tt21100::testing { + +TEST(TT21100TouchscreenTest, SetupDoesNotFail) { + ReportBus bus; + TT21100Touchscreen hub; + hub.set_i2c_bus(&bus); + hub.set_interrupt_pin(nullptr); + hub.setup(); + EXPECT_FALSE(hub.is_failed()); +} + +} // namespace esphome::tt21100::testing From f60abbf84f62065240154f3e115bbff222da60d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:36:42 -1000 Subject: [PATCH 065/170] [components] Publish the remaining child entities with PUBLISH_STATE (#22079) --- esphome/components/aht10/aht10.cpp | 5 +--- .../airthings_wave_base.cpp | 6 +---- .../airton_serial/airton_serial_climate.cpp | 15 ++++-------- .../components/duty_time/duty_time_sensor.cpp | 5 +--- .../emc2101/sensor/emc2101_sensor.cpp | 23 ++++--------------- esphome/components/ens210/ens210.cpp | 12 +++------- .../haier_water_heater/hon_water_heater.cpp | 8 +++---- esphome/components/hdc1080/hdc1080.cpp | 14 ++++------- esphome/components/hdc2080/hdc2080.cpp | 10 ++------ esphome/components/hdc302x/hdc302x.cpp | 16 +++---------- .../honeywellabp2_i2c/honeywellabp2.cpp | 8 ++----- esphome/components/ld6004/ld6004.cpp | 3 +-- .../m5stack_unit_bldc/m5stack_unit_bldc.cpp | 9 +++----- .../mopeka_pro_check/mopeka_pro_check.cpp | 10 ++------ .../mopeka_std_check/mopeka_std_check.cpp | 5 +--- .../seeed_mr24hpc1/seeed_mr24hpc1.cpp | 18 +++------------ esphome/components/sfa30/sfa30.cpp | 17 +++----------- esphome/components/sht4x/sht4x.cpp | 14 +++-------- .../xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp | 16 +++---------- 19 files changed, 48 insertions(+), 166 deletions(-) diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 41167033701..da0b4636f15 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -120,10 +120,7 @@ void AHT10Component::read_data_() { uint32_t raw_temperature = encode_uint24(data[3] & 0xF, data[4], data[5]); uint32_t raw_humidity = encode_uint24(data[1], data[2], data[3]) >> 4; - if (this->temperature_sensor_ != nullptr) { - float temperature = ((200.0f * static_cast(raw_temperature)) / AHT10_DIVISOR) - 50.0f; - this->temperature_sensor_->publish_state(temperature); - } + PUBLISH_STATE(this->temperature_sensor_, ((200.0f * static_cast(raw_temperature)) / AHT10_DIVISOR) - 50.0f); if (this->humidity_sensor_ != nullptr) { float humidity = raw_humidity == 0 ? NAN : static_cast(raw_humidity) * 100.0f / AHT10_DIVISOR; if (std::isnan(humidity)) { diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp index f4dfaaee326..610ba6b0a90 100644 --- a/esphome/components/airthings_wave_base/airthings_wave_base.cpp +++ b/esphome/components/airthings_wave_base/airthings_wave_base.cpp @@ -175,11 +175,7 @@ void AirthingsWaveBase::read_battery_(uint8_t *raw_value, uint16_t value_len) { if ((value_len >= (sizeof(AccessControlPointResponse) + 2)) && (raw_value[0] == ACCESS_CONTROL_POINT_COMMAND)) { ESP_LOGD(TAG, "Battery received: %u mV", (unsigned int) value->battery); - if (this->battery_voltage_ != nullptr) { - float voltage = value->battery / 1000.0f; - - this->battery_voltage_->publish_state(voltage); - } + PUBLISH_STATE(this->battery_voltage_, value->battery / 1000.0f); // read the battery again at the configured update interval if (this->battery_update_interval_ != this->update_interval_) { diff --git a/esphome/components/airton_serial/airton_serial_climate.cpp b/esphome/components/airton_serial/airton_serial_climate.cpp index b7336da1b27..e13b99b3edf 100644 --- a/esphome/components/airton_serial/airton_serial_climate.cpp +++ b/esphome/components/airton_serial/airton_serial_climate.cpp @@ -256,16 +256,11 @@ void AirtonSerialClimate::handle_ac_(const uint8_t *data) { void AirtonSerialClimate::publish_settings_() { #ifdef USE_SWITCH - if (this->display_switch_ != nullptr) - this->display_switch_->publish_state((this->flags_ & FLAG_DISPLAY) != 0); - if (this->ionizer_switch_ != nullptr) - this->ionizer_switch_->publish_state((this->flags_ & FLAG_IONIZER) != 0); - if (this->sleep_switch_ != nullptr) - this->sleep_switch_->publish_state((this->flags_ & FLAG_SLEEP) != 0); - if (this->eco_switch_ != nullptr) - this->eco_switch_->publish_state((this->flags_ & FLAG_ECO) != 0); - if (this->health_switch_ != nullptr) - this->health_switch_->publish_state((this->flags_ & FLAG_HEALTH) != 0); + PUBLISH_STATE(this->display_switch_, (this->flags_ & FLAG_DISPLAY) != 0); + PUBLISH_STATE(this->ionizer_switch_, (this->flags_ & FLAG_IONIZER) != 0); + PUBLISH_STATE(this->sleep_switch_, (this->flags_ & FLAG_SLEEP) != 0); + PUBLISH_STATE(this->eco_switch_, (this->flags_ & FLAG_ECO) != 0); + PUBLISH_STATE(this->health_switch_, (this->flags_ & FLAG_HEALTH) != 0); #endif } diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp index 646c2febc6c..ab61fc96d21 100644 --- a/esphome/components/duty_time/duty_time_sensor.cpp +++ b/esphome/components/duty_time/duty_time_sensor.cpp @@ -70,10 +70,7 @@ void DutyTimeSensor::process_state_(const bool state) { this->last_time_ = ms; // temporary store ms correction only this->last_state_ = false; - if (this->last_duty_time_sensor_ != nullptr) { - const uint32_t turn_on_ms = now - this->edge_time_; - this->last_duty_time_sensor_->publish_state(turn_on_ms * 1e-3f); - } + PUBLISH_STATE(this->last_duty_time_sensor_, (now - this->edge_time_) * 1e-3f); } } else if (state) { diff --git a/esphome/components/emc2101/sensor/emc2101_sensor.cpp b/esphome/components/emc2101/sensor/emc2101_sensor.cpp index bcc8c01c4ff..bfc0ebb781a 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.cpp +++ b/esphome/components/emc2101/sensor/emc2101_sensor.cpp @@ -15,25 +15,10 @@ void EMC2101Sensor::dump_config() { } void EMC2101Sensor::update() { - if (this->internal_temperature_sensor_ != nullptr) { - float internal_temperature = this->parent_->get_internal_temperature(); - this->internal_temperature_sensor_->publish_state(internal_temperature); - } - - if (this->external_temperature_sensor_ != nullptr) { - float external_temperature = this->parent_->get_external_temperature(); - this->external_temperature_sensor_->publish_state(external_temperature); - } - - if (this->speed_sensor_ != nullptr) { - float speed = this->parent_->get_speed(); - this->speed_sensor_->publish_state(speed); - } - - if (this->duty_cycle_sensor_ != nullptr) { - float duty_cycle = this->parent_->get_duty_cycle(); - this->duty_cycle_sensor_->publish_state(duty_cycle * 100.0f); - } + PUBLISH_STATE(this->internal_temperature_sensor_, this->parent_->get_internal_temperature()); + PUBLISH_STATE(this->external_temperature_sensor_, this->parent_->get_external_temperature()); + PUBLISH_STATE(this->speed_sensor_, this->parent_->get_speed()); + PUBLISH_STATE(this->duty_cycle_sensor_, this->parent_->get_duty_cycle() * 100.0f); } } // namespace esphome::emc2101 diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 0d31a398177..bcde951df91 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -166,10 +166,7 @@ void ENS210Component::update() { extract_measurement_(h_val_data, &humidity_data, &humidity_status); if (humidity_status == ENS210_STATUS_OK) { - if (this->humidity_sensor_ != nullptr) { - float humidity = (humidity_data & 0xFFFF) / 512.0; - this->humidity_sensor_->publish_state(humidity); - } + PUBLISH_STATE(this->humidity_sensor_, (humidity_data & 0xFFFF) / 512.0); } else { ESP_LOGW(TAG, "Humidity status failure: %s", LOG_STR_ARG(ens210_status_to_human(humidity_status))); this->status_set_warning(); @@ -181,11 +178,8 @@ void ENS210Component::update() { extract_measurement_(t_val_data, &temperature_data, &temperature_status); if (temperature_status == ENS210_STATUS_OK) { - if (this->temperature_sensor_ != nullptr) { - // Temperature in Celsius - float temperature = (temperature_data & 0xFFFF) / 64.0 - 27315L / 100.0; - this->temperature_sensor_->publish_state(temperature); - } + // Temperature in Celsius + PUBLISH_STATE(this->temperature_sensor_, (temperature_data & 0xFFFF) / 64.0 - 27315L / 100.0); } else { ESP_LOGW(TAG, "Temperature status failure: %s", LOG_STR_ARG(ens210_status_to_human(temperature_status))); } diff --git a/esphome/components/haier_water_heater/hon_water_heater.cpp b/esphome/components/haier_water_heater/hon_water_heater.cpp index 870aa1b69b6..1046085ad41 100644 --- a/esphome/components/haier_water_heater/hon_water_heater.cpp +++ b/esphome/components/haier_water_heater/hon_water_heater.cpp @@ -278,11 +278,9 @@ void HonWaterHeater::publish_status_(const Status &status) { status.secondary_temperature <= MAX_REPORTED_TEMPERATURE ? status.secondary_temperature : NAN); PUBLISH_STATE(this->target_temperature_sensor_, status.target_temperature); PUBLISH_STATE(this->estimated_heating_time_sensor_, status.estimated_heating_time); - if (this->available_water_sensor_ != nullptr) { - // Three segments of available hot water, published as 0, 33, 66 or 100 percent. - const uint8_t percent = status.available_water * 100 / 3; - this->available_water_sensor_->publish_state(status.available_water <= 3 ? percent : NAN); - } + // Three segments of available hot water, published as 0, 33, 66 or 100 percent. + PUBLISH_STATE(this->available_water_sensor_, + status.available_water <= 3 ? static_cast(status.available_water * 100 / 3) : NAN); #endif #ifdef USE_BINARY_SENSOR PUBLISH_STATE(this->power_binary_sensor_, status.powered); diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index 29ddfabef60..b9292cfaf09 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -47,11 +47,8 @@ void HDC1080Component::update() { return; } - if (this->temperature_sensor_ != nullptr) { - raw_temperature = i2c::i2ctohs(raw_temperature); - float temperature = raw_temperature * 0.0025177f - 40.0f; // raw * 2^-16 * 165 - 40 - this->temperature_sensor_->publish_state(temperature); - } + // raw * 2^-16 * 165 - 40 + PUBLISH_STATE(this->temperature_sensor_, i2c::i2ctohs(raw_temperature) * 0.0025177f - 40.0f); if (this->write(&HDC1080_CMD_HUMIDITY, 1) != i2c::ERROR_OK) { this->status_set_warning(); @@ -65,11 +62,8 @@ void HDC1080Component::update() { return; } - if (this->humidity_sensor_ != nullptr) { - raw_humidity = i2c::i2ctohs(raw_humidity); - float humidity = raw_humidity * 0.001525879f; // raw * 2^-16 * 100 - this->humidity_sensor_->publish_state(humidity); - } + // raw * 2^-16 * 100 + PUBLISH_STATE(this->humidity_sensor_, i2c::i2ctohs(raw_humidity) * 0.001525879f); }); }); } diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index 3386fe4d540..aedb0e35830 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -52,14 +52,8 @@ void HDC2080Component::update() { uint8_t raw_data[4]; if (!this->status_comm_result(this->read_register(REG_TEMPERATURE_LOW, raw_data, 4) == i2c::ERROR_OK)) return; - if (this->temperature_sensor_ != nullptr) { - float temp = encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f; - this->temperature_sensor_->publish_state(temp); - } - if (this->humidity_sensor_ != nullptr) { - float humidity = encode_value_le(&raw_data[2]) * (100.0f / 65536.0f); - this->humidity_sensor_->publish_state(humidity); - } + PUBLISH_STATE(this->temperature_sensor_, encode_value_le(raw_data) * (165.0f / 65536.0f) - 40.5f); + PUBLISH_STATE(this->humidity_sensor_, encode_value_le(&raw_data[2]) * (100.0f / 65536.0f)); }); } diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index 6e8ea946e59..54276e68fd7 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -137,19 +137,9 @@ void HDC302XComponent::read_data_() { if (!this->status_comm_result(crc_ok, LOG_STR("Read data: invalid CRC"))) return; - if (this->temperature_sensor_ != nullptr) { - uint16_t raw_t = encode_uint16(buf[0], buf[1]); - // Calculate temperature in Celsius per datasheet section 7.3.3. - float temp = -45 + 175 * (float(raw_t) / 65535.0f); - this->temperature_sensor_->publish_state(temp); - } - - if (this->humidity_sensor_ != nullptr) { - uint16_t raw_rh = encode_uint16(buf[3], buf[4]); - // Calculate RH% per datasheet section 7.3.3. - float humidity = 100 * (float(raw_rh) / 65535.0f); - this->humidity_sensor_->publish_state(humidity); - } + // Temperature in Celsius and RH% per datasheet section 7.3.3. + PUBLISH_STATE(this->temperature_sensor_, -45 + 175 * (encode_uint16(buf[0], buf[1]) / 65535.0f)); + PUBLISH_STATE(this->humidity_sensor_, 100 * (encode_uint16(buf[3], buf[4]) / 65535.0f)); } uint32_t HDC302XComponent::conversion_delay_ms_() { diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp index 2ca3fb821cc..cc4e2bc2821 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp @@ -67,12 +67,8 @@ void HONEYWELLABP2Sensor::loop() { this->cancel_timeout(MEASUREMENT_TIMEOUT_ID); this->read_sensor_data(); - if (pressure_sensor_ != nullptr) { - this->pressure_sensor_->publish_state(this->get_pressure()); - } - if (temperature_sensor_ != nullptr) { - this->temperature_sensor_->publish_state(this->get_temperature()); - } + PUBLISH_STATE(this->pressure_sensor_, this->get_pressure()); + PUBLISH_STATE(this->temperature_sensor_, this->get_temperature()); } } } diff --git a/esphome/components/ld6004/ld6004.cpp b/esphome/components/ld6004/ld6004.cpp index 303a04924bb..0cca9b554d9 100644 --- a/esphome/components/ld6004/ld6004.cpp +++ b/esphome/components/ld6004/ld6004.cpp @@ -81,8 +81,7 @@ bool LD6004Component::handle_model_report(uint16_t type, const uint8_t *data, ui PUBLISH_STATE(this->work_mode_select_, mode); #endif #ifdef USE_TEXT_SENSOR - if (this->work_mode_text_sensor_ != nullptr) - this->work_mode_text_sensor_->publish_state(WorkModeStrings::get_progmem_str(static_cast(mode), 0)); + PUBLISH_STATE(this->work_mode_text_sensor_, WorkModeStrings::get_progmem_str(static_cast(mode), 0)); #endif return true; } diff --git a/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp index 3b1f569cc8d..3d8fdd03b64 100644 --- a/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp +++ b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.cpp @@ -64,12 +64,10 @@ void M5StackUnitBldc::setup() { } this->current_direction_ = this->initial_direction_; #ifdef USE_SELECT - if (this->direction_select_ != nullptr) - this->direction_select_->publish_state(static_cast(this->initial_direction_)); + PUBLISH_STATE(this->direction_select_, static_cast(this->initial_direction_)); #endif #ifdef USE_NUMBER - if (this->command_number_ != nullptr) - this->command_number_->publish_state(command); + PUBLISH_STATE(this->command_number_, command); #endif const uint8_t motor_config[2] = {static_cast(this->motor_model_), this->pole_pairs_}; @@ -219,8 +217,7 @@ void M5StackUnitBldc::finish_direction_change_() { static_cast(DIRECTION_CHANGE_POLL_MS * DIRECTION_CHANGE_MAX_POLLS)); this->last_command_ = 0; #ifdef USE_NUMBER - if (this->command_number_ != nullptr) - this->command_number_->publish_state(0); + PUBLISH_STATE(this->command_number_, 0); #endif return; } diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index 598c9fdb5fd..f087293e7e4 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -61,10 +61,7 @@ bool MopekaProCheck::parse_device(const ble_device_base::ESPBTDevice &device) { } // Get battery level first - if (this->battery_level_sensor_ != nullptr) { - uint8_t level = this->parse_battery_level_(manu_data.data); - this->battery_level_sensor_->publish_state(level); - } + PUBLISH_STATE(this->battery_level_sensor_, this->parse_battery_level_(manu_data.data)); // Get the quality value SensorReadQuality quality_value = this->parse_read_quality_(manu_data.data); @@ -107,10 +104,7 @@ bool MopekaProCheck::parse_device(const ble_device_base::ESPBTDevice &device) { } // Get temperature of sensor - if (this->temperature_sensor_ != nullptr) { - int8_t temp_in_c = this->parse_temperature_(manu_data.data); - this->temperature_sensor_->publish_state(temp_in_c); - } + PUBLISH_STATE(this->temperature_sensor_, this->parse_temperature_(manu_data.data)); return true; } diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index 8c9e21a8cc5..eecb8d8d7ab 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -96,10 +96,7 @@ bool MopekaStdCheck::parse_device(const ble_device_base::ESPBTDevice &device) { } // Get battery level first - if (this->battery_level_sensor_ != nullptr) { - uint8_t level = this->parse_battery_level_(mopeka_data); - this->battery_level_sensor_->publish_state(level); - } + PUBLISH_STATE(this->battery_level_sensor_, this->parse_battery_level_(mopeka_data)); // Get temperature of sensor int8_t temp_in_c = this->parse_temperature_(mopeka_data); diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 809adafe215..27aab36e949 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -450,27 +450,15 @@ void MR24HPC1Component::r24_frame_parse_open_underlying_information_(uint8_t *da break; case 0x0c: case 0x8c: - if (this->motion_trigger_number_ != nullptr) { - uint32_t motion_trigger_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], - data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); - this->motion_trigger_number_->publish_state(motion_trigger_time); - } + PUBLISH_STATE(this->motion_trigger_number_, encode_value(&data[FRAME_DATA_INDEX])); break; case 0x0d: case 0x8d: - if (this->motion_to_rest_number_ != nullptr) { - uint32_t move_to_rest_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], - data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); - this->motion_to_rest_number_->publish_state(move_to_rest_time); - } + PUBLISH_STATE(this->motion_to_rest_number_, encode_value(&data[FRAME_DATA_INDEX])); break; case 0x0e: case 0x8e: - if (this->custom_unman_time_number_ != nullptr) { - uint32_t enter_unmanned_time = encode_uint32(data[FRAME_DATA_INDEX], data[FRAME_DATA_INDEX + 1], - data[FRAME_DATA_INDEX + 2], data[FRAME_DATA_INDEX + 3]); - this->custom_unman_time_number_->publish_state(enter_unmanned_time / 1000.0f); - } + PUBLISH_STATE(this->custom_unman_time_number_, encode_value(&data[FRAME_DATA_INDEX]) / 1000.0f); break; #endif #ifdef USE_SELECT diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index b2db91bb587..9d9c80bd94d 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -54,20 +54,9 @@ void SFA30Component::update() { return; } - if (this->formaldehyde_sensor_ != nullptr) { - const float formaldehyde = static_cast(raw_data[0]) / 5.0f; - this->formaldehyde_sensor_->publish_state(formaldehyde); - } - - if (this->humidity_sensor_ != nullptr) { - const float humidity = static_cast(raw_data[1]) / 100.0f; - this->humidity_sensor_->publish_state(humidity); - } - - if (this->temperature_sensor_ != nullptr) { - const float temperature = static_cast(raw_data[2]) / 200.0f; - this->temperature_sensor_->publish_state(temperature); - } + PUBLISH_STATE(this->formaldehyde_sensor_, static_cast(raw_data[0]) / 5.0f); + PUBLISH_STATE(this->humidity_sensor_, static_cast(raw_data[1]) / 100.0f); + PUBLISH_STATE(this->temperature_sensor_, static_cast(raw_data[2]) / 200.0f); this->status_clear_warning(); }); diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 5e6b8849dd1..6bf76769a35 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -94,17 +94,9 @@ void SHT4XComponent::update() { } // Evaluate and publish measurements - if (this->temp_sensor_ != nullptr) { - // Temp is contained in the first result word - float temp = TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast(buffer[0]) / RAW_MAX; - this->temp_sensor_->publish_state(temp); - } - - if (this->humidity_sensor_ != nullptr) { - // Relative humidity is in the second result word - float rh = HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast(buffer[1]) / RAW_MAX; - this->humidity_sensor_->publish_state(rh); - } + // Temperature is in the first result word, relative humidity in the second + PUBLISH_STATE(this->temp_sensor_, TEMPERATURE_OFFSET + TEMPERATURE_SPAN * static_cast(buffer[0]) / RAW_MAX); + PUBLISH_STATE(this->humidity_sensor_, HUMIDITY_OFFSET + HUMIDITY_SPAN * static_cast(buffer[1]) / RAW_MAX); // Fire heater after measurement to maximize cooldown time before the next reading. // The heater command produces a measurement that we don't need (datasheet 4.9). diff --git a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index d9eec273dfa..b6b4702eda2 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -34,22 +34,12 @@ bool XiaomiHHCCJCY10::parse_device(const ble_device_base::ESPBTDevice &device) { } const uint8_t *data = service_data.data.data(); - if (this->temperature_sensor_ != nullptr) { - const int16_t temperature = encode_value(&data[1]); - this->temperature_sensor_->publish_state((float) temperature / 10.0f); - } + PUBLISH_STATE(this->temperature_sensor_, encode_value(&data[1]) / 10.0f); PUBLISH_STATE(this->moisture_sensor_, data[0]); - if (this->conductivity_sensor_ != nullptr) { - const uint16_t conductivity = encode_uint16(data[7], data[8]); - this->conductivity_sensor_->publish_state((float) conductivity); - } - - if (this->illuminance_sensor_ != nullptr) { - const uint32_t illuminance = encode_uint24(data[3], data[4], data[5]); - this->illuminance_sensor_->publish_state((float) illuminance); - } + PUBLISH_STATE(this->conductivity_sensor_, encode_uint16(data[7], data[8])); + PUBLISH_STATE(this->illuminance_sensor_, encode_uint24(data[3], data[4], data[5])); PUBLISH_STATE(this->battery_level_sensor_, data[6]); success = true; From fd0ca9316ac90f227adeb48fe4db686c14156f28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:37:52 -1000 Subject: [PATCH 066/170] [mqtt] Add tests for mqtt.publish_json code generation (#22025) --- tests/components/mqtt/common.yaml | 19 +++ .../components/test_mqtt_publish_json.py | 141 ++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/unit_tests/components/test_mqtt_publish_json.py diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 36527896a23..aae80b33573 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -515,3 +515,22 @@ alarm_control_panel: name: Alarm Control Panel binary_sensors: - input: some_binary_sensor + +script: + - id: publish_json_by_value + parameters: + x: int + then: + # The user lambdas may modify their by-value argument + - mqtt.publish_json: + topic: !lambda "x *= 2; return to_string(x);" + payload: |- + x *= 2; + root["x"] = x; + qos: !lambda "x *= 0; return x;" + retain: !lambda "x *= 2; return x > 0;" + - mqtt.publish_json: + topic: !lambda "return to_string(x *= 2);" + payload: root["x"] = x *= 2; + qos: !lambda "return x *= 0;" + retain: !lambda "return (x *= 2) > 0;" diff --git a/tests/unit_tests/components/test_mqtt_publish_json.py b/tests/unit_tests/components/test_mqtt_publish_json.py new file mode 100644 index 00000000000..9e71b4e5f33 --- /dev/null +++ b/tests/unit_tests/components/test_mqtt_publish_json.py @@ -0,0 +1,141 @@ +"""Tests for the code generated for mqtt.publish_json.""" + +import pytest + +from esphome import automation +import esphome.codegen as cg +from esphome.components.mqtt import MQTTClientComponent +from esphome.const import ( + CONF_ID, + CONF_PAYLOAD, + CONF_QOS, + CONF_RETAIN, + CONF_TOPIC, + CONF_TYPE_ID, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE, ID, Lambda +from esphome.types import ConfigType + +INT_ARG = cg.TemplateArguments(cg.int_) +INT_ARGS = [(cg.int_, "x")] + + +def _publish_json(**body: object) -> ConfigType: + """Config for an ``mqtt.publish_json`` on ``client``, typed as the registry declares it.""" + return { + "mqtt.publish_json": { + CONF_ID: ID("client", type=MQTTClientComponent), + CONF_TOPIC: "the/topic", + CONF_PAYLOAD: Lambda('root["key"] = 1;'), + CONF_QOS: 0, + CONF_RETAIN: False, + **body, + }, + CONF_TYPE_ID: ID( + "act", + is_declaration=True, + type=automation.ACTION_REGISTRY["mqtt.publish_json"].type_id, + is_manual=False, + ), + } + + +@pytest.fixture +def client() -> None: + CORE.config = {} + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP32} + cg.new_Pvariable(ID("client", is_declaration=True, type=MQTTClientComponent)) + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("client") +@pytest.mark.parametrize("platform", [PLATFORM_ESP32, PLATFORM_ESP8266]) +async def test_publish_json_constants_codegen(platform: str) -> None: + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + + await automation.build_action( + _publish_json(**{CONF_QOS: 1, CONF_RETAIN: True}), + cg.TemplateArguments(), + [], + ) + + assert ( + "new(act) mqtt::MQTTPublishJsonAction<>(client);\n" + 'act->set_topic(ESPHOME_F("the/topic"));\n' + "act->set_payload([](ArduinoJson::JsonObject root) -> void {\n" + ' root["key"] = 1;\n' + "});\n" + "act->set_qos([]() -> uint8_t {\n" + " return 1;\n" + "});\n" + "act->set_retain([]() -> bool {\n" + " return true;\n" + "});\n" + ) in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("client") +async def test_publish_json_lambdas_codegen() -> None: + """Topic, qos and retain are lambdas over the trigger args; the payload also gets ``root``.""" + await automation.build_action( + _publish_json( + **{ + CONF_TOPIC: Lambda("return to_string(x);"), + CONF_PAYLOAD: Lambda('root["x"] = x;'), + CONF_QOS: Lambda("return x > 0;"), + CONF_RETAIN: Lambda("return x > 1;"), + } + ), + INT_ARG, + INT_ARGS, + ) + + assert ( + "new(act) mqtt::MQTTPublishJsonAction(client);\n" + "act->set_topic([](int x) -> std::string {\n" + " return to_string(x);\n" + "});\n" + "act->set_payload([](int x, ArduinoJson::JsonObject root) -> void {\n" + ' root["x"] = x;\n' + "});\n" + "act->set_qos([](int x) -> uint8_t {\n" + " return x > 0;\n" + "});\n" + "act->set_retain([](int x) -> bool {\n" + " return x > 1;\n" + "});\n" + ) in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("client") +@pytest.mark.parametrize( + ("key", "body", "lambda_"), + [ + (CONF_TOPIC, "x *= 2; return to_string(x);", "[](int x) -> std::string {"), + (CONF_TOPIC, "return to_string(x *= 2);", "[](int x) -> std::string {"), + (CONF_QOS, "x *= 0; return x;", "[](int x) -> uint8_t {"), + (CONF_QOS, "return x *= 0;", "[](int x) -> uint8_t {"), + (CONF_RETAIN, "x *= 2; return x > 0;", "[](int x) -> bool {"), + (CONF_RETAIN, "return (x *= 2) > 0;", "[](int x) -> bool {"), + ( + CONF_PAYLOAD, + 'x *= 2; root["x"] = x;', + "[](int x, ArduinoJson::JsonObject root) -> void {", + ), + ], +) +async def test_publish_json_lambdas_take_args_by_value( + key: str, body: str, lambda_: str +) -> None: + """A user lambda gets the trigger args by value, so it may modify them.""" + await automation.build_action( + _publish_json(**{key: Lambda(body)}), INT_ARG, INT_ARGS + ) + + assert f"{lambda_}\n {body}\n}}" in CORE.cpp_main_section From 481b39787f58193c25fb2c3ea99706d244667c32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:38:07 -1000 Subject: [PATCH 067/170] [api] Add tests for user service code generation (#22009) --- .../component_tests/api/test_user_services.py | 233 ++++++++++++++++++ .../api/test_user_services.yaml | 91 +++++++ .../api/test_user_services_legacy.yaml | 20 ++ .../fixtures/api_custom_services.yaml | 49 ++++ tests/integration/test_api_custom_services.py | 41 ++- 5 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/api/test_user_services.py create mode 100644 tests/component_tests/api/test_user_services.yaml create mode 100644 tests/component_tests/api/test_user_services_legacy.yaml diff --git a/tests/component_tests/api/test_user_services.py b/tests/component_tests/api/test_user_services.py new file mode 100644 index 00000000000..51b71a2f42e --- /dev/null +++ b/tests/component_tests/api/test_user_services.py @@ -0,0 +1,233 @@ +"""Code generation of api user-defined actions (``api: actions:`` and legacy ``services:``). + +Objects are found from the action name and the links between them, not from +auto-numbered ids, so adding or changing one action does not break the others. +""" + +import re +from typing import NamedTuple + +from esphome.helpers import fnv1_hash + +CONFIG = "tests/component_tests/api/test_user_services.yaml" +LEGACY_CONFIG = "tests/component_tests/api/test_user_services_legacy.yaml" + +NONE = "api::enums::SUPPORTS_RESPONSE_NONE" +OPTIONAL = "api::enums::SUPPORTS_RESPONSE_OPTIONAL" +ONLY = "api::enums::SUPPORTS_RESPONSE_ONLY" + + +class Service(NamedTuple): + id: str + type: str + + +def _constructed(main_cpp: str, id_: str) -> str: + match = re.search(rf"\bnew\({id_}\) (.*?);\n", main_cpp, re.DOTALL) + assert match is not None, id_ + return match.group(1) + + +def _actions_of(main_cpp: str, automation: str) -> list[str]: + return re.findall(rf"\b{automation}->add_action\((\w+)\);", main_cpp) + + +def _function_body(main_cpp: str, name: str) -> str: + match = re.search( + rf"static void {name}\([^;{{]*\) \{{(.*?)\n\}}\n", main_cpp, re.DOTALL + ) + assert match is not None, name + return match.group(1) + + +def _strings(main_cpp: str, name: str) -> tuple[str, str]: + """Return the id and the entries of the strings table of action ``name``.""" + match = re.search( + rf'\b(api_action\d+_strings)\[\] PROGMEM = \{{("{name}"(?:, .*?)?)\}};', + main_cpp, + ) + assert match is not None, name + return match.group(1), match.group(2) + + +def _service(main_cpp: str, name: str) -> Service: + """Return the service object built from the strings table of action ``name``.""" + table, _ = _strings(main_cpp, name) + match = re.search( + rf"\bnew\((\w+)\) ([\w:]+<.*?>)\({table}, (\d+)(?:UL)?\);\n", main_cpp + ) + assert match is not None, name + assert int(match.group(3)) == fnv1_hash(name) + return Service(match.group(1), match.group(2)) + + +def _automation(main_cpp: str, trigger: str) -> tuple[str, str]: + """Return the id and template arguments of the Automation on ``trigger``.""" + match = re.search(rf"\bnew\((\w+)\) Automation<(.*?)>\({trigger}\);", main_cpp) + assert match is not None, trigger + return match.group(1), match.group(2) + + +def _action_code(main_cpp: str, action: str) -> str: + """Return the code an action runs: its generated function, or its constructor.""" + constructed = _constructed(main_cpp, action) + if match := re.match(r"ApplyRunAction<(\w+)[,>]", constructed): + return _function_body(main_cpp, match.group(1)) + return constructed + + +def _action_class(main_cpp: str, action: str) -> str: + return re.match(r"[\w:]+", _constructed(main_cpp, action)).group(0) + + +def test_no_args_service(generate_main) -> None: + """A service without arguments triggers an Automation of its merged inline actions.""" + main_cpp = generate_main(CONFIG) + service = _service(main_cpp, "no_args") + assert service.type == f"api::UserServiceTrigger<{NONE}>" + automation, args = _automation(main_cpp, service.id) + assert args == "" + (action,) = _actions_of(main_cpp, automation) + assert _action_class(main_cpp, action) == "ApplyRunAction" + code = _action_code(main_cpp, action) + assert "NO_ARGS called" in code + assert "::doubled->value() = 1;" in code + + +def test_mixed_args_types(generate_main) -> None: + """Strings and arrays are non-owning views when every action is synchronous.""" + main_cpp = generate_main(CONFIG) + types = ( + "bool, int32_t, float, StringRef, const FixedVector &, " + "const FixedVector &" + ) + service = _service(main_cpp, "mixed_args") + assert service.type == f"api::UserServiceTrigger<{NONE}, {types}>" + automation, args = _automation(main_cpp, service.id) + assert args == types + assert _strings(main_cpp, "mixed_args")[1] == ( + '"mixed_args", "bool_", "int_", "float_", "string_", "int_arr", "string_arr"' + ) + (action,) = _actions_of(main_cpp, automation) + assert "string_.c_str(), int_arr.size(), string_arr.size()" in _action_code( + main_cpp, action + ) + + +def test_lambda_action_gets_args_by_value(generate_main) -> None: + """A lambda action takes the arguments by value, so ``x *= 2`` compiles in both forms.""" + main_cpp = generate_main(CONFIG) + service = _service(main_cpp, "by_value") + assert service.type == f"api::UserServiceTrigger<{NONE}, int32_t>" + automation, _ = _automation(main_cpp, service.id) + (action,) = _actions_of(main_cpp, automation) + code = _action_code(main_cpp, action) + assert code.count("[](int32_t x) -> void {") == 2 + assert "x *= 2;" in code + assert '"BY_VALUE again %d", x *= 2' in code + + +def test_delay_uses_owning_args(generate_main) -> None: + """A delay keeps separate actions and owning string and array arguments.""" + main_cpp = generate_main(CONFIG) + types = "std::string, std::vector" + service = _service(main_cpp, "with_delay") + assert service.type == f"api::UserServiceTrigger<{NONE}, {types}>" + automation, args = _automation(main_cpp, service.id) + assert args == types + before, delay, after = _actions_of(main_cpp, automation) + assert _action_class(main_cpp, delay) == "DelayAction" + assert "WITH_DELAY before" in _action_code(main_cpp, before) + after_code = _action_code(main_cpp, after) + assert "WITH_DELAY %s %zu" in after_code + assert "[](std::string name, std::vector int_arr) -> void" in after_code + + +def test_respond_optional(generate_main) -> None: + """An optional response passes call_id and return_response; the call is unregistered last.""" + main_cpp = generate_main(CONFIG) + service = _service(main_cpp, "respond_optional") + assert service.type == f"api::UserServiceTrigger<{OPTIONAL}, int32_t>" + automation, args = _automation(main_cpp, service.id) + assert args == "uint32_t, bool, int32_t" + respond, unregister = _actions_of(main_cpp, automation) + assert _constructed(main_cpp, respond) == ( + "api::APIRespondAction(api_apiserver_id)" + ) + assert f"{respond}->set_is_optional_mode(true);" in main_cpp + assert unregister == f"{service.id}__unregister" + assert _constructed(main_cpp, unregister) == ( + "api::APIUnregisterServiceCallAction(api_apiserver_id)" + ) + + +def test_respond_only(generate_main) -> None: + """A response-only service passes call_id; the call is unregistered after every action.""" + main_cpp = generate_main(CONFIG) + service = _service(main_cpp, "respond_only") + assert service.type == f"api::UserServiceTrigger<{ONLY}, StringRef>" + automation, args = _automation(main_cpp, service.id) + assert args == "uint32_t, StringRef" + log, respond, unregister = _actions_of(main_cpp, automation) + assert "RESPOND_ONLY %s" in _action_code(main_cpp, log) + assert _constructed(main_cpp, respond) == ( + "api::APIRespondAction(api_apiserver_id)" + ) + assert f"{respond}->set_is_optional_mode" not in main_cpp + assert unregister == f"{service.id}__unregister" + assert _constructed(main_cpp, unregister) == ( + "api::APIUnregisterServiceCallAction(api_apiserver_id)" + ) + + +def test_manual_trigger_id(generate_main) -> None: + """A trigger_id set by hand names the service object.""" + main_cpp = generate_main(CONFIG) + assert _service(main_cpp, "manual_trigger_id") == Service( + "manual_service_trigger", f"api::UserServiceTrigger<{NONE}>" + ) + automation, _ = _automation(main_cpp, "manual_service_trigger") + (action,) = _actions_of(main_cpp, automation) + assert "MANUAL called" in _action_code(main_cpp, action) + + +def test_lambda_uses_own_service_id(generate_main) -> None: + """A lambda in a service can refer to the service's own id.""" + main_cpp = generate_main(CONFIG) + service = _service(main_cpp, "self_reference") + assert service.id == "self_reference_service" + automation, _ = _automation(main_cpp, service.id) + (action,) = _actions_of(main_cpp, automation) + assert "(const void *) self_reference_service" in _action_code(main_cpp, action) + + +def test_services_registered_in_order(generate_main) -> None: + """Every service is registered with the server once, in config order.""" + main_cpp = generate_main(CONFIG) + names = [ + "no_args", + "mixed_args", + "by_value", + "with_delay", + "respond_optional", + "respond_only", + "manual_trigger_id", + "self_reference", + ] + match = re.search( + r"\bapi_apiserver_id->initialize_user_services\(\{(.*?)\}\);", main_cpp + ) + assert match is not None + assert match.group(1).split(", ") == [_service(main_cpp, n).id for n in names] + + +def test_legacy_services_key(generate_main) -> None: + """The legacy ``services:`` key with ``service:`` names builds the same service.""" + main_cpp = generate_main(LEGACY_CONFIG) + assert _strings(main_cpp, "legacy_service")[1] == '"legacy_service", "devices"' + service = _service(main_cpp, "legacy_service") + assert service.type == f"api::UserServiceTrigger<{NONE}, StringRef>" + automation, args = _automation(main_cpp, service.id) + assert args == "StringRef" + (action,) = _actions_of(main_cpp, automation) + assert '"LEGACY %s", devices.c_str()' in _action_code(main_cpp, action) diff --git a/tests/component_tests/api/test_user_services.yaml b/tests/component_tests/api/test_user_services.yaml new file mode 100644 index 00000000000..83b4fb7bd54 --- /dev/null +++ b/tests/component_tests/api/test_user_services.yaml @@ -0,0 +1,91 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +globals: + - id: doubled + type: int + initial_value: "0" + +api: + actions: + - action: no_args + then: + - logger.log: "NO_ARGS called" + - globals.set: + id: doubled + value: "1" + - action: mixed_args + variables: + bool_: bool + int_: int + float_: float + string_: string + int_arr: int[] + string_arr: string[] + then: + - logger.log: + format: "MIXED %d %ld %.1f %s %zu %zu" + args: + - bool_ + - (long) int_ + - float_ + - string_.c_str() + - int_arr.size() + - string_arr.size() + # A lambda action gets the arguments by value, so it may change them + - action: by_value + variables: + x: int + then: + - lambda: |- + x *= 2; + ESP_LOGD("test", "BY_VALUE %d", x); + - lambda: |- + ESP_LOGD("test", "BY_VALUE again %d", x *= 2); + - action: with_delay + variables: + name: string + int_arr: int[] + then: + - logger.log: "WITH_DELAY before" + - delay: 10ms + - logger.log: + format: "WITH_DELAY %s %zu" + args: [name.c_str(), int_arr.size()] + - action: respond_optional + variables: + value: int + then: + - api.respond: + data: !lambda |- + root["value"] = value; + - action: respond_only + supports_response: only + variables: + name: string + then: + - logger.log: + format: "RESPOND_ONLY %s" + args: [name.c_str()] + - api.respond: + data: !lambda |- + root["name"] = name; + - action: manual_trigger_id + trigger_id: manual_service_trigger + then: + - logger.log: "MANUAL called" + # A lambda may use the service's own id + - action: self_reference + trigger_id: self_reference_service + then: + - lambda: |- + ESP_LOGD("test", "SELF_REFERENCE %p", (const void *) id(self_reference_service)); diff --git a/tests/component_tests/api/test_user_services_legacy.yaml b/tests/component_tests/api/test_user_services_legacy.yaml new file mode 100644 index 00000000000..d905ef2972c --- /dev/null +++ b/tests/component_tests/api/test_user_services_legacy.yaml @@ -0,0 +1,20 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +logger: + +api: + services: + - service: legacy_service + variables: + devices: string + then: + - lambda: |- + ESP_LOGD("test", "LEGACY %s", devices.c_str()); diff --git a/tests/integration/fixtures/api_custom_services.yaml b/tests/integration/fixtures/api_custom_services.yaml index 827bee93a6e..87bfbf5defa 100644 --- a/tests/integration/fixtures/api_custom_services.yaml +++ b/tests/integration/fixtures/api_custom_services.yaml @@ -34,6 +34,55 @@ api: format: "YAML service many args: %d, %.2f, %d, %s" args: [arg1, arg2, arg3, arg4.c_str()] + # Array arguments, read in place + - action: test_yaml_service_arrays + variables: + bool_arr: bool[] + int_arr: int[] + float_arr: float[] + string_arr: string[] + then: + - logger.log: + format: "YAML service arrays: %d %d, %ld %ld %ld, %.1f, %s %s" + args: + - bool_arr[0] + - bool_arr[1] + - (long) int_arr[0] + - (long) int_arr[1] + - (long) int_arr[2] + - float_arr[0] + - string_arr[0].c_str() + - string_arr[1].c_str() + + # Arguments still read after a delay + - action: test_yaml_service_delay + variables: + name: string + int_arr: int[] + then: + - logger.log: "YAML service delay before" + - delay: 10ms + - logger.log: + format: "YAML service delay after: %s %zu %ld" + args: + - name.c_str() + - int_arr.size() + - (long) int_arr[1] + + # Each lambda gets its own copy of the arguments + - action: test_yaml_service_by_value + variables: + x: int + then: + - lambda: |- + x *= 2; + ESP_LOGI("test", "YAML service by value first: %d", (int) x); + - lambda: |- + ESP_LOGI("test", "YAML service by value second: %d", (int) (x *= 3)); + - logger.log: + format: "YAML service by value last: %d" + args: [(int) x] + logger: level: DEBUG diff --git a/tests/integration/test_api_custom_services.py b/tests/integration/test_api_custom_services.py index 24b5ceea723..8bf36cc8193 100644 --- a/tests/integration/test_api_custom_services.py +++ b/tests/integration/test_api_custom_services.py @@ -30,8 +30,8 @@ async def test_api_custom_services( # List services _, services = await client.list_entities_services() - # Should have 6 services: 3 YAML + 3 CustomAPIDevice - assert len(services) == 6, f"Expected 6 services, found {len(services)}" + # Should have 9 services: 6 YAML + 3 CustomAPIDevice + assert len(services) == 9, f"Expected 9 services, found {len(services)}" # Find our services yaml_service = require_service(services, "test_yaml_service") @@ -85,6 +85,43 @@ async def test_api_custom_services( ) await waiter.wait_for("YAML service many args: 42, 3.14, 1, hello", timeout=5.0) + # YAML service with array arguments + yaml_arrays_service = require_service(services, "test_yaml_service_arrays") + assert [arg.type for arg in yaml_arrays_service.args] == [ + UserServiceArgType.BOOL_ARRAY, + UserServiceArgType.INT_ARRAY, + UserServiceArgType.FLOAT_ARRAY, + UserServiceArgType.STRING_ARRAY, + ] + await client.execute_service( + yaml_arrays_service, + { + "bool_arr": [True, False], + "int_arr": [7, -8, 9], + "float_arr": [2.5], + "string_arr": ["ab", "cd"], + }, + ) + await waiter.wait_for( + "YAML service arrays: 1 0, 7 -8 9, 2.5, ab cd", timeout=5.0 + ) + + # Arguments survive a delay + await client.execute_service( + require_service(services, "test_yaml_service_delay"), + {"name": "late", "int_arr": [4, 5]}, + ) + await waiter.wait_for("YAML service delay before", timeout=5.0) + await waiter.wait_for("YAML service delay after: late 2 5", timeout=5.0) + + # A lambda's change to an argument stays in that lambda + await client.execute_service( + require_service(services, "test_yaml_service_by_value"), {"x": 5} + ) + await waiter.wait_for("YAML service by value first: 10", timeout=5.0) + await waiter.wait_for("YAML service by value second: 15", timeout=5.0) + await waiter.wait_for("YAML service by value last: 5", timeout=5.0) + # Test simple CustomAPIDevice service await client.execute_service(custom_service, {}) await waiter.wait_for("Custom test service called!", timeout=5.0) From 0ac52f42adc0a5677dab752ba2a17ae64714861e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:38:23 -1000 Subject: [PATCH 068/170] [core] Add tests for how apply actions call user lambdas (#22007) --- .../config/nec_lambda_args.yaml | 18 +++++ .../test_nec_lambda_args.py | 26 +++++++ tests/unit_tests/test_automation.py | 76 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 tests/component_tests/remote_transmitter/config/nec_lambda_args.yaml create mode 100644 tests/component_tests/remote_transmitter/test_nec_lambda_args.py diff --git a/tests/component_tests/remote_transmitter/config/nec_lambda_args.yaml b/tests/component_tests/remote_transmitter/config/nec_lambda_args.yaml new file mode 100644 index 00000000000..acfc9e10150 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/nec_lambda_args.yaml @@ -0,0 +1,18 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 50% + +script: + - id: send_nec + parameters: + x: int + then: + - remote_transmitter.transmit_nec: + address: !lambda "x *= 2; return x;" + command: !lambda "return x *= 2;" diff --git a/tests/component_tests/remote_transmitter/test_nec_lambda_args.py b/tests/component_tests/remote_transmitter/test_nec_lambda_args.py new file mode 100644 index 00000000000..8e1a6d5741e --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_nec_lambda_args.py @@ -0,0 +1,26 @@ +"""How a transmit action's field lambdas get the script's arguments.""" + +from collections.abc import Callable +from pathlib import Path +import re + +from tests.component_tests.remote_transmitter import protocol_call_bodies + + +def test_nec_field_lambdas_get_the_script_args( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("nec_lambda_args.yaml")) + (body,) = protocol_call_bodies(main_cpp) + assert re.search( + r"static void esphome__\w+__fn\(const std::remove_cvref_t & x\) \{", + main_cpp, + ) + assert ( + "apply_call.data.address = [](const std::remove_cvref_t & x) -> uint16_t {" + in body + ) + assert "x *= 2; return x;" in body + assert "}(x);" in body + assert "apply_call.data.command = static_cast(x *= 2);" in body diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index f72f4876a80..afa94276662 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1140,6 +1140,52 @@ async def test_apply_lambdas( assert "}(x));" in text +@pytest.mark.asyncio +async def test_apply_lambda_modifying_its_arg( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """How a field lambda that modifies its trigger arg is rendered.""" + fields = ( + ApplyField("kp", "set_kp", cg.float_), + ApplyField("ki", "set_ki", cg.float_), + ) + config = {"kp": Lambda("x *= 2;\nreturn x;"), "ki": Lambda("return x *= 2;")} + await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) + text = _squash(_apply_definition(mock_cg)) + assert text.startswith( + "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" + ) + assert ( + f"::{PARENT_OBJ}->set_kp([](const std::remove_cvref_t & x) -> float " + "{ x *= 2; return x; }(x));" + ) in text + assert f"::{PARENT_OBJ}->set_ki(static_cast(x *= 2));" in text + + +@pytest.mark.asyncio +async def test_apply_condition_lambda_modifying_its_arg( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """How a condition lambda that modifies its trigger arg is rendered.""" + check = ApplyCall("state == {}", (("state", cg.float_),)) + await _run_apply_condition( + registries, + check, + {"state": Lambda("x *= 2;\nreturn x;")}, + args=[(cg.float_, "x")], + ) + assert ( + f"return ::{PARENT_OBJ}->state == [](const std::remove_cvref_t & x) " + "-> float { x *= 2; return x; }(x);" + ) in _squash(_apply_definition(mock_cg)) + await _run_apply_condition( + registries, check, {"state": Lambda("return x *= 2;")}, args=[(cg.float_, "x")] + ) + assert f"return ::{PARENT_OBJ}->state == static_cast(x *= 2);" in _squash( + _apply_definition(mock_cg) + ) + + @pytest.mark.asyncio async def test_apply_call_keys( registries: tuple[Registry, Registry], mock_cg: MockCodegen @@ -1727,6 +1773,36 @@ async def test_trigger_callback_filter_names_parent(mock_cg: MockCodegen) -> Non ) in text +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body", "expected"), + [ + ( + "x *= 2;\nreturn x;", + ( + "if (!(x == [](const std::remove_cvref_t & x) -> float " + "{ x *= 2; return x; }(x))) return;" + ), + ), + ("return x *= 2;", "if (!(x == static_cast(x *= 2))) return;"), + ], +) +async def test_trigger_callback_filter_lambda_modifying_its_arg( + mock_cg: MockCodegen, body: str, expected: str +) -> None: + """How a filter lambda that modifies the callback's arg is rendered.""" + text = _squash( + await build_trigger_callback( + [(cg.float_, "x")], + {**TRIGGER_CONF, "level": Lambda(body)}, + [(cg.float_, "x")], + when=ApplyCall("x == {}", (("level", cg.float_),)), + ) + ) + assert text.startswith("[](const std::remove_cvref_t & x) -> void {") + assert expected in text + + @pytest.mark.asyncio async def test_build_callback_automations_when_names_parent( mock_cg: MockCodegen, From b76f491eef573a3a4e63caf3b314a260d41b3cb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:38:35 -1000 Subject: [PATCH 069/170] [core] Add tests for component.update, suspend and resume codegen (#21985) --- tests/components/teleinfo/common.yaml | 13 ++++ tests/unit_tests/test_automation.py | 107 ++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index 82082b39fac..4fbb7b0fc84 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -16,6 +16,19 @@ button: id: test_teleinfo update_interval: !lambda return 2500; +script: + - id: resume_teleinfo_seconds + parameters: + seconds: int + then: + # The interval lambda may modify its by-value argument + - component.resume: + id: test_teleinfo + update_interval: !lambda "seconds *= 1000; return seconds;" + - component.resume: + id: test_teleinfo + update_interval: !lambda "return seconds *= 1000;" + teleinfo: id: test_teleinfo historical_mode: true diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index afa94276662..fb5ebeb0e33 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -31,7 +31,6 @@ from esphome.automation import ( NotCondition, OrCondition, RepeatAction, - ResumeComponentAction, StatelessLambdaCondition, TriggerForwarder, TriggerOnFalseForwarder, @@ -92,7 +91,15 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda +from esphome.core import ( + CORE, + ID, + KEY_CORE, + KEY_TARGET_PLATFORM, + EsphomeError, + Lambda, + TimePeriodMilliseconds, +) from esphome.cpp_generator import LambdaExpression, MockObj, RawExpression from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.types import ConfigType @@ -2301,22 +2308,90 @@ async def test_wait_until_action_timeout_is_optional(with_timeout: bool) -> None assert ("wait->set_timeout_value(" in CORE.cpp_main_section) == with_timeout -@pytest.mark.asyncio -@pytest.mark.parametrize("with_interval", [True, False]) -async def test_component_resume_action_update_interval_is_optional( - with_interval: bool, -) -> None: +def _component_action(name: str, body: ConfigType | None = None) -> ConfigType: + """Config for a ``component.*`` action on ``comp``, typed as the registry declares it.""" + return { + name: {CONF_ID: ID("comp", type=cg.PollingComponent), **(body or {})}, + CONF_TYPE_ID: ID( + "act", + is_declaration=True, + type=ACTION_REGISTRY[name].type_id, + is_manual=False, + ), + } + + +@pytest.fixture +def polling_component() -> None: CORE.config = {} cg.new_Pvariable(ID("comp", is_declaration=True, type=cg.PollingComponent)) - body: ConfigType = {CONF_ID: ID("comp", type=cg.PollingComponent)} - if with_interval: - body[CONF_UPDATE_INTERVAL] = 250 - config: ConfigType = { - "component.resume": body, - CONF_TYPE_ID: ID("resume", is_declaration=True, type=ResumeComponentAction), - } - await build_action(config, TEMPLATE_ARG, []) - assert ("resume->set_update_interval(" in CORE.cpp_main_section) == with_interval + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("polling_component") +@pytest.mark.parametrize( + "name", + ["component.update", "component.suspend", "component.resume"], +) +async def test_component_action_codegen(name: str) -> None: + """Each action is an object constructed with the component, with no interval set.""" + await build_action(_component_action(name), TEMPLATE_ARG, []) + + class_name = str(ACTION_REGISTRY[name].type_id) + assert f"new(act) {class_name}<>(comp);" in CORE.cpp_main_section + assert "set_update_interval" not in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("polling_component") +async def test_component_resume_constant_interval_codegen() -> None: + await build_action( + _component_action( + "component.resume", + {CONF_UPDATE_INTERVAL: TimePeriodMilliseconds(milliseconds=250)}, + ), + TEMPLATE_ARG, + [], + ) + + assert ( + "act->set_update_interval([]() -> uint32_t {\n return 250;\n});" + ) in CORE.cpp_main_section + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("polling_component") +async def test_component_resume_interval_lambda_codegen() -> None: + """A templated interval is a lambda over the trigger args.""" + await build_action( + _component_action( + "component.resume", {CONF_UPDATE_INTERVAL: Lambda("return x * 1000;")} + ), + cg.TemplateArguments(cg.uint32), + [(cg.uint32, "x")], + ) + + main = CORE.cpp_main_section + assert "new(act) ResumeComponentAction(comp);" in main + assert ( + "act->set_update_interval([](uint32_t x) -> uint32_t {\n" + " return x * 1000;\n" + "});" + ) in main + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("polling_component") +@pytest.mark.parametrize("body", ["x *= 1000; return x;", "return x *= 1000;"]) +async def test_component_resume_interval_lambda_takes_args_by_value(body: str) -> None: + """The user's interval lambda gets the trigger args by value, so it may modify them.""" + await build_action( + _component_action("component.resume", {CONF_UPDATE_INTERVAL: Lambda(body)}), + cg.TemplateArguments(cg.uint32), + [(cg.uint32, "x")], + ) + + assert f"[](uint32_t x) -> uint32_t {{\n {body}\n}}" in CORE.cpp_main_section COMPONENT_ID = ID("comp", type=cg.Component) From bfb4ad7b02153d0ad488098a856356a7a0226647 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:44:25 -1000 Subject: [PATCH 070/170] [ade7880] Build the channels from a table in code generation (#22076) --- esphome/components/ade7880/sensor.py | 90 ++++++++++------------------ 1 file changed, 33 insertions(+), 57 deletions(-) diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py index 7e01b35b73b..ca4eeb30802 100644 --- a/esphome/components/ade7880/sensor.py +++ b/esphome/components/ade7880/sensor.py @@ -53,9 +53,6 @@ CONF_POWER_GAIN = "power_gain" CONF_NEUTRAL = "neutral" -# Tuple of power channel phases -POWER_PHASES = (CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C) - # Tuple of sensor types that can be configured for power channels POWER_SENSOR_TYPES = ( CONF_CURRENT, @@ -66,6 +63,20 @@ POWER_SENSOR_TYPES = ( CONF_FORWARD_ACTIVE_ENERGY, CONF_REVERSE_ACTIVE_ENERGY, ) +POWER_CALIBRATION_TYPES = ( + CONF_CURRENT_GAIN, + CONF_VOLTAGE_GAIN, + CONF_POWER_GAIN, + CONF_PHASE_ANGLE, +) + +# Channel config key, ADE7880 setter, sensor types, calibration types +CHANNELS = ( + (CONF_PHASE_A, "set_channel_a", POWER_SENSOR_TYPES, POWER_CALIBRATION_TYPES), + (CONF_PHASE_B, "set_channel_b", POWER_SENSOR_TYPES, POWER_CALIBRATION_TYPES), + (CONF_PHASE_C, "set_channel_c", POWER_SENSOR_TYPES, POWER_CALIBRATION_TYPES), + (CONF_NEUTRAL, "set_channel_n", (CONF_CURRENT,), (CONF_CURRENT_GAIN,)), +) NEUTRAL_CHANNEL_SCHEMA = cv.Schema( { @@ -191,7 +202,7 @@ def prefix_sensor_name( def process_channel_sensors( - config: ConfigType, channel_key: str, sensor_types: tuple + config: ConfigType, channel_key: str, sensor_types: tuple[str, ...] ) -> None: """Process sensors for a channel and prefix their names. @@ -212,12 +223,8 @@ def process_channel_sensors( def preprocess_channels(config: ConfigType) -> ConfigType: """Preprocess channel configurations to add channel name prefix to sensor names.""" - # Process power channels - for channel in POWER_PHASES: - process_channel_sensors(config, channel, POWER_SENSOR_TYPES) - - # Process neutral channel - process_channel_sensors(config, CONF_NEUTRAL, (CONF_CURRENT,)) + for channel_key, _, sensor_types, _ in CHANNELS: + process_channel_sensors(config, channel_key, sensor_types) return config @@ -244,39 +251,20 @@ CONFIG_SCHEMA = cv.All( ) -async def neutral_channel(config: ConfigType) -> MockObj: +async def new_channel( + config: ConfigType, + sensor_types: tuple[str, ...], + calibration_types: tuple[str, ...], +) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) - current = config[CONF_CURRENT] - sens = await sensor.new_sensor(current) - cg.add(var.set_current(sens)) + sensors = sensor.sub_sensors(config) + for sensor_type in sensor_types: + await sensors(sensor_type, getattr(var, f"set_{sensor_type}")) - cg.add( - var.set_current_gain_calibration(config[CONF_CALIBRATION][CONF_CURRENT_GAIN]) - ) - - return var - - -async def power_channel(config: ConfigType) -> MockObj: - var = cg.new_Pvariable(config[CONF_ID]) - - for sensor_type in POWER_SENSOR_TYPES: - if conf := config.get(sensor_type): - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{sensor_type}")(sens)) - - for calib_type in [ - CONF_CURRENT_GAIN, - CONF_VOLTAGE_GAIN, - CONF_POWER_GAIN, - CONF_PHASE_ANGLE, - ]: - cg.add( - getattr(var, f"set_{calib_type}_calibration")( - config[CONF_CALIBRATION][calib_type] - ) - ) + calibration = config[CONF_CALIBRATION] + for calib_type in calibration_types: + cg.add(getattr(var, f"set_{calib_type}_calibration")(calibration[calib_type])) return var @@ -288,21 +276,9 @@ async def to_code(config: ConfigType) -> None: await cg.add_pin(var.set_irq1_pin, config[CONF_IRQ1_PIN]) await cg.add_pin(var.set_reset_pin, config.get(CONF_RESET_PIN)) - if frequency := config.get(CONF_FREQUENCY): - cg.add(var.set_frequency(frequency)) + cg.add(var.set_frequency(config[CONF_FREQUENCY])) - if channel := config.get(CONF_PHASE_A): - chan = await power_channel(channel) - cg.add(var.set_channel_a(chan)) - - if channel := config.get(CONF_PHASE_B): - chan = await power_channel(channel) - cg.add(var.set_channel_b(chan)) - - if channel := config.get(CONF_PHASE_C): - chan = await power_channel(channel) - cg.add(var.set_channel_c(chan)) - - if channel := config.get(CONF_NEUTRAL): - chan = await neutral_channel(channel) - cg.add(var.set_channel_n(chan)) + for channel_key, setter, sensor_types, calibration_types in CHANNELS: + if channel := config.get(channel_key): + chan = await new_channel(channel, sensor_types, calibration_types) + cg.add(getattr(var, setter)(chan)) From ae0850f15993e46c8eb3232885c88a9305db3ef5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:47:33 -1000 Subject: [PATCH 071/170] [components] Declare entity pointers with the SUB_* macros (#22051) --- .../absolute_humidity/absolute_humidity.h | 8 ++- esphome/components/aqi/aqi_sensor.h | 7 ++- .../bang_bang/bang_bang_climate.cpp | 4 +- .../components/bang_bang/bang_bang_climate.h | 10 ++-- esphome/components/bang_bang/climate.py | 2 +- esphome/components/climate_ir/climate_ir.h | 4 +- esphome/components/combination/combination.h | 7 ++- .../components/copy/binary_sensor/__init__.py | 2 +- .../copy/binary_sensor/copy_binary_sensor.cpp | 6 +-- .../copy/binary_sensor/copy_binary_sensor.h | 6 +-- esphome/components/copy/number/__init__.py | 2 +- .../components/copy/number/copy_number.cpp | 14 +++--- esphome/components/copy/number/copy_number.h | 5 +- esphome/components/copy/select/__init__.py | 2 +- .../components/copy/select/copy_select.cpp | 8 +-- esphome/components/copy/select/copy_select.h | 5 +- esphome/components/copy/switch/__init__.py | 2 +- .../components/copy/switch/copy_switch.cpp | 6 +-- esphome/components/copy/switch/copy_switch.h | 5 +- .../current_based/current_based_cover.h | 7 ++- esphome/components/dew_point/dew_point.h | 8 ++- .../components/display_menu_base/__init__.py | 6 +-- .../display_menu_base/menu_item.cpp | 49 ++++++++++--------- .../components/display_menu_base/menu_item.h | 14 +++--- esphome/components/endstop/cover.py | 6 ++- esphome/components/endstop/endstop_cover.cpp | 4 +- esphome/components/endstop/endstop_cover.h | 11 ++--- esphome/components/graph/__init__.py | 2 +- esphome/components/graph/graph.cpp | 18 ++++--- esphome/components/graph/graph.h | 4 +- esphome/components/improv_ble/__init__.py | 2 +- .../improv_ble/improv_ble_component.cpp | 14 +++--- .../improv_ble/improv_ble_component.h | 10 ++-- .../integration/integration_sensor.cpp | 2 +- .../integration/integration_sensor.h | 4 +- esphome/components/integration/sensor.py | 2 +- esphome/components/midea/air_conditioner.cpp | 4 +- esphome/components/midea/air_conditioner.h | 10 ++-- esphome/components/ntc/ntc.cpp | 6 +-- esphome/components/ntc/ntc.h | 4 +- esphome/components/ntc/sensor.py | 2 +- esphome/components/pid/climate.py | 2 +- esphome/components/pid/pid_climate.cpp | 4 +- esphome/components/pid/pid_climate.h | 9 ++-- .../components/resistance/resistance_sensor.h | 10 ++-- esphome/components/resistance/sensor.py | 2 +- esphome/components/scd4x/scd4x.cpp | 8 +-- esphome/components/scd4x/scd4x.h | 4 +- esphome/components/scd4x/sensor.py | 2 +- esphome/components/sen6x/sen6x.cpp | 8 +-- esphome/components/sen6x/sen6x.h | 4 +- esphome/components/sen6x/sensor.py | 2 +- esphome/components/stcc4/sensor.py | 8 +-- esphome/components/stcc4/stcc4.cpp | 36 ++++++++------ esphome/components/stcc4/stcc4.h | 11 ++--- .../components/template/climate/__init__.py | 2 +- .../template/climate/template_climate.cpp | 6 +-- .../template/climate/template_climate.h | 17 +++---- esphome/components/yashima/climate.py | 2 +- esphome/components/yashima/yashima.cpp | 8 +-- esphome/components/yashima/yashima.h | 4 +- .../improv_ble/test_improv_ble.py | 2 +- 62 files changed, 213 insertions(+), 232 deletions(-) diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h index 9989bb17fc8..3289e17e230 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.h +++ b/esphome/components/absolute_humidity/absolute_humidity.h @@ -14,9 +14,10 @@ enum SaturationVaporPressureEquation { /// This class implements calculation of absolute humidity from temperature and relative humidity. class AbsoluteHumidityComponent final : public sensor::Sensor, public Component { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } void set_equation(SaturationVaporPressureEquation equation) { this->equation_ = equation; } void setup() override; @@ -48,9 +49,6 @@ class AbsoluteHumidityComponent final : public sensor::Sensor, public Component */ static float vapor_density(float es, float hr, float ta); - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; - float temperature_{NAN}; float humidity_{NAN}; SaturationVaporPressureEquation equation_; diff --git a/esphome/components/aqi/aqi_sensor.h b/esphome/components/aqi/aqi_sensor.h index 464c088188b..3fee5bb3208 100644 --- a/esphome/components/aqi/aqi_sensor.h +++ b/esphome/components/aqi/aqi_sensor.h @@ -7,20 +7,19 @@ namespace esphome::aqi { class AQISensor final : public sensor::Sensor, public Component { + SUB_SENSOR(pm_2_5) + SUB_SENSOR(pm_10_0) + public: void setup() override; void dump_config() override; - void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; } - void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; } void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; } void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; } protected: void calculate_aqi_(); - sensor::Sensor *pm_2_5_sensor_{nullptr}; - sensor::Sensor *pm_10_0_sensor_{nullptr}; AQICalculatorType aqi_calc_type_{AQI_TYPE}; bool extended_range_{false}; AQICalculatorFactory aqi_calculator_factory_; diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index 1546df9f10b..cc2ee72d10e 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -8,14 +8,14 @@ ESPHOME_LOG_TAG(TAG, "bang_bang.climate"); BangBangClimate::BangBangClimate() = default; void BangBangClimate::setup() { - this->sensor_->add_on_state_callback([this](float state) { + this->temperature_sensor_->add_on_state_callback([this](float state) { this->current_temperature = state; // control may have changed, recompute this->compute_state_(); // current temperature changed, publish state this->publish_state(); }); - this->current_temperature = this->sensor_->state; + this->current_temperature = this->temperature_sensor_->state; // register for humidity values and get initial state if (this->humidity_sensor_ != nullptr) { diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index fff9bf873ff..ba7c284ce1d 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -17,13 +17,14 @@ struct BangBangClimateTargetTempConfig { }; class BangBangClimate final : public climate::Climate, public Component { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: BangBangClimate(); void setup() override; void dump_config() override; - void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_normal_config(const BangBangClimateTargetTempConfig &normal_config); @@ -47,11 +48,6 @@ class BangBangClimate final : public climate::Climate, public Component { /// Switch the climate device to the given climate mode. void switch_to_action_(climate::ClimateAction action); - /// The sensor used for getting the current temperature - sensor::Sensor *sensor_{nullptr}; - /// The sensor used for getting the current humidity - sensor::Sensor *humidity_sensor_{nullptr}; - /** The trigger to call when the controller should switch to idle mode. * * In idle mode, the controller is assumed to have both heating and cooling disabled. diff --git a/esphome/components/bang_bang/climate.py b/esphome/components/bang_bang/climate.py index e09b187ae3d..98be96406f4 100644 --- a/esphome/components/bang_bang/climate.py +++ b/esphome/components/bang_bang/climate.py @@ -46,7 +46,7 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_temperature_sensor, config[CONF_SENSOR]) await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) normal_config = BangBangClimateTargetTempConfig( diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 20dd49d28c8..8399ce68d4a 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -21,6 +21,8 @@ class ClimateIR : public Component, public climate::Climate, public remote_base::RemoteReceiverListener, public remote_base::RemoteTransmittable { + SUB_SENSOR(humidity) + public: ClimateIR(float minimum_temperature, float maximum_temperature, float temperature_step = 1.0f, bool supports_dry = false, bool supports_fan_only = false, @@ -47,7 +49,6 @@ class ClimateIR : public Component, this->set_mode_supported_(climate::CLIMATE_MODE_HEAT_COOL, supports_heat_cool); } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } - void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } protected: float minimum_temperature_, maximum_temperature_, temperature_step_; @@ -84,7 +85,6 @@ class ClimateIR : public Component, climate::ClimatePresetMask presets_{}; sensor::Sensor *sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; }; } // namespace esphome::climate_ir diff --git a/esphome/components/combination/combination.h b/esphome/components/combination/combination.h index 00745663e08..d170e15c8f0 100644 --- a/esphome/components/combination/combination.h +++ b/esphome/components/combination/combination.h @@ -59,6 +59,9 @@ class CombinationOneParameterComponent : public CombinationComponent { }; class KalmanCombinationComponent final : public CombinationOneParameterComponent { + // Optional sensor for publishing the current error + SUB_SENSOR(std_dev) + public: void dump_config() override; void setup() override; @@ -66,15 +69,11 @@ class KalmanCombinationComponent final : public CombinationOneParameterComponent void set_process_std_dev(float process_std_dev) { this->update_variance_value_ = process_std_dev * process_std_dev * 0.001f; } - void set_std_dev_sensor(Sensor *sensor) { this->std_dev_sensor_ = sensor; } protected: void update_variance_(); void correct_(float value, float stddev); - // Optional sensor for publishing the current error - sensor::Sensor *std_dev_sensor_{nullptr}; - // Tick of the last update uint32_t last_update_{0}; // Change of the variance, per ms diff --git a/esphome/components/copy/binary_sensor/__init__.py b/esphome/components/copy/binary_sensor/__init__.py index e21743ef296..e4726ba2c33 100644 --- a/esphome/components/copy/binary_sensor/__init__.py +++ b/esphome/components/copy/binary_sensor/__init__.py @@ -38,4 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) await cg.register_component(var, config) - await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) + await cg.add_variable(var.set_source_binary_sensor, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp index 80093a4a965..9860a0248d5 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp @@ -6,9 +6,9 @@ namespace esphome::copy { ESPHOME_LOG_TAG(TAG, "copy.binary_sensor"); void CopyBinarySensor::setup() { - source_->add_on_state_callback([this](bool value) { this->publish_state(value); }); - if (source_->has_state()) - this->publish_state(source_->state); + this->source_binary_sensor_->add_on_state_callback([this](bool value) { this->publish_state(value); }); + if (this->source_binary_sensor_->has_state()) + this->publish_state(this->source_binary_sensor_->state); } void CopyBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Copy Binary Sensor", this); } diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.h b/esphome/components/copy/binary_sensor/copy_binary_sensor.h index b30ca9cb212..4928b3fbb89 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.h +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.h @@ -6,13 +6,11 @@ namespace esphome::copy { class CopyBinarySensor final : public binary_sensor::BinarySensor, public Component { + SUB_BINARY_SENSOR(source) + public: - void set_source(binary_sensor::BinarySensor *source) { source_ = source; } void setup() override; void dump_config() override; - - protected: - binary_sensor::BinarySensor *source_; }; } // namespace esphome::copy diff --git a/esphome/components/copy/number/__init__.py b/esphome/components/copy/number/__init__.py index c0370afc18c..0a60a899351 100644 --- a/esphome/components/copy/number/__init__.py +++ b/esphome/components/copy/number/__init__.py @@ -38,4 +38,4 @@ async def to_code(config: ConfigType) -> None: var = await number.new_number(config, min_value=0, max_value=0, step=0) await cg.register_component(var, config) - await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) + await cg.add_variable(var.set_source_number, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/number/copy_number.cpp b/esphome/components/copy/number/copy_number.cpp index 7ddc7da1704..6d8c5e7a684 100644 --- a/esphome/components/copy/number/copy_number.cpp +++ b/esphome/components/copy/number/copy_number.cpp @@ -6,20 +6,20 @@ namespace esphome::copy { ESPHOME_LOG_TAG(TAG, "copy.number"); void CopyNumber::setup() { - source_->add_on_state_callback([this](float value) { this->publish_state(value); }); + this->source_number_->add_on_state_callback([this](float value) { this->publish_state(value); }); - traits.set_min_value(source_->traits.get_min_value()); - traits.set_max_value(source_->traits.get_max_value()); - traits.set_step(source_->traits.get_step()); + traits.set_min_value(this->source_number_->traits.get_min_value()); + traits.set_max_value(this->source_number_->traits.get_max_value()); + traits.set_step(this->source_number_->traits.get_step()); - if (source_->has_state()) - this->publish_state(source_->state); + if (this->source_number_->has_state()) + this->publish_state(this->source_number_->state); } void CopyNumber::dump_config() { LOG_NUMBER("", "Copy Number", this); } void CopyNumber::control(float value) { - auto call2 = source_->make_call(); + auto call2 = this->source_number_->make_call(); call2.set_value(value); call2.perform(); } diff --git a/esphome/components/copy/number/copy_number.h b/esphome/components/copy/number/copy_number.h index 82af6cc8ea2..feb251231de 100644 --- a/esphome/components/copy/number/copy_number.h +++ b/esphome/components/copy/number/copy_number.h @@ -6,15 +6,14 @@ namespace esphome::copy { class CopyNumber final : public number::Number, public Component { + SUB_NUMBER(source) + public: - void set_source(number::Number *source) { source_ = source; } void setup() override; void dump_config() override; protected: void control(float value) override; - - number::Number *source_; }; } // namespace esphome::copy diff --git a/esphome/components/copy/select/__init__.py b/esphome/components/copy/select/__init__.py index e6a4091be8f..c29aeec5f76 100644 --- a/esphome/components/copy/select/__init__.py +++ b/esphome/components/copy/select/__init__.py @@ -31,4 +31,4 @@ async def to_code(config: ConfigType) -> None: await select.register_select(var, config, options=[]) await cg.register_component(var, config) - await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) + await cg.add_variable(var.set_source_select, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/select/copy_select.cpp b/esphome/components/copy/select/copy_select.cpp index 2315514ac54..337adedd9c9 100644 --- a/esphome/components/copy/select/copy_select.cpp +++ b/esphome/components/copy/select/copy_select.cpp @@ -6,11 +6,11 @@ namespace esphome::copy { ESPHOME_LOG_TAG(TAG, "copy.select"); void CopySelect::setup() { - source_->add_on_state_callback([this](size_t index) { this->publish_state(index); }); + this->source_select_->add_on_state_callback([this](size_t index) { this->publish_state(index); }); - traits.set_options(source_->traits.get_options()); + traits.set_options(this->source_select_->traits.get_options()); - auto idx = this->source_->active_index(); + auto idx = this->source_select_->active_index(); if (idx.has_value()) this->publish_state(*idx); } @@ -18,7 +18,7 @@ void CopySelect::setup() { void CopySelect::dump_config() { LOG_SELECT("", "Copy Select", this); } void CopySelect::control(size_t index) { - auto call = source_->make_call(); + auto call = this->source_select_->make_call(); call.set_index(index); call.perform(); } diff --git a/esphome/components/copy/select/copy_select.h b/esphome/components/copy/select/copy_select.h index 2c541d99bc1..b48d7e1ac9b 100644 --- a/esphome/components/copy/select/copy_select.h +++ b/esphome/components/copy/select/copy_select.h @@ -6,15 +6,14 @@ namespace esphome::copy { class CopySelect final : public select::Select, public Component { + SUB_SELECT(source) + public: - void set_source(select::Select *source) { source_ = source; } void setup() override; void dump_config() override; protected: void control(size_t index) override; - - select::Select *source_; }; } // namespace esphome::copy diff --git a/esphome/components/copy/switch/__init__.py b/esphome/components/copy/switch/__init__.py index 6bcd10ff200..7b8a7cf1e3d 100644 --- a/esphome/components/copy/switch/__init__.py +++ b/esphome/components/copy/switch/__init__.py @@ -36,4 +36,4 @@ async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) - await cg.add_variable(var.set_source, config[CONF_SOURCE_ID]) + await cg.add_variable(var.set_source_switch, config[CONF_SOURCE_ID]) diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 51850f898a0..86a3f73cfbb 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -6,13 +6,13 @@ namespace esphome::copy { ESPHOME_LOG_TAG(TAG, "copy.switch"); void CopySwitch::setup() { - source_->add_on_state_callback([this](float value) { this->publish_state(value); }); + this->source_switch_->add_on_state_callback([this](float value) { this->publish_state(value); }); - this->publish_state(source_->state); + this->publish_state(this->source_switch_->state); } void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); } -void CopySwitch::write_state(bool state) { this->source_->control(state); } +void CopySwitch::write_state(bool state) { this->source_switch_->control(state); } } // namespace esphome::copy diff --git a/esphome/components/copy/switch/copy_switch.h b/esphome/components/copy/switch/copy_switch.h index bef254093ce..2e9c6e5c12c 100644 --- a/esphome/components/copy/switch/copy_switch.h +++ b/esphome/components/copy/switch/copy_switch.h @@ -6,15 +6,14 @@ namespace esphome::copy { class CopySwitch final : public switch_::Switch, public Component { + SUB_SWITCH(source) + public: - void set_source(switch_::Switch *source) { source_ = source; } void setup() override; void dump_config() override; protected: void write_state(bool state) override; - - switch_::Switch *source_; }; } // namespace esphome::copy diff --git a/esphome/components/current_based/current_based_cover.h b/esphome/components/current_based/current_based_cover.h index 41dd9962b70..d51098110d1 100644 --- a/esphome/components/current_based/current_based_cover.h +++ b/esphome/components/current_based/current_based_cover.h @@ -9,6 +9,9 @@ namespace esphome::current_based { class CurrentBasedCover final : public cover::Cover, public Component { + SUB_SENSOR(open) + SUB_SENSOR(close) + public: void setup() override; void loop() override; @@ -17,7 +20,6 @@ class CurrentBasedCover final : public cover::Cover, public Component { Trigger<> *get_stop_trigger() { return &this->stop_trigger_; } Trigger<> *get_open_trigger() { return &this->open_trigger_; } - void set_open_sensor(sensor::Sensor *open_sensor) { this->open_sensor_ = open_sensor; } void set_open_moving_current_threshold(float open_moving_current_threshold) { this->open_moving_current_threshold_ = open_moving_current_threshold; } @@ -27,7 +29,6 @@ class CurrentBasedCover final : public cover::Cover, public Component { void set_open_duration(uint32_t open_duration) { this->open_duration_ = open_duration; } Trigger<> *get_close_trigger() { return &this->close_trigger_; } - void set_close_sensor(sensor::Sensor *close_sensor) { this->close_sensor_ = close_sensor; } void set_close_moving_current_threshold(float close_moving_current_threshold) { this->close_moving_current_threshold_ = close_moving_current_threshold; } @@ -64,13 +65,11 @@ class CurrentBasedCover final : public cover::Cover, public Component { Trigger<> stop_trigger_; - sensor::Sensor *open_sensor_{nullptr}; Trigger<> open_trigger_; float open_moving_current_threshold_{0.0f}; float open_obstacle_current_threshold_{FLT_MAX}; uint32_t open_duration_{0}; - sensor::Sensor *close_sensor_{nullptr}; Trigger<> close_trigger_; float close_moving_current_threshold_{0.0f}; float close_obstacle_current_threshold_{FLT_MAX}; diff --git a/esphome/components/dew_point/dew_point.h b/esphome/components/dew_point/dew_point.h index 0e97b22a04e..6140473561f 100644 --- a/esphome/components/dew_point/dew_point.h +++ b/esphome/components/dew_point/dew_point.h @@ -6,10 +6,10 @@ namespace esphome::dew_point { class DewPointComponent final : public Component, public sensor::Sensor { - public: - void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } - void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: void setup() override; void dump_config() override; void loop() override; @@ -17,8 +17,6 @@ class DewPointComponent final : public Component, public sensor::Sensor { float get_setup_priority() const override; protected: - sensor::Sensor *temperature_sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; float temperature_value_{NAN}; float humidity_value_{NAN}; }; diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 4c6d91898d8..d7a0881ae6e 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -279,12 +279,12 @@ async def menu_item_to_code(menu, config, parent): await menu_item_to_code(menu, c, item) cg.add_optional(item.set_immediate_edit, config.get(CONF_IMMEDIATE_EDIT)) if config[CONF_TYPE] == CONF_SELECT: - await cg.add_variable(item.set_select_variable, config[CONF_SELECT]) + await cg.add_variable(item.set_variable_select, config[CONF_SELECT]) if config[CONF_TYPE] == CONF_NUMBER: - await cg.add_variable(item.set_number_variable, config[CONF_NUMBER]) + await cg.add_variable(item.set_variable_number, config[CONF_NUMBER]) cg.add(item.set_format(config[CONF_FORMAT])) if config[CONF_TYPE] == CONF_SWITCH: - await cg.add_variable(item.set_switch_variable, config[CONF_SWITCH]) + await cg.add_variable(item.set_variable_switch, config[CONF_SWITCH]) cg.add(item.set_on_text(config[CONF_ON_TEXT])) cg.add(item.set_off_text(config[CONF_OFF_TEXT])) await _build_item_automations(item, config) diff --git a/esphome/components/display_menu_base/menu_item.cpp b/esphome/components/display_menu_base/menu_item.cpp index 246f50f48e3..803ca23dcd7 100644 --- a/esphome/components/display_menu_base/menu_item.cpp +++ b/esphome/components/display_menu_base/menu_item.cpp @@ -40,8 +40,8 @@ std::string MenuItemSelect::get_value_text() const { if (this->value_getter_.has_value()) { result = this->value_getter_.value()(this); } else { - if (this->select_var_ != nullptr) { - auto option = this->select_var_->current_option(); + if (this->variable_select_ != nullptr) { + auto option = this->variable_select_->current_option(); result.assign(option.c_str(), option.size()); } } @@ -52,8 +52,8 @@ std::string MenuItemSelect::get_value_text() const { bool MenuItemSelect::select_next() { bool changed = false; - if (this->select_var_ != nullptr) { - this->select_var_->make_call().select_next(true).perform(); + if (this->variable_select_ != nullptr) { + this->variable_select_->make_call().select_next(true).perform(); this->on_value_(); changed = true; } @@ -64,8 +64,8 @@ bool MenuItemSelect::select_next() { bool MenuItemSelect::select_prev() { bool changed = false; - if (this->select_var_ != nullptr) { - this->select_var_->make_call().select_previous(true).perform(); + if (this->variable_select_ != nullptr) { + this->variable_select_->make_call().select_previous(true).perform(); this->on_value_(); changed = true; } @@ -92,11 +92,11 @@ std::string MenuItemNumber::get_value_text() const { bool MenuItemNumber::select_next() { bool changed = false; - if (this->number_var_ != nullptr) { - float last = this->number_var_->state; - this->number_var_->make_call().number_increment(false).perform(); + if (this->variable_number_ != nullptr) { + float last = this->variable_number_->state; + this->variable_number_->make_call().number_increment(false).perform(); - if (this->number_var_->state != last) { + if (this->variable_number_->state != last) { this->on_value_(); changed = true; } @@ -108,11 +108,11 @@ bool MenuItemNumber::select_next() { bool MenuItemNumber::select_prev() { bool changed = false; - if (this->number_var_ != nullptr) { - float last = this->number_var_->state; - this->number_var_->make_call().number_decrement(false).perform(); + if (this->variable_number_ != nullptr) { + float last = this->variable_number_->state; + this->variable_number_->make_call().number_decrement(false).perform(); - if (this->number_var_->state != last) { + if (this->variable_number_->state != last) { this->on_value_(); changed = true; } @@ -124,13 +124,14 @@ bool MenuItemNumber::select_prev() { float MenuItemNumber::get_number_value_() const { float result = 0.0; - if (this->number_var_ != nullptr) { - if (!this->number_var_->has_state() || this->number_var_->state < this->number_var_->traits.get_min_value()) { - result = this->number_var_->traits.get_min_value(); - } else if (this->number_var_->state > this->number_var_->traits.get_max_value()) { - result = this->number_var_->traits.get_max_value(); + if (this->variable_number_ != nullptr) { + if (!this->variable_number_->has_state() || + this->variable_number_->state < this->variable_number_->traits.get_min_value()) { + result = this->variable_number_->traits.get_min_value(); + } else if (this->variable_number_->state > this->variable_number_->traits.get_max_value()) { + result = this->variable_number_->traits.get_max_value(); } else { - result = this->number_var_->state; + result = this->variable_number_->state; } } @@ -155,13 +156,15 @@ bool MenuItemSwitch::select_next() { return this->toggle_switch_(); } bool MenuItemSwitch::select_prev() { return this->toggle_switch_(); } -bool MenuItemSwitch::get_switch_state_() const { return (this->switch_var_ != nullptr && this->switch_var_->state); } +bool MenuItemSwitch::get_switch_state_() const { + return (this->variable_switch_ != nullptr && this->variable_switch_->state); +} bool MenuItemSwitch::toggle_switch_() { bool changed = false; - if (this->switch_var_ != nullptr) { - this->switch_var_->toggle(); + if (this->variable_switch_ != nullptr) { + this->variable_switch_->toggle(); this->on_value_(); changed = true; } diff --git a/esphome/components/display_menu_base/menu_item.h b/esphome/components/display_menu_base/menu_item.h index 8eaea451a94..d35ef521634 100644 --- a/esphome/components/display_menu_base/menu_item.h +++ b/esphome/components/display_menu_base/menu_item.h @@ -98,26 +98,25 @@ class MenuItemEditable : public MenuItem { #ifdef USE_SELECT class MenuItemSelect final : public MenuItemEditable { + SUB_SELECT(variable) + public: explicit MenuItemSelect() : MenuItemEditable(MENU_ITEM_SELECT) {} - void set_select_variable(select::Select *var) { this->select_var_ = var; } bool has_value() const override { return true; } std::string get_value_text() const override; bool select_next() override; bool select_prev() override; - - protected: - select::Select *select_var_{nullptr}; }; #endif #ifdef USE_NUMBER class MenuItemNumber final : public MenuItemEditable { + SUB_NUMBER(variable) + public: explicit MenuItemNumber() : MenuItemEditable(MENU_ITEM_NUMBER) {} - void set_number_variable(number::Number *var) { this->number_var_ = var; } // Keeps the pointer; the string must outlive the item (codegen passes a literal) void set_format(const char *fmt) { this->format_ = fmt; } @@ -130,16 +129,16 @@ class MenuItemNumber final : public MenuItemEditable { protected: float get_number_value_() const; - number::Number *number_var_{nullptr}; const char *format_{""}; }; #endif #ifdef USE_SWITCH class MenuItemSwitch final : public MenuItemEditable { + SUB_SWITCH(variable) + public: explicit MenuItemSwitch() : MenuItemEditable(MENU_ITEM_SWITCH) {} - void set_switch_variable(switch_::Switch *var) { this->switch_var_ = var; } // Both keep the pointer; the strings must outlive the item (codegen passes literals) void set_on_text(const char *t) { this->switch_on_text_ = t; } void set_off_text(const char *t) { this->switch_off_text_ = t; } @@ -154,7 +153,6 @@ class MenuItemSwitch final : public MenuItemEditable { bool get_switch_state_() const; bool toggle_switch_(); - switch_::Switch *switch_var_{nullptr}; const char *switch_on_text_{""}; const char *switch_off_text_{""}; }; diff --git a/esphome/components/endstop/cover.py b/esphome/components/endstop/cover.py index 1e7f6e44216..20a7e0eaeaa 100644 --- a/esphome/components/endstop/cover.py +++ b/esphome/components/endstop/cover.py @@ -43,13 +43,15 @@ async def to_code(config: ConfigType) -> None: var.get_stop_trigger(), [], config[CONF_STOP_ACTION] ) - await cg.add_variable(var.set_open_endstop, config[CONF_OPEN_ENDSTOP]) + await cg.add_variable(var.set_open_endstop_binary_sensor, config[CONF_OPEN_ENDSTOP]) cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) await automation.build_automation( var.get_open_trigger(), [], config[CONF_OPEN_ACTION] ) - await cg.add_variable(var.set_close_endstop, config[CONF_CLOSE_ENDSTOP]) + await cg.add_variable( + var.set_close_endstop_binary_sensor, config[CONF_CLOSE_ENDSTOP] + ) cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) await automation.build_automation( var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] diff --git a/esphome/components/endstop/endstop_cover.cpp b/esphome/components/endstop/endstop_cover.cpp index 991f0d8f666..e2b06628c64 100644 --- a/esphome/components/endstop/endstop_cover.cpp +++ b/esphome/components/endstop/endstop_cover.cpp @@ -108,8 +108,8 @@ void EndstopCover::dump_config() { " Open Duration: %.1fs\n" " Close Duration: %.1fs", this->open_duration_ / 1e3f, this->close_duration_ / 1e3f); - LOG_BINARY_SENSOR(" ", "Open Endstop", this->open_endstop_); - LOG_BINARY_SENSOR(" ", "Close Endstop", this->close_endstop_); + LOG_BINARY_SENSOR(" ", "Open Endstop", this->open_endstop_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Close Endstop", this->close_endstop_binary_sensor_); } void EndstopCover::stop_prev_trigger_() { diff --git a/esphome/components/endstop/endstop_cover.h b/esphome/components/endstop/endstop_cover.h index 5319c74d7bc..d52f9faa769 100644 --- a/esphome/components/endstop/endstop_cover.h +++ b/esphome/components/endstop/endstop_cover.h @@ -8,6 +8,9 @@ namespace esphome::endstop { class EndstopCover final : public cover::Cover, public Component { + SUB_BINARY_SENSOR(open_endstop) + SUB_BINARY_SENSOR(close_endstop) + public: void setup() override; void loop() override; @@ -16,8 +19,6 @@ class EndstopCover final : public cover::Cover, public Component { Trigger<> *get_open_trigger() { return &this->open_trigger_; } Trigger<> *get_close_trigger() { return &this->close_trigger_; } Trigger<> *get_stop_trigger() { return &this->stop_trigger_; } - void set_open_endstop(binary_sensor::BinarySensor *open_endstop) { this->open_endstop_ = open_endstop; } - void set_close_endstop(binary_sensor::BinarySensor *close_endstop) { this->close_endstop_ = close_endstop; } void set_open_duration(uint32_t open_duration) { this->open_duration_ = open_duration; } void set_close_duration(uint32_t close_duration) { this->close_duration_ = close_duration; } void set_max_duration(uint32_t max_duration) { this->max_duration_ = max_duration; } @@ -27,16 +28,14 @@ class EndstopCover final : public cover::Cover, public Component { protected: void control(const cover::CoverCall &call) override; void stop_prev_trigger_(); - bool is_open_() const { return this->open_endstop_->state; } - bool is_closed_() const { return this->close_endstop_->state; } + bool is_open_() const { return this->open_endstop_binary_sensor_->state; } + bool is_closed_() const { return this->close_endstop_binary_sensor_->state; } bool is_at_target_() const; void start_direction_(cover::CoverOperation dir); void recompute_position_(); - binary_sensor::BinarySensor *open_endstop_; - binary_sensor::BinarySensor *close_endstop_; Trigger<> open_trigger_; uint32_t open_duration_; Trigger<> close_trigger_; diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 6ebb8113404..f9811659a70 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -162,7 +162,7 @@ async def to_code(config: ConfigType) -> None: # Trace options for trace in config[CONF_TRACES]: tr = cg.new_Pvariable(trace[CONF_ID], GraphTrace()) - await cg.add_variable(tr.set_sensor, trace[CONF_SENSOR]) + await cg.add_variable(tr.set_source_sensor, trace[CONF_SENSOR]) if CONF_NAME in trace: cg.add(tr.set_name(trace[CONF_NAME])) else: diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 147890fa29a..890eda1c781 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -49,7 +49,7 @@ void HistoryData::take_sample(float data) { void GraphTrace::init(Graph *g) { ESP_LOGI(TAG, "Init trace for sensor %s", this->name_); this->data_.init(g->get_width()); - sensor_->add_on_state_callback([this](float state) { this->data_.take_sample(state); }); + this->source_sensor_->add_on_state_callback([this](float state) { this->data_.take_sample(state); }); this->data_.set_update_time_ms(g->get_duration() * 1000 / g->get_width()); } @@ -234,10 +234,12 @@ void GraphLegend::init(Graph *g) { if (this->values_ != VALUE_POSITION_TYPE_NONE) { char valstr[VALUE_ACCURACY_MAX_LEN]; if (this->units_) { - value_accuracy_with_uom_to_buf(valstr, trace->sensor_->get_state(), trace->sensor_->get_accuracy_decimals(), - trace->sensor_->get_unit_of_measurement_ref()); + value_accuracy_with_uom_to_buf(valstr, trace->source_sensor_->get_state(), + trace->source_sensor_->get_accuracy_decimals(), + trace->source_sensor_->get_unit_of_measurement_ref()); } else { - value_accuracy_to_buf(valstr, trace->sensor_->get_state(), trace->sensor_->get_accuracy_decimals()); + value_accuracy_to_buf(valstr, trace->source_sensor_->get_state(), + trace->source_sensor_->get_accuracy_decimals()); } this->font_value_->measure(valstr, &fw, &fos, &fbl, &fh); if (fw > valw) @@ -372,10 +374,12 @@ void Graph::draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_of int yv = y + legend_->yv_; char valstr[VALUE_ACCURACY_MAX_LEN]; if (legend_->units_) { - value_accuracy_with_uom_to_buf(valstr, trace->sensor_->get_state(), trace->sensor_->get_accuracy_decimals(), - trace->sensor_->get_unit_of_measurement_ref()); + value_accuracy_with_uom_to_buf(valstr, trace->source_sensor_->get_state(), + trace->source_sensor_->get_accuracy_decimals(), + trace->source_sensor_->get_unit_of_measurement_ref()); } else { - value_accuracy_to_buf(valstr, trace->sensor_->get_state(), trace->sensor_->get_accuracy_decimals()); + value_accuracy_to_buf(valstr, trace->source_sensor_->get_state(), + trace->source_sensor_->get_accuracy_decimals()); } buff->printf(xv, yv, legend_->font_value_, trace->get_line_color(), TextAlign::TOP_CENTER, "%s", valstr); ESP_LOGV(TAG, " value: %s", valstr); diff --git a/esphome/components/graph/graph.h b/esphome/components/graph/graph.h index 0196c44e3bf..a7c3415ff28 100644 --- a/esphome/components/graph/graph.h +++ b/esphome/components/graph/graph.h @@ -106,10 +106,11 @@ class HistoryData { }; class GraphTrace final { + SUB_SENSOR(source) + public: void init(Graph *g); void set_name(const char *name) { this->name_ = name; } - void set_sensor(sensor::Sensor *sensor) { sensor_ = sensor; } uint8_t get_line_thickness() { return this->line_thickness_; } void set_line_thickness(uint8_t val) { this->line_thickness_ = val; } enum LineType get_line_type() { return this->line_type_; } @@ -122,7 +123,6 @@ class GraphTrace final { const HistoryData *get_tracedata() { return &data_; } protected: - sensor::Sensor *sensor_{nullptr}; const char *name_{""}; uint8_t line_thickness_{3}; enum LineType line_type_ { LINE_TYPE_SOLID }; diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index dbc6542ec81..b60211e38f7 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -135,7 +135,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_wifi_timeout(config[CONF_WIFI_TIMEOUT])) - await cg.add_variable(var.set_authorizer, config.get(CONF_AUTHORIZER)) + await cg.add_variable(var.set_authorizer_binary_sensor, config.get(CONF_AUTHORIZER)) await cg.add_variable(var.set_status_indicator, config.get(CONF_STATUS_INDICATOR)) if any(config.get(entry.conf_key) for entry in _CALLBACK_AUTOMATIONS): diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 0d441ee62dc..a4e59c033b7 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -47,8 +47,8 @@ ImprovBLEComponent::ImprovBLEComponent() { global_improv_component = this; } void ImprovBLEComponent::setup() { #ifdef USE_BINARY_SENSOR - if (this->authorizer_ != nullptr) { - this->authorizer_->add_on_state_callback([this](bool state) { + if (this->authorizer_binary_sensor_ != nullptr) { + this->authorizer_binary_sensor_->add_on_state_callback([this](bool state) { if (state) { this->authorized_start_ = millis(); this->identify_start_ = 0; @@ -164,7 +164,7 @@ void ImprovBLEComponent::loop() { break; case improv::STATE_AWAITING_AUTHORIZATION: { #ifdef USE_BINARY_SENSOR - if (this->authorizer_ == nullptr || + if (this->authorizer_binary_sensor_ == nullptr || (this->authorized_start_ != 0 && ((now - this->authorized_start_) < this->authorized_duration_))) { this->set_state_(improv::STATE_AUTHORIZED); } else { @@ -179,7 +179,7 @@ void ImprovBLEComponent::loop() { } case improv::STATE_AUTHORIZED: { #ifdef USE_BINARY_SENSOR - if (this->authorizer_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) { + if (this->authorizer_binary_sensor_ != nullptr && now - this->authorized_start_ > this->authorized_duration_) { ESP_LOGD(TAG, "Authorization timeout"); this->set_state_(improv::STATE_AWAITING_AUTHORIZATION); return; @@ -342,7 +342,7 @@ float ImprovBLEComponent::get_setup_priority() const { return setup_priority::AF void ImprovBLEComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv BLE:"); #ifdef USE_BINARY_SENSOR - LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_); + LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_binary_sensor_); #endif #ifdef USE_OUTPUT ESP_LOGCONFIG(TAG, " Status Indicator: '%s'", YESNO(this->status_indicator_ != nullptr)); @@ -425,7 +425,7 @@ void ImprovBLEComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); #ifdef USE_BINARY_SENSOR - if (this->authorizer_ != nullptr) + if (this->authorizer_binary_sensor_ != nullptr) this->authorized_start_ = millis(); #endif ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network"); @@ -540,7 +540,7 @@ void ImprovBLEComponent::release_advertising_() { improv::State ImprovBLEComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state - return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; + return this->authorizer_binary_sensor_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; #else // No binary_sensor support = no authorizer possible, start as authorized return improv::STATE_AUTHORIZED; diff --git a/esphome/components/improv_ble/improv_ble_component.h b/esphome/components/improv_ble/improv_ble_component.h index 126663ed000..15399740855 100644 --- a/esphome/components/improv_ble/improv_ble_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -38,6 +38,10 @@ namespace esphome::improv_ble { using namespace esp32_ble_server; class ImprovBLEComponent final : public Component, public improv_base::ImprovBase { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(authorizer) +#endif + public: ImprovBLEComponent(); void dump_config() override; @@ -56,9 +60,6 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas this->state_callback_.add(std::forward(callback)); } #endif -#ifdef USE_BINARY_SENSOR - void set_authorizer(binary_sensor::BinarySensor *authorizer) { this->authorizer_ = authorizer; } -#endif #ifdef USE_OUTPUT void set_status_indicator(output::BinaryOutput *status_indicator) { this->status_indicator_ = status_indicator; } #endif @@ -97,9 +98,6 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas BLECharacteristic *rpc_response_{nullptr}; BLECharacteristic *capabilities_{nullptr}; -#ifdef USE_BINARY_SENSOR - binary_sensor::BinarySensor *authorizer_{nullptr}; -#endif #ifdef USE_OUTPUT output::BinaryOutput *status_indicator_{nullptr}; #endif diff --git a/esphome/components/integration/integration_sensor.cpp b/esphome/components/integration/integration_sensor.cpp index c7b6173e39b..17507df0cd5 100644 --- a/esphome/components/integration/integration_sensor.cpp +++ b/esphome/components/integration/integration_sensor.cpp @@ -18,7 +18,7 @@ void IntegrationSensor::setup() { this->last_update_ = millis(); this->publish_and_save_(this->result_); - this->sensor_->add_on_state_callback([this](float state) { this->process_sensor_value_(state); }); + this->source_sensor_->add_on_state_callback([this](float state) { this->process_sensor_value_(state); }); } void IntegrationSensor::dump_config() { LOG_SENSOR("", "Integration Sensor", this); } void IntegrationSensor::process_sensor_value_(float value) { diff --git a/esphome/components/integration/integration_sensor.h b/esphome/components/integration/integration_sensor.h index 22d664cd555..a1ec6e94c0b 100644 --- a/esphome/components/integration/integration_sensor.h +++ b/esphome/components/integration/integration_sensor.h @@ -23,10 +23,11 @@ enum IntegrationMethod { }; class IntegrationSensor final : public sensor::Sensor, public Component { + SUB_SENSOR(source) + public: void setup() override; void dump_config() override; - void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_time(IntegrationSensorTime time) { time_ = time; } void set_method(IntegrationMethod method) { method_ = method; } void set_restore(bool restore) { restore_ = restore; } @@ -60,7 +61,6 @@ class IntegrationSensor final : public sensor::Sensor, public Component { } } - sensor::Sensor *sensor_; IntegrationSensorTime time_; IntegrationMethod method_; bool restore_; diff --git a/esphome/components/integration/sensor.py b/esphome/components/integration/sensor.py index 6a7dc63dbd5..f2979803d93 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -93,7 +93,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await sensor.register_sensor(var, config) - await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_source_sensor, config[CONF_SENSOR]) cg.add(var.set_time(config[CONF_TIME_UNIT])) cg.add(var.set_method(config[CONF_INTEGRATION_METHOD])) cg.add(var.set_restore(config[CONF_RESTORE])) diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 1ea86a2e595..66d188b806d 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -67,9 +67,9 @@ void AirConditioner::on_status_change() { } if (need_publish) this->publish_state(); - set_sensor(this->outdoor_sensor_, this->base_.getOutdoorTemp()); + set_sensor(this->outdoor_temperature_sensor_, this->base_.getOutdoorTemp()); set_sensor(this->power_sensor_, this->base_.getPowerUsage()); - set_sensor(this->humidity_sensor_, this->base_.getIndoorHum()); + set_sensor(this->humidity_setpoint_sensor_, this->base_.getIndoorHum()); } void AirConditioner::control(const ClimateCall &call) { diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 4e9a740e09b..868a8ab217f 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -29,11 +29,12 @@ enum class DisplayControl : uint8_t { }; class AirConditioner final : public ApplianceBase, public climate::Climate { + SUB_SENSOR(outdoor_temperature) + SUB_SENSOR(humidity_setpoint) + SUB_SENSOR(power) + public: void dump_config() override; - void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } - void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; } - void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; } void set_display_control(DisplayControl control) { this->display_control_ = control; } void on_status_change() override; @@ -61,9 +62,6 @@ class AirConditioner final : public ApplianceBasesensor_->add_on_state_callback([this](float value) { this->process_(value); }); - if (this->sensor_->has_state()) - this->process_(this->sensor_->state); + this->source_sensor_->add_on_state_callback([this](float value) { this->process_(value); }); + if (this->source_sensor_->has_state()) + this->process_(this->source_sensor_->state); } void NTC::dump_config() { LOG_SENSOR("", "NTC Sensor", this); } void NTC::process_(float value) { diff --git a/esphome/components/ntc/ntc.h b/esphome/components/ntc/ntc.h index 25fbf3c85d5..f44ec9ed258 100644 --- a/esphome/components/ntc/ntc.h +++ b/esphome/components/ntc/ntc.h @@ -6,8 +6,9 @@ namespace esphome::ntc { class NTC final : public Component, public sensor::Sensor { + SUB_SENSOR(source) + public: - void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_a(double a) { a_ = a; } void set_b(double b) { b_ = b; } void set_c(double c) { c_ = c; } @@ -17,7 +18,6 @@ class NTC final : public Component, public sensor::Sensor { protected: void process_(float value); - sensor::Sensor *sensor_; double a_; double b_; double c_; diff --git a/esphome/components/ntc/sensor.py b/esphome/components/ntc/sensor.py index 9ac69b595f6..7faf15260ae 100644 --- a/esphome/components/ntc/sensor.py +++ b/esphome/components/ntc/sensor.py @@ -138,7 +138,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_source_sensor, config[CONF_SENSOR]) calib = config[CONF_CALIBRATION] cg.add(var.set_a(calib[CONF_A])) cg.add(var.set_b(calib[CONF_B])) diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index a6b5641eb70..c33f80f8587 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -100,7 +100,7 @@ async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) - await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_temperature_sensor, config[CONF_SENSOR]) await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) await cg.add_variable(var.set_cool_output, config.get(CONF_COOL_OUTPUT)) await cg.add_variable(var.set_heat_output, config.get(CONF_HEAT_OUTPUT)) diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index 059f130130e..dfcbff50ddf 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -19,13 +19,13 @@ bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_hi } void PIDClimate::setup() { - this->sensor_->add_on_state_callback([this](float state) { + this->temperature_sensor_->add_on_state_callback([this](float state) { // only publish if state/current temperature has changed in two digits of precision this->do_publish_ = roundf(state * 100) != roundf(this->current_temperature * 100); this->current_temperature = state; this->update_pid_(); }); - this->current_temperature = this->sensor_->state; + this->current_temperature = this->temperature_sensor_->state; // register for humidity values and get initial state if (this->humidity_sensor_ != nullptr) { diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index 6fea2bedf30..4a4ccbf93fa 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -12,13 +12,14 @@ namespace esphome::pid { class PIDClimate final : public climate::Climate, public Component { + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + public: PIDClimate() = default; void setup() override; void dump_config() override; - void set_sensor(sensor::Sensor *sensor) { sensor_ = sensor; } - void set_humidity_sensor(sensor::Sensor *sensor) { humidity_sensor_ = sensor; } void set_cool_output(output::FloatOutput *cool_output) { cool_output_ = cool_output; } void set_heat_output(output::FloatOutput *heat_output) { heat_output_ = heat_output; } void set_kp(float kp) { controller_.kp_ = kp; } @@ -96,10 +97,6 @@ class PIDClimate final : public climate::Climate, public Component { void write_output_(float value); - /// The sensor used for getting the current temperature - sensor::Sensor *sensor_; - /// The sensor used for getting the current humidity - sensor::Sensor *humidity_sensor_{nullptr}; output::FloatOutput *cool_output_{nullptr}; output::FloatOutput *heat_output_{nullptr}; PIDController controller_; diff --git a/esphome/components/resistance/resistance_sensor.h b/esphome/components/resistance/resistance_sensor.h index ecb77795ffe..8bc1030831a 100644 --- a/esphome/components/resistance/resistance_sensor.h +++ b/esphome/components/resistance/resistance_sensor.h @@ -11,22 +11,22 @@ enum ResistanceConfiguration { }; class ResistanceSensor final : public Component, public sensor::Sensor { + SUB_SENSOR(source) + public: - void set_sensor(Sensor *sensor) { sensor_ = sensor; } void set_configuration(ResistanceConfiguration configuration) { configuration_ = configuration; } void set_resistor(float resistor) { resistor_ = resistor; } void set_reference_voltage(float reference_voltage) { reference_voltage_ = reference_voltage; } void setup() override { - this->sensor_->add_on_state_callback([this](float value) { this->process_(value); }); - if (this->sensor_->has_state()) - this->process_(this->sensor_->state); + this->source_sensor_->add_on_state_callback([this](float value) { this->process_(value); }); + if (this->source_sensor_->has_state()) + this->process_(this->source_sensor_->state); } void dump_config() override; protected: void process_(float value); - sensor::Sensor *sensor_; ResistanceConfiguration configuration_; float resistor_; float reference_voltage_; diff --git a/esphome/components/resistance/sensor.py b/esphome/components/resistance/sensor.py index c76d910df53..506748570b0 100644 --- a/esphome/components/resistance/sensor.py +++ b/esphome/components/resistance/sensor.py @@ -43,7 +43,7 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) - await cg.add_variable(var.set_sensor, config[CONF_SENSOR]) + await cg.add_variable(var.set_source_sensor, config[CONF_SENSOR]) cg.add(var.set_configuration(config[CONF_CONFIGURATION])) cg.add(var.set_resistor(config[CONF_RESISTOR])) cg.add(var.set_reference_voltage(config[CONF_REFERENCE_VOLTAGE])) diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index 54eaf1fee8d..0e83c0408b0 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -104,9 +104,9 @@ void SCD4XComponent::dump_config() { " Measurement mode: %s\n" " Temperature offset: %.2f °C", ONOFF(this->enable_asc_), measurement_mode_str, this->temperature_offset_); - if (this->ambient_pressure_source_ != nullptr) { + if (this->ambient_pressure_source_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " Dynamic ambient pressure compensation using '%s'", - LOG_STR_ARG(this->ambient_pressure_source_->get_log_name())); + LOG_STR_ARG(this->ambient_pressure_source_sensor_->get_log_name())); } else { if (this->ambient_pressure_) { ESP_LOGCONFIG(TAG, @@ -131,8 +131,8 @@ void SCD4XComponent::update() { return; } - if (this->ambient_pressure_source_ != nullptr) { - float pressure = this->ambient_pressure_source_->state; + if (this->ambient_pressure_source_sensor_ != nullptr) { + float pressure = this->ambient_pressure_source_sensor_->state; if (!std::isnan(pressure)) { this->set_ambient_pressure_compensation(pressure); } diff --git a/esphome/components/scd4x/scd4x.h b/esphome/components/scd4x/scd4x.h index 130f0e1a5ce..170194af7b0 100644 --- a/esphome/components/scd4x/scd4x.h +++ b/esphome/components/scd4x/scd4x.h @@ -18,6 +18,8 @@ class SCD4XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(co2) SUB_SENSOR(temperature) SUB_SENSOR(humidity) + // Input sensors for compensation + SUB_SENSOR(ambient_pressure_source) public: void setup() override; @@ -27,7 +29,6 @@ class SCD4XComponent final : public PollingComponent, public sensirion_common::S void set_automatic_self_calibration(bool asc) { enable_asc_ = asc; } void set_altitude_compensation(uint16_t altitude) { altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(float pressure_in_hpa); - void set_ambient_pressure_source(sensor::Sensor *pressure) { ambient_pressure_source_ = pressure; } void set_temperature_offset(float offset) { temperature_offset_ = offset; }; void set_measurement_mode(MeasurementMode mode) { measurement_mode_ = mode; } @@ -38,7 +39,6 @@ class SCD4XComponent final : public PollingComponent, public sensirion_common::S bool update_ambient_pressure_compensation_(uint16_t pressure_in_hpa); bool start_measurement_(); - sensor::Sensor *ambient_pressure_source_{nullptr}; // used for compensation float temperature_offset_; uint16_t altitude_compensation_{0}; uint16_t ambient_pressure_{0}; // Per datasheet, valid values are 700 to 1200 hPa; 0 is a valid sentinel value diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index f1c7beaea8b..53b304165b5 100644 --- a/esphome/components/scd4x/sensor.py +++ b/esphome/components/scd4x/sensor.py @@ -124,7 +124,7 @@ async def to_code(config: ConfigType) -> None: cg.add(getattr(var, funcName)(sens)) await cg.add_variable( - var.set_ambient_pressure_source, + var.set_ambient_pressure_source_sensor, config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), ) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index b1e15601c2d..abadbba6338 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -370,9 +370,9 @@ void SEN6XComponent::dump_config() { if (this->ambient_pressure_ != PRESSURE_UNSET) { ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %u hPa", this->ambient_pressure_); } - if (this->ambient_pressure_source_ != nullptr) { + if (this->ambient_pressure_source_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " Ambient pressure compensation source: %s", - LOG_STR_ARG(this->ambient_pressure_source_->get_log_name())); + LOG_STR_ARG(this->ambient_pressure_source_sensor_->get_log_name())); } } LOG_SENSOR(" ", "PM 1.0", this->pm_1_0_sensor_); @@ -411,8 +411,8 @@ void SEN6XComponent::update() { return; } - if (this->ambient_pressure_source_ != nullptr && this->co2_sensor_ != nullptr) { - this->update_ambient_pressure_compensation_(this->ambient_pressure_source_->state); + if (this->ambient_pressure_source_sensor_ != nullptr && this->co2_sensor_ != nullptr) { + this->update_ambient_pressure_compensation_(this->ambient_pressure_source_sensor_->state); } set_read_command_and_words(this->sen6x_type_, this->read_cmd_, this->read_words_); diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index bfc64697f8e..003a73eb58f 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -80,6 +80,8 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S SUB_SENSOR(nox) SUB_SENSOR(co2) SUB_SENSOR(hcho) + // Input sensors for compensation + SUB_SENSOR(ambient_pressure_source) public: void setup() override; @@ -121,7 +123,6 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void set_automatic_self_calibration(bool enabled) { this->co2_asc_ = enabled; } void set_altitude_compensation(uint16_t altitude) { this->altitude_compensation_ = altitude; } void set_ambient_pressure_compensation(uint16_t pressure_hpa) { this->ambient_pressure_ = pressure_hpa; } - void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } void start_measurement(); void stop_measurement(); void start_fan_cleaning(); @@ -175,7 +176,6 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S optional temperature_acceleration_; optional temperature_compensation_; uint16_t read_cmd_{0}; - sensor::Sensor *ambient_pressure_source_{nullptr}; uint32_t startup_delay_ms_{60000}; Sen6xType sen6x_type_{UNKNOWN}; // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index cf466569214..7bce5b07796 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -333,7 +333,7 @@ async def to_code(config: ConfigType) -> None: if (pressure := co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION)) is not None: cg.add(var.set_ambient_pressure_compensation(pressure)) await cg.add_variable( - var.set_ambient_pressure_source, + var.set_ambient_pressure_source_sensor, co2_cfg.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), ) diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py index 4ea1bc6ed81..4d1ada4c9ff 100644 --- a/esphome/components/stcc4/sensor.py +++ b/esphome/components/stcc4/sensor.py @@ -108,10 +108,12 @@ async def to_code(config: ConfigType) -> None: await sensors(CONF_HUMIDITY, var.set_humidity_sensor) await cg.add_variable( - var.set_temperature_source, config.get(CONF_TEMPERATURE_SOURCE) + var.set_temperature_source_sensor, config.get(CONF_TEMPERATURE_SOURCE) ) - await cg.add_variable(var.set_humidity_source, config.get(CONF_HUMIDITY_SOURCE)) + await cg.add_variable( + var.set_humidity_source_sensor, config.get(CONF_HUMIDITY_SOURCE) + ) if ( ambient_pressure_compensation := config.get(CONF_AMBIENT_PRESSURE_COMPENSATION) @@ -123,7 +125,7 @@ async def to_code(config: ConfigType) -> None: ) await cg.add_variable( - var.set_ambient_pressure_source, + var.set_ambient_pressure_source_sensor, config.get(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE), ) diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp index 7412ecd37ea..88747d92369 100644 --- a/esphome/components/stcc4/stcc4.cpp +++ b/esphome/components/stcc4/stcc4.cpp @@ -90,10 +90,10 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) } // Apply the dynamic compensation sources' current values, if configured - if (this->temperature_source_ != nullptr && this->humidity_source_ != nullptr) { + if (this->temperature_source_sensor_ != nullptr && this->humidity_source_sensor_ != nullptr) { this->update_rht_compensation_from_source_(); } - if (this->ambient_pressure_source_ != nullptr) { + if (this->ambient_pressure_source_sensor_ != nullptr) { this->update_ambient_pressure_compensation_from_source_(); } @@ -127,12 +127,14 @@ void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) void STCC4Component::finish_setup_() { this->ready_ = true; // Follow the sources only once measuring started, so a failed setup stops writing to the device - if (this->temperature_source_ != nullptr && this->humidity_source_ != nullptr) { - this->temperature_source_->add_on_state_callback([this](float) { this->update_rht_compensation_from_source_(); }); - this->humidity_source_->add_on_state_callback([this](float) { this->update_rht_compensation_from_source_(); }); + if (this->temperature_source_sensor_ != nullptr && this->humidity_source_sensor_ != nullptr) { + this->temperature_source_sensor_->add_on_state_callback( + [this](float) { this->update_rht_compensation_from_source_(); }); + this->humidity_source_sensor_->add_on_state_callback( + [this](float) { this->update_rht_compensation_from_source_(); }); } - if (this->ambient_pressure_source_ != nullptr) { - this->ambient_pressure_source_->add_on_state_callback( + if (this->ambient_pressure_source_sensor_ != nullptr) { + this->ambient_pressure_source_sensor_->add_on_state_callback( [this](float) { this->update_ambient_pressure_compensation_from_source_(); }); } } @@ -143,18 +145,20 @@ void STCC4Component::dump_config() { ESP_LOGCONFIG(TAG, " Measurement mode: %s", this->measurement_mode_ == MeasurementMode::CONTINUOUS ? LOG_STR_LITERAL("Continuous (1s)") : LOG_STR_LITERAL("Single shot")); - if (this->ambient_pressure_source_ != nullptr) { + if (this->ambient_pressure_source_sensor_ != nullptr) { ESP_LOGCONFIG(TAG, " Dynamic ambient pressure compensation using '%s'", - LOG_STR_ARG(this->ambient_pressure_source_->get_log_name())); + LOG_STR_ARG(this->ambient_pressure_source_sensor_->get_log_name())); } else if (this->ambient_pressure_in_pa_2_ != 0) { ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %f hPa", pressure_in_pa_2_to_hpa(this->ambient_pressure_in_pa_2_)); } - if (this->temperature_source_ != nullptr) { - ESP_LOGCONFIG(TAG, " Temperature compensation using '%s'", LOG_STR_ARG(this->temperature_source_->get_log_name())); + if (this->temperature_source_sensor_ != nullptr) { + ESP_LOGCONFIG(TAG, " Temperature compensation using '%s'", + LOG_STR_ARG(this->temperature_source_sensor_->get_log_name())); } - if (this->humidity_source_ != nullptr) { - ESP_LOGCONFIG(TAG, " Humidity compensation using '%s'", LOG_STR_ARG(this->humidity_source_->get_log_name())); + if (this->humidity_source_sensor_ != nullptr) { + ESP_LOGCONFIG(TAG, " Humidity compensation using '%s'", + LOG_STR_ARG(this->humidity_source_sensor_->get_log_name())); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "CO2", this->co2_sensor_); @@ -219,8 +223,8 @@ bool STCC4Component::read_measurement_(uint8_t sensirion_options) { } void STCC4Component::update_rht_compensation_from_source_() { - const float temperature_in_c = this->temperature_source_->state; - const float humidity_in_percent = this->humidity_source_->state; + const float temperature_in_c = this->temperature_source_sensor_->state; + const float humidity_in_percent = this->humidity_source_sensor_->state; if (std::isnan(temperature_in_c) || std::isnan(humidity_in_percent)) return; @@ -238,7 +242,7 @@ void STCC4Component::set_ambient_pressure_compensation(float pressure_in_hpa) { } void STCC4Component::update_ambient_pressure_compensation_from_source_() { - const float pressure_in_hpa = this->ambient_pressure_source_->state; + const float pressure_in_hpa = this->ambient_pressure_source_sensor_->state; if (std::isnan(pressure_in_hpa)) return; diff --git a/esphome/components/stcc4/stcc4.h b/esphome/components/stcc4/stcc4.h index 1b8e02acccc..482ee0ee56f 100644 --- a/esphome/components/stcc4/stcc4.h +++ b/esphome/components/stcc4/stcc4.h @@ -15,16 +15,17 @@ class STCC4Component final : public PollingComponent, public sensirion_common::S SUB_SENSOR(co2) SUB_SENSOR(temperature) SUB_SENSOR(humidity) + // Input sensors for compensation + SUB_SENSOR(temperature_source) + SUB_SENSOR(humidity_source) + SUB_SENSOR(ambient_pressure_source) public: void setup() override; void dump_config() override; void update() override; - void set_temperature_source(sensor::Sensor *temperature) { this->temperature_source_ = temperature; } - void set_humidity_source(sensor::Sensor *humidity) { this->humidity_source_ = humidity; } void set_ambient_pressure_compensation(float pressure_in_hpa); - void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } void set_measurement_mode(MeasurementMode mode) { this->measurement_mode_ = mode; } protected: @@ -37,10 +38,6 @@ class STCC4Component final : public PollingComponent, public sensirion_common::S bool write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks); bool write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2); - sensor::Sensor *temperature_source_{nullptr}; - sensor::Sensor *humidity_source_{nullptr}; - sensor::Sensor *ambient_pressure_source_{nullptr}; - MeasurementMode measurement_mode_{MeasurementMode::CONTINUOUS}; uint16_t temperature_in_ticks_{0}; diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index c40f1c2b076..551cba9e47f 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -335,7 +335,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await climate.register_climate(var, config) - await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_temperature_sensor, config.get(CONF_SENSOR)) await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) for key, flag in ( diff --git a/esphome/components/template/climate/template_climate.cpp b/esphome/components/template/climate/template_climate.cpp index 9345cf2ac20..a69efb6e930 100644 --- a/esphome/components/template/climate/template_climate.cpp +++ b/esphome/components/template/climate/template_climate.cpp @@ -17,9 +17,9 @@ void TemplateClimate::setup() { // NAN means the sensor went unavailable and is passed through rather than dropped; the second // check stops an unavailable sensor re-publishing forever, since NAN never equals NAN. #ifdef USE_SENSOR - if (this->sensor_ != nullptr) { - this->current_temperature = this->sensor_->state; - this->sensor_->add_on_state_callback([this](float state) { + if (this->temperature_sensor_ != nullptr) { + this->current_temperature = this->temperature_sensor_->state; + this->temperature_sensor_->add_on_state_callback([this](float state) { if (state != this->current_temperature && !(std::isnan(state) && std::isnan(this->current_temperature))) { this->current_temperature = state; this->publish_state(); diff --git a/esphome/components/template/climate/template_climate.h b/esphome/components/template/climate/template_climate.h index bfee3e1f7d5..c4459903ca7 100644 --- a/esphome/components/template/climate/template_climate.h +++ b/esphome/components/template/climate/template_climate.h @@ -16,6 +16,12 @@ enum class TemplateClimateRestoreMode { }; class TemplateClimate final : public climate::Climate, public Component { +#ifdef USE_SENSOR + // Codegen adds the matching feature flags, so the configuration alone decides them. + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) +#endif + public: void setup() override; void dump_config() override; @@ -30,12 +36,6 @@ class TemplateClimate final : public climate::Climate, public Component { this->traits_.set_visual_max_temperature(celsius_to(climate::ClimateTraits::DEFAULT_VISUAL_MAX_TEMPERATURE, unit)); } -#ifdef USE_SENSOR - // The matching feature flag is added from codegen, so the configuration alone decides it. - void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } - void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } -#endif - void add_supported_mode(climate::ClimateMode mode) { this->traits_.add_supported_mode(mode); } void add_supported_fan_mode(climate::ClimateFanMode mode) { this->traits_.add_supported_fan_mode(mode); } void add_supported_swing_mode(climate::ClimateSwingMode mode) { this->traits_.add_supported_swing_mode(mode); } @@ -78,11 +78,6 @@ class TemplateClimate final : public climate::Climate, public Component { bool optimistic_{false}; TemplateClimateRestoreMode restore_mode_{TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_NO_RESTORE}; -#ifdef USE_SENSOR - sensor::Sensor *sensor_{nullptr}; - sensor::Sensor *humidity_sensor_{nullptr}; -#endif - Trigger set_mode_trigger_; Trigger set_target_temperature_trigger_; Trigger set_target_temperature_low_trigger_; diff --git a/esphome/components/yashima/climate.py b/esphome/components/yashima/climate.py index 25e06b37586..27415710843 100644 --- a/esphome/components/yashima/climate.py +++ b/esphome/components/yashima/climate.py @@ -32,5 +32,5 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) - await cg.add_variable(var.set_sensor, config.get(CONF_SENSOR)) + await cg.add_variable(var.set_temperature_sensor, config.get(CONF_SENSOR)) await cg.add_variable(var.set_transmitter, config[CONF_TRANSMITTER_ID]) diff --git a/esphome/components/yashima/yashima.cpp b/esphome/components/yashima/yashima.cpp index 6383a3c4a6f..4fa337da608 100644 --- a/esphome/components/yashima/yashima.cpp +++ b/esphome/components/yashima/yashima.cpp @@ -84,7 +84,7 @@ const uint32_t YASHIMA_CARRIER_FREQUENCY = 38000; climate::ClimateTraits YashimaClimate::traits() { auto traits = climate::ClimateTraits(); - if (this->sensor_ != nullptr) { + if (this->temperature_sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); } @@ -101,13 +101,13 @@ climate::ClimateTraits YashimaClimate::traits() { } void YashimaClimate::setup() { - if (this->sensor_) { - this->sensor_->add_on_state_callback([this](float state) { + if (this->temperature_sensor_) { + this->temperature_sensor_->add_on_state_callback([this](float state) { this->current_temperature = state; // current temperature changed, publish state this->publish_state(); }); - this->current_temperature = this->sensor_->state; + this->current_temperature = this->temperature_sensor_->state; } else { this->current_temperature = NAN; } diff --git a/esphome/components/yashima/yashima.h b/esphome/components/yashima/yashima.h index 864b3fce664..ae915c4f7ee 100644 --- a/esphome/components/yashima/yashima.h +++ b/esphome/components/yashima/yashima.h @@ -10,6 +10,8 @@ namespace esphome::yashima { class YashimaClimate final : public climate::Climate, public Component { + SUB_SENSOR(temperature) + public: void setup() override; void set_transmitter(remote_transmitter::RemoteTransmitterComponent *transmitter) { @@ -17,7 +19,6 @@ class YashimaClimate final : public climate::Climate, public Component { } void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } protected: /// Override control to change settings of the climate device. @@ -32,7 +33,6 @@ class YashimaClimate final : public climate::Climate, public Component { bool supports_heat_{true}; remote_transmitter::RemoteTransmitterComponent *transmitter_; - sensor::Sensor *sensor_{nullptr}; }; } // namespace esphome::yashima diff --git a/tests/component_tests/improv_ble/test_improv_ble.py b/tests/component_tests/improv_ble/test_improv_ble.py index d7df3869dc3..59cd41a1a71 100644 --- a/tests/component_tests/improv_ble/test_improv_ble.py +++ b/tests/component_tests/improv_ble/test_improv_ble.py @@ -57,5 +57,5 @@ def test_automations_register_state_callbacks( assert f"state == {state}" in main_cpp assert main_cpp.count("add_on_state_callback(") == 5 assert "->is_failed()" in main_cpp - assert "set_authorizer" in main_cpp + assert "set_authorizer_binary_sensor" in main_cpp assert "set_status_indicator" in main_cpp From 7ed387b66095a5907e722c0aa80b79d495fd3b55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:48:07 -1000 Subject: [PATCH 072/170] [thermostat] Build triggers, settings and mode checks from tables (#22050) --- esphome/components/thermostat/climate.py | 808 +++++++---------------- 1 file changed, 253 insertions(+), 555 deletions(-) diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 1cc538f4b6c..897c777fc40 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -1,4 +1,5 @@ -from typing import Any +from collections.abc import Callable +from typing import Any, NamedTuple from esphome import automation import esphome.codegen as cg @@ -114,6 +115,158 @@ validate_climate_mode = cv.enum(CLIMATE_MODES, upper=True) ClimatePreset = climate_ns.enum("ClimatePreset") +# Actions a preset's mode, fan_mode or swing_mode needs +_MODE_ACTIONS: dict[str, tuple[str, ...]] = { + "HEAT_COOL": (CONF_COOL_ACTION, CONF_HEAT_ACTION), + "COOL": (CONF_COOL_ACTION,), + "HEAT": (CONF_HEAT_ACTION,), + "DRY": (CONF_DRY_ACTION,), + "FAN_ONLY": (CONF_FAN_ONLY_ACTION,), + "AUTO": (CONF_COOL_ACTION, CONF_HEAT_ACTION), + "OFF": (), +} +_FAN_MODE_ACTIONS: dict[str, str] = { + "ON": CONF_FAN_MODE_ON_ACTION, + "OFF": CONF_FAN_MODE_OFF_ACTION, + "AUTO": CONF_FAN_MODE_AUTO_ACTION, + "LOW": CONF_FAN_MODE_LOW_ACTION, + "MEDIUM": CONF_FAN_MODE_MEDIUM_ACTION, + "HIGH": CONF_FAN_MODE_HIGH_ACTION, + "MIDDLE": CONF_FAN_MODE_MIDDLE_ACTION, + "FOCUS": CONF_FAN_MODE_FOCUS_ACTION, + "DIFFUSE": CONF_FAN_MODE_DIFFUSE_ACTION, + "QUIET": CONF_FAN_MODE_QUIET_ACTION, +} +_SWING_MODE_ACTIONS: dict[str, str] = { + "BOTH": CONF_SWING_BOTH_ACTION, + "HORIZONTAL": CONF_SWING_HORIZONTAL_ACTION, + "OFF": CONF_SWING_OFF_ACTION, + "VERTICAL": CONF_SWING_VERTICAL_ACTION, +} +_PRESET_MODE_ACTIONS: dict[str, dict[str, tuple[str, ...]]] = { + CONF_MODE: _MODE_ACTIONS, + CONF_FAN_MODE: {m: (k,) for m, k in _FAN_MODE_ACTIONS.items()}, + CONF_SWING_MODE: {m: (k,) for m, k in _SWING_MODE_ACTIONS.items()}, +} + + +def validate_heat_cool_mode(value: Any) -> list: + """Validate heat_cool_mode - accepts either True or an automation.""" + if value is True: + # Convert True to empty automation list + return [] + if value is False: + raise cv.Invalid( + "heat_cool_mode cannot be 'false'. Specify 'true' to enable the mode or provide an automation" + ) + # Otherwise validate as automation + return automation.validate_automation(single=True)(value) + + +class _Trigger(NamedTuple): + key: str + getter: str + supports: str | None = None # set to true when the key is configured + validator: Callable[[Any], Any] | None = None # None: a single automation + + +# Generated in this order +_TRIGGERS: tuple[_Trigger, ...] = ( + _Trigger(CONF_COOL_ACTION, "get_cool_action_trigger", "set_supports_cool"), + _Trigger(CONF_SUPPLEMENTAL_COOLING_ACTION, "get_supplemental_cool_action_trigger"), + _Trigger(CONF_DRY_ACTION, "get_dry_action_trigger", "set_supports_dry"), + _Trigger( + CONF_FAN_ONLY_ACTION, "get_fan_only_action_trigger", "set_supports_fan_only" + ), + _Trigger(CONF_HEAT_ACTION, "get_heat_action_trigger", "set_supports_heat"), + _Trigger(CONF_SUPPLEMENTAL_HEATING_ACTION, "get_supplemental_heat_action_trigger"), + _Trigger(CONF_AUTO_MODE, "get_auto_mode_trigger", "set_supports_auto"), + _Trigger(CONF_COOL_MODE, "get_cool_mode_trigger", "set_supports_cool"), + _Trigger(CONF_DRY_MODE, "get_dry_mode_trigger", "set_supports_dry"), + _Trigger(CONF_FAN_ONLY_MODE, "get_fan_only_mode_trigger", "set_supports_fan_only"), + _Trigger(CONF_HEAT_MODE, "get_heat_mode_trigger", "set_supports_heat"), + _Trigger( + CONF_HEAT_COOL_MODE, + "get_heat_cool_mode_trigger", + "set_supports_heat_cool", + validate_heat_cool_mode, + ), + _Trigger(CONF_OFF_MODE, "get_off_mode_trigger"), + *( + _Trigger( + key, + f"get_fan_mode_{mode.lower()}_trigger", + f"set_supports_fan_mode_{mode.lower()}", + ) + for mode, key in _FAN_MODE_ACTIONS.items() + ), + *( + _Trigger( + key, + f"get_swing_mode_{mode.lower()}_trigger", + f"set_supports_swing_mode_{mode.lower()}", + ) + for mode, key in _SWING_MODE_ACTIONS.items() + ), + _Trigger(CONF_TARGET_HUMIDITY_CHANGE_ACTION, "get_humidity_change_trigger"), + _Trigger(CONF_TARGET_TEMPERATURE_CHANGE_ACTION, "get_temperature_change_trigger"), +) +# Mutually exclusive; supports is set before the automation is built +_HUMIDITY_CONTROL_TRIGGERS: tuple[_Trigger, ...] = ( + _Trigger( + CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION, + "get_humidity_control_dehumidify_action_trigger", + "set_supports_dehumidification", + ), + _Trigger( + CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION, + "get_humidity_control_humidify_action_trigger", + "set_supports_humidification", + ), +) + +# Settings passed to their setter as is, in this order +_DEADBAND_OVERRUNS: dict[str, str] = { + CONF_COOL_DEADBAND: "set_cool_deadband", + CONF_COOL_OVERRUN: "set_cool_overrun", + CONF_HEAT_DEADBAND: "set_heat_deadband", + CONF_HEAT_OVERRUN: "set_heat_overrun", +} +_TIMERS: dict[str, str] = { + CONF_MAX_COOLING_RUN_TIME: "set_cooling_maximum_run_time_in_sec", + CONF_MAX_HEATING_RUN_TIME: "set_heating_maximum_run_time_in_sec", + CONF_MIN_COOLING_OFF_TIME: "set_cooling_minimum_off_time_in_sec", + CONF_MIN_COOLING_RUN_TIME: "set_cooling_minimum_run_time_in_sec", + CONF_MIN_FAN_MODE_SWITCHING_TIME: "set_fan_mode_minimum_switching_time_in_sec", + CONF_MIN_FANNING_OFF_TIME: "set_fanning_minimum_off_time_in_sec", + CONF_MIN_FANNING_RUN_TIME: "set_fanning_minimum_run_time_in_sec", + CONF_MIN_HEATING_OFF_TIME: "set_heating_minimum_off_time_in_sec", + CONF_MIN_HEATING_RUN_TIME: "set_heating_minimum_run_time_in_sec", +} +_SUPPLEMENTAL_DELTAS: dict[str, str] = { + CONF_SUPPLEMENTAL_COOLING_DELTA: "set_supplemental_cool_delta", + CONF_SUPPLEMENTAL_HEATING_DELTA: "set_supplemental_heat_delta", +} +_FLAGS: dict[str, str] = { + CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER: "set_supports_fan_only_action_uses_fan_mode_timer", + CONF_FAN_ONLY_COOLING: "set_supports_fan_only_cooling", + CONF_FAN_WITH_COOLING: "set_supports_fan_with_cooling", + CONF_FAN_WITH_HEATING: "set_supports_fan_with_heating", + CONF_STARTUP_DELAY: "set_use_startup_delay", +} +_SETTINGS: tuple[tuple[str, str], ...] = ( + *_DEADBAND_OVERRUNS.items(), + *_TIMERS.items(), + *_SUPPLEMENTAL_DELTAS.items(), + (CONF_MIN_IDLE_TIME, "set_idle_minimum_time_in_sec"), + *_FLAGS.items(), +) + +_TARGET_TEMPERATURES = ( + CONF_DEFAULT_TARGET_TEMPERATURE_LOW, + CONF_DEFAULT_TARGET_TEMPERATURE_HIGH, +) + PRESET_CONFIG_SCHEMA = cv.Schema( { # Still accepted for existing configs; presets are flash table entries, not variables. @@ -151,28 +304,12 @@ def validate_temperature_preset( def generate_comparable_preset(config: ConfigType, name: str) -> str: comparable_preset = f"{CONF_PRESET}:\n - {CONF_NAME}: {name}\n" - - if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: - comparable_preset += f" {CONF_DEFAULT_TARGET_TEMPERATURE_LOW}: {config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW]}\n" - if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in config: - comparable_preset += f" {CONF_DEFAULT_TARGET_TEMPERATURE_HIGH}: {config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH]}\n" - + for key in _TARGET_TEMPERATURES: + if key in config: + comparable_preset += f" {key}: {config[key]}\n" return comparable_preset -def validate_heat_cool_mode(value: Any) -> list: - """Validate heat_cool_mode - accepts either True or an automation.""" - if value is True: - # Convert True to empty automation list - return [] - if value is False: - raise cv.Invalid( - "heat_cool_mode cannot be 'false'. Specify 'true' to enable the mode or provide an automation" - ) - # Otherwise validate as automation - return automation.validate_automation(single=True)(value) - - _KEY_REQUIREMENTS: dict[str, list[str]] = { CONF_HEAT_COOL_MODE: [ CONF_COOL_ACTION, @@ -272,13 +409,11 @@ _KEY_REQUIREMENTS: dict[str, list[str]] = { def validate_thermostat(config: ConfigType) -> ConfigType: if CONF_FAN_ONLY_ACTION in config: # determine validation requirements based on fan_only_action_uses_fan_mode_timer setting - if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] is True: - requirements = [CONF_MIN_FAN_MODE_SWITCHING_TIME] - else: - requirements = [ - CONF_MIN_FANNING_OFF_TIME, - CONF_MIN_FANNING_RUN_TIME, - ] + requirements = ( + [CONF_MIN_FAN_MODE_SWITCHING_TIME] + if config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] + else [CONF_MIN_FANNING_OFF_TIME, CONF_MIN_FANNING_RUN_TIME] + ) for config_req_action in requirements: if config_req_action not in config: raise cv.Invalid( @@ -286,35 +421,23 @@ def validate_thermostat(config: ConfigType) -> ConfigType: ) # determine validation requirements based on fan_only_cooling setting - if config[CONF_FAN_ONLY_COOLING] is True: - requirements = { - CONF_DEFAULT_TARGET_TEMPERATURE_HIGH: [ - CONF_COOL_ACTION, - CONF_FAN_ONLY_ACTION, - ], - CONF_DEFAULT_TARGET_TEMPERATURE_LOW: [CONF_HEAT_ACTION], - } - else: - requirements = { - CONF_DEFAULT_TARGET_TEMPERATURE_HIGH: [CONF_COOL_ACTION], - CONF_DEFAULT_TARGET_TEMPERATURE_LOW: [CONF_HEAT_ACTION], - } + requirements = { + CONF_DEFAULT_TARGET_TEMPERATURE_HIGH: ( + [CONF_COOL_ACTION, CONF_FAN_ONLY_ACTION] + if config[CONF_FAN_ONLY_COOLING] + else [CONF_COOL_ACTION] + ), + CONF_DEFAULT_TARGET_TEMPERATURE_LOW: [CONF_HEAT_ACTION], + } # Legacy high/low configs - if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in config: - comparable_preset = generate_comparable_preset(config, "Your new preset") - - raise cv.Invalid( - f"{CONF_DEFAULT_TARGET_TEMPERATURE_LOW} is no longer valid. Please switch to using a preset for an equivalent experience.\nEquivalent configuration:\n\n" - f"{comparable_preset}" - ) - if CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in config: - comparable_preset = generate_comparable_preset(config, "Your new preset") - - raise cv.Invalid( - f"{CONF_DEFAULT_TARGET_TEMPERATURE_HIGH} is no longer valid. Please switch to using a preset for an equivalent experience.\nEquivalent configuration:\n\n" - f"{comparable_preset}" - ) + for key in _TARGET_TEMPERATURES: + if key in config: + comparable_preset = generate_comparable_preset(config, "Your new preset") + raise cv.Invalid( + f"{key} is no longer valid. Please switch to using a preset for an equivalent experience.\nEquivalent configuration:\n\n" + f"{comparable_preset}" + ) # Legacy away mode - raise an error instructing the user to switch to presets if CONF_AWAY_CONFIG in config: @@ -335,43 +458,19 @@ def validate_thermostat(config: ConfigType) -> ConfigType: ) # Warn about using the removed CONF_DEFAULT_MODE and advise users - if CONF_DEFAULT_MODE in config and config[CONF_DEFAULT_MODE] is not None: + if config[CONF_DEFAULT_MODE] is not None: raise cv.Invalid( f"{CONF_DEFAULT_MODE} is no longer valid. Please switch to using presets and specify a {CONF_DEFAULT_PRESET}" ) - default_mode = config[CONF_DEFAULT_MODE] - requirements = { - "HEAT_COOL": [CONF_COOL_ACTION, CONF_HEAT_ACTION], - "COOL": [CONF_COOL_ACTION], - "HEAT": [CONF_HEAT_ACTION], - "DRY": [CONF_DRY_ACTION], - "FAN_ONLY": [CONF_FAN_ONLY_ACTION], - "AUTO": [CONF_COOL_ACTION, CONF_HEAT_ACTION], - "OFF": [], - } - actions_for_default_mode = requirements.get(default_mode, []) - for req in actions_for_default_mode: - if req not in config: - raise cv.Invalid( - f"{CONF_DEFAULT_MODE} is set to {default_mode} but {req} is not present in the configuration" - ) - # Verify that the modes for presets are valid given the configuration if CONF_PRESET in config: # Preset temperature vs Visual temperature validation # Default visual configuration from climate_traits.h - visual_min_temperature = 10.0 - visual_max_temperature = 30.0 - if CONF_VISUAL in config: - visual_config = config[CONF_VISUAL] - - if CONF_MIN_TEMPERATURE in visual_config: - visual_min_temperature = visual_config[CONF_MIN_TEMPERATURE] - - if CONF_MAX_TEMPERATURE in visual_config: - visual_max_temperature = visual_config[CONF_MAX_TEMPERATURE] + visual_config = config.get(CONF_VISUAL, {}) + visual_min_temperature = visual_config.get(CONF_MIN_TEMPERATURE, 10.0) + visual_max_temperature = visual_config.get(CONF_MAX_TEMPERATURE, 30.0) for preset_config in config[CONF_PRESET]: if CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: @@ -392,64 +491,16 @@ def validate_thermostat(config: ConfigType) -> ConfigType: f"{CONF_DEFAULT_TARGET_TEMPERATURE_HIGH} for {preset_config[CONF_NAME]} is set to {preset_max_temperature} which is more than the visual maximum temperature of {visual_max_temperature}" ) - # Mode validation - for preset_config in config[CONF_PRESET]: - if CONF_MODE not in preset_config: - continue - - mode = preset_config[CONF_MODE] - - for req in requirements[mode]: - if req not in config: - raise cv.Invalid( - f"{CONF_MODE} is set to {mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" - ) - - # Fan mode requirements - requirements = { - "ON": [CONF_FAN_MODE_ON_ACTION], - "OFF": [CONF_FAN_MODE_OFF_ACTION], - "AUTO": [CONF_FAN_MODE_AUTO_ACTION], - "LOW": [CONF_FAN_MODE_LOW_ACTION], - "MEDIUM": [CONF_FAN_MODE_MEDIUM_ACTION], - "HIGH": [CONF_FAN_MODE_HIGH_ACTION], - "MIDDLE": [CONF_FAN_MODE_MIDDLE_ACTION], - "FOCUS": [CONF_FAN_MODE_FOCUS_ACTION], - "DIFFUSE": [CONF_FAN_MODE_DIFFUSE_ACTION], - "QUIET": [CONF_FAN_MODE_QUIET_ACTION], - } - - for preset_config in config[CONF_PRESET]: - if CONF_FAN_MODE not in preset_config: - continue - - fan_mode = preset_config[CONF_FAN_MODE] - - for req in requirements[fan_mode]: - if req not in config: - raise cv.Invalid( - f"{CONF_FAN_MODE} is set to {fan_mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" - ) - - # Swing mode requirements - requirements = { - "OFF": [CONF_SWING_OFF_ACTION], - "BOTH": [CONF_SWING_BOTH_ACTION], - "VERTICAL": [CONF_SWING_VERTICAL_ACTION], - "HORIZONTAL": [CONF_SWING_HORIZONTAL_ACTION], - } - - for preset_config in config[CONF_PRESET]: - if CONF_SWING_MODE not in preset_config: - continue - - swing_mode = preset_config[CONF_SWING_MODE] - - for req in requirements[swing_mode]: - if req not in config: - raise cv.Invalid( - f"{CONF_SWING_MODE} is set to {swing_mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" - ) + # Mode, fan mode and swing mode validation + for mode_key, mode_actions in _PRESET_MODE_ACTIONS.items(): + for preset_config in config[CONF_PRESET]: + if (mode := preset_config.get(mode_key)) is None: + continue + for req in mode_actions[mode]: + if req not in config: + raise cv.Invalid( + f"{mode_key} is set to {mode} for {preset_config[CONF_NAME]} but {req} is not present in the configuration" + ) # If a default preset is requested then ensure that preset is defined if CONF_DEFAULT_PRESET in config: @@ -461,56 +512,30 @@ def validate_thermostat(config: ConfigType) -> ConfigType: ) presets = config[CONF_PRESET] - found_preset = False - - for preset in presets: - if preset[CONF_NAME] == default_preset: - found_preset = True - break - - if found_preset is False: + if not any(preset[CONF_NAME] == default_preset for preset in presets): raise cv.Invalid( f"{CONF_DEFAULT_PRESET} set to '{default_preset}' but no such preset has been defined. Available presets: {[preset[CONF_NAME] for preset in presets]}" ) # If restoring default preset on boot is true then ensure we have a default preset if ( - CONF_ON_BOOT_RESTORE_FROM in config - and config[CONF_ON_BOOT_RESTORE_FROM] == "DEFAULT_PRESET" + config.get(CONF_ON_BOOT_RESTORE_FROM) == "DEFAULT_PRESET" and CONF_DEFAULT_PRESET not in config ): raise cv.Invalid( f"{CONF_DEFAULT_PRESET} must be defined to use {CONF_ON_BOOT_RESTORE_FROM} in DEFAULT_PRESET mode" ) - if config[CONF_FAN_WITH_COOLING] is True and CONF_FAN_ONLY_ACTION not in config: - raise cv.Invalid( - f"{CONF_FAN_ONLY_ACTION} must be defined to use {CONF_FAN_WITH_COOLING}" - ) - if config[CONF_FAN_WITH_HEATING] is True and CONF_FAN_ONLY_ACTION not in config: - raise cv.Invalid( - f"{CONF_FAN_ONLY_ACTION} must be defined to use {CONF_FAN_WITH_HEATING}" - ) + for key in (CONF_FAN_WITH_COOLING, CONF_FAN_WITH_HEATING): + if config[key] is True and CONF_FAN_ONLY_ACTION not in config: + raise cv.Invalid(f"{CONF_FAN_ONLY_ACTION} must be defined to use {key}") # if min_fan_mode_switching_time is defined, at least one fan_mode action should be defined - if CONF_MIN_FAN_MODE_SWITCHING_TIME in config: - requirements = [ - CONF_FAN_MODE_ON_ACTION, - CONF_FAN_MODE_OFF_ACTION, - CONF_FAN_MODE_AUTO_ACTION, - CONF_FAN_MODE_LOW_ACTION, - CONF_FAN_MODE_MEDIUM_ACTION, - CONF_FAN_MODE_HIGH_ACTION, - CONF_FAN_MODE_MIDDLE_ACTION, - CONF_FAN_MODE_FOCUS_ACTION, - CONF_FAN_MODE_DIFFUSE_ACTION, - CONF_FAN_MODE_QUIET_ACTION, - ] - for config_req_action in requirements: - if config_req_action in config: - return config + if CONF_MIN_FAN_MODE_SWITCHING_TIME in config and not any( + key in config for key in _FAN_MODE_ACTIONS.values() + ): raise cv.Invalid( - f"At least one of {CONF_FAN_MODE_ON_ACTION}, {CONF_FAN_MODE_OFF_ACTION}, {CONF_FAN_MODE_AUTO_ACTION}, {CONF_FAN_MODE_LOW_ACTION}, {CONF_FAN_MODE_MEDIUM_ACTION}, {CONF_FAN_MODE_HIGH_ACTION}, {CONF_FAN_MODE_MIDDLE_ACTION}, {CONF_FAN_MODE_FOCUS_ACTION}, {CONF_FAN_MODE_DIFFUSE_ACTION}, {CONF_FAN_MODE_QUIET_ACTION} must be defined to use {CONF_MIN_FAN_MODE_SWITCHING_TIME}" + f"At least one of {', '.join(_FAN_MODE_ACTIONS.values())} must be defined to use {CONF_MIN_FAN_MODE_SWITCHING_TIME}" ) return config @@ -522,124 +547,37 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor), cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), cv.Required(CONF_IDLE_ACTION): automation.validate_automation(single=True), - cv.Optional(CONF_COOL_ACTION): automation.validate_automation(single=True), - cv.Optional( - CONF_SUPPLEMENTAL_COOLING_ACTION - ): automation.validate_automation(single=True), - cv.Optional(CONF_DRY_ACTION): automation.validate_automation(single=True), - cv.Optional(CONF_FAN_ONLY_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_HEAT_ACTION): automation.validate_automation(single=True), - cv.Optional( - CONF_SUPPLEMENTAL_HEATING_ACTION - ): automation.validate_automation(single=True), - cv.Optional(CONF_AUTO_MODE): automation.validate_automation(single=True), - cv.Optional(CONF_COOL_MODE): automation.validate_automation(single=True), - cv.Optional(CONF_DRY_MODE): automation.validate_automation(single=True), - cv.Optional(CONF_FAN_ONLY_MODE): automation.validate_automation( - single=True - ), - cv.Optional(CONF_HEAT_COOL_MODE): validate_heat_cool_mode, - cv.Optional(CONF_HEAT_MODE): automation.validate_automation(single=True), - cv.Optional(CONF_OFF_MODE): automation.validate_automation(single=True), - cv.Optional(CONF_FAN_MODE_ON_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_OFF_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_AUTO_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_LOW_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_MEDIUM_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_HIGH_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_MIDDLE_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_FOCUS_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_DIFFUSE_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_FAN_MODE_QUIET_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_SWING_BOTH_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_SWING_HORIZONTAL_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_SWING_OFF_ACTION): automation.validate_automation( - single=True - ), - cv.Optional(CONF_SWING_VERTICAL_ACTION): automation.validate_automation( - single=True - ), - cv.Optional( - CONF_TARGET_HUMIDITY_CHANGE_ACTION - ): automation.validate_automation(single=True), - cv.Optional( - CONF_TARGET_TEMPERATURE_CHANGE_ACTION - ): automation.validate_automation(single=True), - cv.Exclusive( - CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION, - group_of_exclusion="humidity_control", - ): automation.validate_automation(single=True), - cv.Exclusive( - CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION, - group_of_exclusion="humidity_control", - ): automation.validate_automation(single=True), + **{ + cv.Optional(t.key): t.validator + or automation.validate_automation(single=True) + for t in _TRIGGERS + }, + **{ + cv.Exclusive( + t.key, group_of_exclusion="humidity_control" + ): automation.validate_automation(single=True) + for t in _HUMIDITY_CONTROL_TRIGGERS + }, cv.Optional( CONF_HUMIDITY_CONTROL_OFF_ACTION ): automation.validate_automation(single=True), cv.Optional(CONF_HUMIDITY_HYSTERESIS, default=1.0): cv.percentage, cv.Optional(CONF_DEFAULT_MODE, default=None): cv.valid, cv.Optional(CONF_DEFAULT_PRESET): cv.string, - cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, - cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, + **{cv.Optional(key): cv.temperature for key in _TARGET_TEMPERATURES}, cv.Optional( CONF_SET_POINT_MINIMUM_DIFFERENTIAL, default=0.5 ): cv.temperature_delta, - cv.Optional(CONF_COOL_DEADBAND, default=0.5): cv.temperature_delta, - cv.Optional(CONF_COOL_OVERRUN, default=0.5): cv.temperature_delta, - cv.Optional(CONF_HEAT_DEADBAND, default=0.5): cv.temperature_delta, - cv.Optional(CONF_HEAT_OVERRUN, default=0.5): cv.temperature_delta, - cv.Optional(CONF_MAX_COOLING_RUN_TIME): cv.positive_time_period_seconds, - cv.Optional(CONF_MAX_HEATING_RUN_TIME): cv.positive_time_period_seconds, - cv.Optional(CONF_MIN_COOLING_OFF_TIME): cv.positive_time_period_seconds, - cv.Optional(CONF_MIN_COOLING_RUN_TIME): cv.positive_time_period_seconds, - cv.Optional( - CONF_MIN_FAN_MODE_SWITCHING_TIME - ): cv.positive_time_period_seconds, - cv.Optional(CONF_MIN_FANNING_OFF_TIME): cv.positive_time_period_seconds, - cv.Optional(CONF_MIN_FANNING_RUN_TIME): cv.positive_time_period_seconds, - cv.Optional(CONF_MIN_HEATING_OFF_TIME): cv.positive_time_period_seconds, - cv.Optional(CONF_MIN_HEATING_RUN_TIME): cv.positive_time_period_seconds, + **{ + cv.Optional(key, default=0.5): cv.temperature_delta + for key in _DEADBAND_OVERRUNS + }, + **{cv.Optional(key): cv.positive_time_period_seconds for key in _TIMERS}, cv.Required(CONF_MIN_IDLE_TIME): cv.positive_time_period_seconds, - cv.Optional(CONF_SUPPLEMENTAL_COOLING_DELTA): cv.temperature_delta, - cv.Optional(CONF_SUPPLEMENTAL_HEATING_DELTA): cv.temperature_delta, - cv.Optional( - CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER, default=False - ): cv.boolean, - cv.Optional(CONF_FAN_ONLY_COOLING, default=False): cv.boolean, - cv.Optional(CONF_FAN_WITH_COOLING, default=False): cv.boolean, - cv.Optional(CONF_FAN_WITH_HEATING, default=False): cv.boolean, - cv.Optional(CONF_STARTUP_DELAY, default=False): cv.boolean, + **{cv.Optional(key): cv.temperature_delta for key in _SUPPLEMENTAL_DELTAS}, + **{cv.Optional(key, default=False): cv.boolean for key in _FLAGS}, cv.Optional(CONF_AWAY_CONFIG): cv.Schema( - { - cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, - cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, - } + {cv.Optional(key): cv.temperature for key in _TARGET_TEMPERATURES} ), cv.Optional(CONF_PRESET): cv.All( cv.ensure_list(PRESET_CONFIG_SCHEMA), cv.Length(max=255) @@ -663,24 +601,20 @@ CONFIG_SCHEMA = cv.All( any_of=True, ), cv.key_requires( - ( - CONF_FAN_MODE_ON_ACTION, - CONF_FAN_MODE_OFF_ACTION, - CONF_FAN_MODE_AUTO_ACTION, - CONF_FAN_MODE_LOW_ACTION, - CONF_FAN_MODE_MEDIUM_ACTION, - CONF_FAN_MODE_HIGH_ACTION, - CONF_FAN_MODE_MIDDLE_ACTION, - CONF_FAN_MODE_FOCUS_ACTION, - CONF_FAN_MODE_DIFFUSE_ACTION, - CONF_FAN_MODE_QUIET_ACTION, - ), - CONF_MIN_FAN_MODE_SWITCHING_TIME, + tuple(_FAN_MODE_ACTIONS.values()), CONF_MIN_FAN_MODE_SWITCHING_TIME ), validate_thermostat, ) +async def _build_trigger( + var: cg.MockObj, config: ConfigType, key: str, getter: str +) -> None: + # heat_cool_mode: true is an empty list and has no automation + if conf := config.get(key): + await automation.build_automation(getattr(var, getter)(), [], conf) + + async def to_code(config: ConfigType) -> None: var = await climate.new_climate(config) await cg.register_component(var, config) @@ -702,255 +636,24 @@ async def to_code(config: ConfigType) -> None: await cg.add_variable(var.set_humidity_sensor, config.get(CONF_HUMIDITY_SENSOR)) - cg.add(var.set_cool_deadband(config[CONF_COOL_DEADBAND])) - cg.add(var.set_cool_overrun(config[CONF_COOL_OVERRUN])) - cg.add(var.set_heat_deadband(config[CONF_HEAT_DEADBAND])) - cg.add(var.set_heat_overrun(config[CONF_HEAT_OVERRUN])) + for key, setter in _SETTINGS: + cg.add_optional(getattr(var, setter), config.get(key)) - if CONF_MAX_COOLING_RUN_TIME in config: - cg.add( - var.set_cooling_maximum_run_time_in_sec(config[CONF_MAX_COOLING_RUN_TIME]) - ) - - if CONF_MAX_HEATING_RUN_TIME in config: - cg.add( - var.set_heating_maximum_run_time_in_sec(config[CONF_MAX_HEATING_RUN_TIME]) - ) - - if CONF_MIN_COOLING_OFF_TIME in config: - cg.add( - var.set_cooling_minimum_off_time_in_sec(config[CONF_MIN_COOLING_OFF_TIME]) - ) - - if CONF_MIN_COOLING_RUN_TIME in config: - cg.add( - var.set_cooling_minimum_run_time_in_sec(config[CONF_MIN_COOLING_RUN_TIME]) - ) - - if CONF_MIN_FAN_MODE_SWITCHING_TIME in config: - cg.add( - var.set_fan_mode_minimum_switching_time_in_sec( - config[CONF_MIN_FAN_MODE_SWITCHING_TIME] - ) - ) - - if CONF_MIN_FANNING_OFF_TIME in config: - cg.add( - var.set_fanning_minimum_off_time_in_sec(config[CONF_MIN_FANNING_OFF_TIME]) - ) - - if CONF_MIN_FANNING_RUN_TIME in config: - cg.add( - var.set_fanning_minimum_run_time_in_sec(config[CONF_MIN_FANNING_RUN_TIME]) - ) - - if CONF_MIN_HEATING_OFF_TIME in config: - cg.add( - var.set_heating_minimum_off_time_in_sec(config[CONF_MIN_HEATING_OFF_TIME]) - ) - - if CONF_MIN_HEATING_RUN_TIME in config: - cg.add( - var.set_heating_minimum_run_time_in_sec(config[CONF_MIN_HEATING_RUN_TIME]) - ) - - if CONF_SUPPLEMENTAL_COOLING_DELTA in config: - cg.add(var.set_supplemental_cool_delta(config[CONF_SUPPLEMENTAL_COOLING_DELTA])) - - if CONF_SUPPLEMENTAL_HEATING_DELTA in config: - cg.add(var.set_supplemental_heat_delta(config[CONF_SUPPLEMENTAL_HEATING_DELTA])) - - cg.add(var.set_idle_minimum_time_in_sec(config[CONF_MIN_IDLE_TIME])) - - cg.add( - var.set_supports_fan_only_action_uses_fan_mode_timer( - config[CONF_FAN_ONLY_ACTION_USES_FAN_MODE_TIMER] - ) + await _build_trigger(var, config, CONF_IDLE_ACTION, "get_idle_action_trigger") + for t in _TRIGGERS: + await _build_trigger(var, config, t.key, t.getter) + if t.supports is not None and t.key in config: + cg.add(getattr(var, t.supports)(True)) + for t in _HUMIDITY_CONTROL_TRIGGERS: + if t.key in config: + cg.add(getattr(var, t.supports)(True)) + await _build_trigger(var, config, t.key, t.getter) + await _build_trigger( + var, + config, + CONF_HUMIDITY_CONTROL_OFF_ACTION, + "get_humidity_control_off_action_trigger", ) - cg.add(var.set_supports_fan_only_cooling(config[CONF_FAN_ONLY_COOLING])) - cg.add(var.set_supports_fan_with_cooling(config[CONF_FAN_WITH_COOLING])) - cg.add(var.set_supports_fan_with_heating(config[CONF_FAN_WITH_HEATING])) - - cg.add(var.set_use_startup_delay(config[CONF_STARTUP_DELAY])) - - await automation.build_automation( - var.get_idle_action_trigger(), [], config[CONF_IDLE_ACTION] - ) - - if CONF_COOL_ACTION in config: - await automation.build_automation( - var.get_cool_action_trigger(), [], config[CONF_COOL_ACTION] - ) - cg.add(var.set_supports_cool(True)) - if CONF_SUPPLEMENTAL_COOLING_ACTION in config: - await automation.build_automation( - var.get_supplemental_cool_action_trigger(), - [], - config[CONF_SUPPLEMENTAL_COOLING_ACTION], - ) - if CONF_DRY_ACTION in config: - await automation.build_automation( - var.get_dry_action_trigger(), [], config[CONF_DRY_ACTION] - ) - cg.add(var.set_supports_dry(True)) - if CONF_FAN_ONLY_ACTION in config: - await automation.build_automation( - var.get_fan_only_action_trigger(), [], config[CONF_FAN_ONLY_ACTION] - ) - cg.add(var.set_supports_fan_only(True)) - if CONF_HEAT_ACTION in config: - await automation.build_automation( - var.get_heat_action_trigger(), [], config[CONF_HEAT_ACTION] - ) - cg.add(var.set_supports_heat(True)) - if CONF_SUPPLEMENTAL_HEATING_ACTION in config: - await automation.build_automation( - var.get_supplemental_heat_action_trigger(), - [], - config[CONF_SUPPLEMENTAL_HEATING_ACTION], - ) - if CONF_AUTO_MODE in config: - await automation.build_automation( - var.get_auto_mode_trigger(), [], config[CONF_AUTO_MODE] - ) - cg.add(var.set_supports_auto(True)) - if CONF_COOL_MODE in config: - await automation.build_automation( - var.get_cool_mode_trigger(), [], config[CONF_COOL_MODE] - ) - cg.add(var.set_supports_cool(True)) - if CONF_DRY_MODE in config: - await automation.build_automation( - var.get_dry_mode_trigger(), [], config[CONF_DRY_MODE] - ) - cg.add(var.set_supports_dry(True)) - if CONF_FAN_ONLY_MODE in config: - await automation.build_automation( - var.get_fan_only_mode_trigger(), [], config[CONF_FAN_ONLY_MODE] - ) - cg.add(var.set_supports_fan_only(True)) - if CONF_HEAT_MODE in config: - await automation.build_automation( - var.get_heat_mode_trigger(), [], config[CONF_HEAT_MODE] - ) - cg.add(var.set_supports_heat(True)) - if CONF_HEAT_COOL_MODE in config: - # Build automation only if user provided actions (not just `true`) - if config[CONF_HEAT_COOL_MODE]: - await automation.build_automation( - var.get_heat_cool_mode_trigger(), [], config[CONF_HEAT_COOL_MODE] - ) - cg.add(var.set_supports_heat_cool(True)) - if CONF_OFF_MODE in config: - await automation.build_automation( - var.get_off_mode_trigger(), [], config[CONF_OFF_MODE] - ) - if CONF_FAN_MODE_ON_ACTION in config: - await automation.build_automation( - var.get_fan_mode_on_trigger(), [], config[CONF_FAN_MODE_ON_ACTION] - ) - cg.add(var.set_supports_fan_mode_on(True)) - if CONF_FAN_MODE_OFF_ACTION in config: - await automation.build_automation( - var.get_fan_mode_off_trigger(), [], config[CONF_FAN_MODE_OFF_ACTION] - ) - cg.add(var.set_supports_fan_mode_off(True)) - if CONF_FAN_MODE_AUTO_ACTION in config: - await automation.build_automation( - var.get_fan_mode_auto_trigger(), [], config[CONF_FAN_MODE_AUTO_ACTION] - ) - cg.add(var.set_supports_fan_mode_auto(True)) - if CONF_FAN_MODE_LOW_ACTION in config: - await automation.build_automation( - var.get_fan_mode_low_trigger(), [], config[CONF_FAN_MODE_LOW_ACTION] - ) - cg.add(var.set_supports_fan_mode_low(True)) - if CONF_FAN_MODE_MEDIUM_ACTION in config: - await automation.build_automation( - var.get_fan_mode_medium_trigger(), [], config[CONF_FAN_MODE_MEDIUM_ACTION] - ) - cg.add(var.set_supports_fan_mode_medium(True)) - if CONF_FAN_MODE_HIGH_ACTION in config: - await automation.build_automation( - var.get_fan_mode_high_trigger(), [], config[CONF_FAN_MODE_HIGH_ACTION] - ) - cg.add(var.set_supports_fan_mode_high(True)) - if CONF_FAN_MODE_MIDDLE_ACTION in config: - await automation.build_automation( - var.get_fan_mode_middle_trigger(), [], config[CONF_FAN_MODE_MIDDLE_ACTION] - ) - cg.add(var.set_supports_fan_mode_middle(True)) - if CONF_FAN_MODE_FOCUS_ACTION in config: - await automation.build_automation( - var.get_fan_mode_focus_trigger(), [], config[CONF_FAN_MODE_FOCUS_ACTION] - ) - cg.add(var.set_supports_fan_mode_focus(True)) - if CONF_FAN_MODE_DIFFUSE_ACTION in config: - await automation.build_automation( - var.get_fan_mode_diffuse_trigger(), [], config[CONF_FAN_MODE_DIFFUSE_ACTION] - ) - cg.add(var.set_supports_fan_mode_diffuse(True)) - if CONF_FAN_MODE_QUIET_ACTION in config: - await automation.build_automation( - var.get_fan_mode_quiet_trigger(), [], config[CONF_FAN_MODE_QUIET_ACTION] - ) - cg.add(var.set_supports_fan_mode_quiet(True)) - if CONF_SWING_BOTH_ACTION in config: - await automation.build_automation( - var.get_swing_mode_both_trigger(), [], config[CONF_SWING_BOTH_ACTION] - ) - cg.add(var.set_supports_swing_mode_both(True)) - if CONF_SWING_HORIZONTAL_ACTION in config: - await automation.build_automation( - var.get_swing_mode_horizontal_trigger(), - [], - config[CONF_SWING_HORIZONTAL_ACTION], - ) - cg.add(var.set_supports_swing_mode_horizontal(True)) - if CONF_SWING_OFF_ACTION in config: - await automation.build_automation( - var.get_swing_mode_off_trigger(), [], config[CONF_SWING_OFF_ACTION] - ) - cg.add(var.set_supports_swing_mode_off(True)) - if CONF_SWING_VERTICAL_ACTION in config: - await automation.build_automation( - var.get_swing_mode_vertical_trigger(), - [], - config[CONF_SWING_VERTICAL_ACTION], - ) - cg.add(var.set_supports_swing_mode_vertical(True)) - if CONF_TARGET_HUMIDITY_CHANGE_ACTION in config: - await automation.build_automation( - var.get_humidity_change_trigger(), - [], - config[CONF_TARGET_HUMIDITY_CHANGE_ACTION], - ) - if CONF_TARGET_TEMPERATURE_CHANGE_ACTION in config: - await automation.build_automation( - var.get_temperature_change_trigger(), - [], - config[CONF_TARGET_TEMPERATURE_CHANGE_ACTION], - ) - if CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION in config: - cg.add(var.set_supports_dehumidification(True)) - await automation.build_automation( - var.get_humidity_control_dehumidify_action_trigger(), - [], - config[CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION], - ) - if CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION in config: - cg.add(var.set_supports_humidification(True)) - await automation.build_automation( - var.get_humidity_control_humidify_action_trigger(), - [], - config[CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION], - ) - if CONF_HUMIDITY_CONTROL_OFF_ACTION in config: - await automation.build_automation( - var.get_humidity_control_off_action_trigger(), - [], - config[CONF_HUMIDITY_CONTROL_OFF_ACTION], - ) cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: @@ -1000,19 +703,14 @@ async def to_code(config: ConfigType) -> None: ) cg.add(var.set_custom_preset_config(table, len(custom_presets))) - if CONF_DEFAULT_PRESET in config: - default_preset_name = config[CONF_DEFAULT_PRESET] - - # if the name is a built in preset use the appropriate naming format - if default_preset_name.upper() in climate.CLIMATE_PRESETS: - climate_preset = climate.CLIMATE_PRESETS[default_preset_name.upper()] - cg.add(var.set_default_preset(climate_preset)) - else: - cg.add(var.set_default_preset(default_preset_name)) + if (default_preset := config.get(CONF_DEFAULT_PRESET)) is not None: + # a built in preset is passed as its enum value + cg.add( + var.set_default_preset( + climate.CLIMATE_PRESETS.get(default_preset.upper(), default_preset) + ) + ) cg.add_optional(var.set_on_boot_restore_from, config.get(CONF_ON_BOOT_RESTORE_FROM)) - if CONF_PRESET_CHANGE in config: - await automation.build_automation( - var.get_preset_change_trigger(), [], config[CONF_PRESET_CHANGE] - ) + await _build_trigger(var, config, CONF_PRESET_CHANGE, "get_preset_change_trigger") From 730dda3d54e3042f475cb3e2219a2ae2e86a466f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:48:54 -1000 Subject: [PATCH 073/170] [components] Publish child entities guarded by a bare pointer check with PUBLISH_STATE (#22082) --- .../components/bmp581_base/bmp581_base.cpp | 9 +- esphome/components/daly_bms/daly_bms.cpp | 141 +++++------------- esphome/components/ezo_pmp/ezo_pmp.cpp | 9 +- esphome/components/max31855/max31855.cpp | 4 +- esphome/components/mmc5983/mmc5983.cpp | 12 +- esphome/components/pipsolar/pipsolar.cpp | 62 ++------ 6 files changed, 58 insertions(+), 179 deletions(-) diff --git a/esphome/components/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 9f67f734e6e..c981246b663 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -249,13 +249,8 @@ void BMP581Component::update() { // 4) Publish measurements to sensor(s), if applicable // ///////////////////////////////////////////////////////// - if (this->temperature_sensor_) { - this->temperature_sensor_->publish_state(temperature); - } - - if (this->pressure_sensor_) { - this->pressure_sensor_->publish_state(pressure); - } + PUBLISH_STATE(this->temperature_sensor_, temperature); + PUBLISH_STATE(this->pressure_sensor_, pressure); this->status_clear_warning(); }); diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 422f6f1350b..0bf6d9e38be 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -136,45 +136,23 @@ void DalyBmsComponent::decode_data_(std::vector data) { switch (it[2]) { #ifdef USE_SENSOR case DALY_REQUEST_BATTERY_LEVEL: - if (this->voltage_sensor_) { - this->voltage_sensor_->publish_state((float) encode_uint16(it[4], it[5]) / 10); - } - if (this->current_sensor_) { - this->current_sensor_->publish_state(((float) (encode_uint16(it[8], it[9]) - DALY_CURRENT_OFFSET) / 10)); - } - if (this->battery_level_sensor_) { - this->battery_level_sensor_->publish_state((float) encode_uint16(it[10], it[11]) / 10); - } + PUBLISH_STATE(this->voltage_sensor_, (float) encode_uint16(it[4], it[5]) / 10); + PUBLISH_STATE(this->current_sensor_, (float) (encode_uint16(it[8], it[9]) - DALY_CURRENT_OFFSET) / 10); + PUBLISH_STATE(this->battery_level_sensor_, (float) encode_uint16(it[10], it[11]) / 10); break; case DALY_REQUEST_MIN_MAX_VOLTAGE: - if (this->max_cell_voltage_sensor_) { - this->max_cell_voltage_sensor_->publish_state((float) encode_uint16(it[4], it[5]) / 1000); - } - if (this->max_cell_voltage_number_sensor_) { - this->max_cell_voltage_number_sensor_->publish_state(it[6]); - } - if (this->min_cell_voltage_sensor_) { - this->min_cell_voltage_sensor_->publish_state((float) encode_uint16(it[7], it[8]) / 1000); - } - if (this->min_cell_voltage_number_sensor_) { - this->min_cell_voltage_number_sensor_->publish_state(it[9]); - } + PUBLISH_STATE(this->max_cell_voltage_sensor_, (float) encode_uint16(it[4], it[5]) / 1000); + PUBLISH_STATE(this->max_cell_voltage_number_sensor_, it[6]); + PUBLISH_STATE(this->min_cell_voltage_sensor_, (float) encode_uint16(it[7], it[8]) / 1000); + PUBLISH_STATE(this->min_cell_voltage_number_sensor_, it[9]); break; case DALY_REQUEST_MIN_MAX_TEMPERATURE: - if (this->max_temperature_sensor_) { - this->max_temperature_sensor_->publish_state(it[4] - DALY_TEMPERATURE_OFFSET); - } - if (this->max_temperature_probe_number_sensor_) { - this->max_temperature_probe_number_sensor_->publish_state(it[5]); - } - if (this->min_temperature_sensor_) { - this->min_temperature_sensor_->publish_state(it[6] - DALY_TEMPERATURE_OFFSET); - } - if (this->min_temperature_probe_number_sensor_) { - this->min_temperature_probe_number_sensor_->publish_state(it[7]); - } + PUBLISH_STATE(this->max_temperature_sensor_, it[4] - DALY_TEMPERATURE_OFFSET); + PUBLISH_STATE(this->max_temperature_probe_number_sensor_, it[5]); + PUBLISH_STATE(this->min_temperature_sensor_, it[6] - DALY_TEMPERATURE_OFFSET); + PUBLISH_STATE(this->min_temperature_probe_number_sensor_, it[7]); break; #endif case DALY_REQUEST_MOS: @@ -196,106 +174,57 @@ void DalyBmsComponent::decode_data_(std::vector data) { } #endif #ifdef USE_BINARY_SENSOR - if (this->charging_mos_enabled_binary_sensor_) { - this->charging_mos_enabled_binary_sensor_->publish_state(it[5]); - } - if (this->discharging_mos_enabled_binary_sensor_) { - this->discharging_mos_enabled_binary_sensor_->publish_state(it[6]); - } + PUBLISH_STATE(this->charging_mos_enabled_binary_sensor_, it[5]); + PUBLISH_STATE(this->discharging_mos_enabled_binary_sensor_, it[6]); #endif #ifdef USE_SENSOR - if (this->remaining_capacity_sensor_) { - this->remaining_capacity_sensor_->publish_state((float) encode_uint32(it[8], it[9], it[10], it[11]) / - 1000); - } + PUBLISH_STATE(this->remaining_capacity_sensor_, (float) encode_uint32(it[8], it[9], it[10], it[11]) / 1000); #endif break; #ifdef USE_SENSOR case DALY_REQUEST_STATUS: - if (this->cells_number_sensor_) { - this->cells_number_sensor_->publish_state(it[4]); - } + PUBLISH_STATE(this->cells_number_sensor_, it[4]); break; case DALY_REQUEST_TEMPERATURE: if (it[4] == 1) { - if (this->temperature_1_sensor_) { - this->temperature_1_sensor_->publish_state(it[5] - DALY_TEMPERATURE_OFFSET); - } - if (this->temperature_2_sensor_) { - this->temperature_2_sensor_->publish_state(it[6] - DALY_TEMPERATURE_OFFSET); - } + PUBLISH_STATE(this->temperature_1_sensor_, it[5] - DALY_TEMPERATURE_OFFSET); + PUBLISH_STATE(this->temperature_2_sensor_, it[6] - DALY_TEMPERATURE_OFFSET); } break; case DALY_REQUEST_CELL_VOLTAGE: switch (it[4]) { case 1: - if (this->cell_1_voltage_sensor_) { - this->cell_1_voltage_sensor_->publish_state((float) encode_uint16(it[5], it[6]) / 1000); - } - if (this->cell_2_voltage_sensor_) { - this->cell_2_voltage_sensor_->publish_state((float) encode_uint16(it[7], it[8]) / 1000); - } - if (this->cell_3_voltage_sensor_) { - this->cell_3_voltage_sensor_->publish_state((float) encode_uint16(it[9], it[10]) / 1000); - } + PUBLISH_STATE(this->cell_1_voltage_sensor_, (float) encode_uint16(it[5], it[6]) / 1000); + PUBLISH_STATE(this->cell_2_voltage_sensor_, (float) encode_uint16(it[7], it[8]) / 1000); + PUBLISH_STATE(this->cell_3_voltage_sensor_, (float) encode_uint16(it[9], it[10]) / 1000); break; case 2: - if (this->cell_4_voltage_sensor_) { - this->cell_4_voltage_sensor_->publish_state((float) encode_uint16(it[5], it[6]) / 1000); - } - if (this->cell_5_voltage_sensor_) { - this->cell_5_voltage_sensor_->publish_state((float) encode_uint16(it[7], it[8]) / 1000); - } - if (this->cell_6_voltage_sensor_) { - this->cell_6_voltage_sensor_->publish_state((float) encode_uint16(it[9], it[10]) / 1000); - } + PUBLISH_STATE(this->cell_4_voltage_sensor_, (float) encode_uint16(it[5], it[6]) / 1000); + PUBLISH_STATE(this->cell_5_voltage_sensor_, (float) encode_uint16(it[7], it[8]) / 1000); + PUBLISH_STATE(this->cell_6_voltage_sensor_, (float) encode_uint16(it[9], it[10]) / 1000); break; case 3: - if (this->cell_7_voltage_sensor_) { - this->cell_7_voltage_sensor_->publish_state((float) encode_uint16(it[5], it[6]) / 1000); - } - if (this->cell_8_voltage_sensor_) { - this->cell_8_voltage_sensor_->publish_state((float) encode_uint16(it[7], it[8]) / 1000); - } - if (this->cell_9_voltage_sensor_) { - this->cell_9_voltage_sensor_->publish_state((float) encode_uint16(it[9], it[10]) / 1000); - } + PUBLISH_STATE(this->cell_7_voltage_sensor_, (float) encode_uint16(it[5], it[6]) / 1000); + PUBLISH_STATE(this->cell_8_voltage_sensor_, (float) encode_uint16(it[7], it[8]) / 1000); + PUBLISH_STATE(this->cell_9_voltage_sensor_, (float) encode_uint16(it[9], it[10]) / 1000); break; case 4: - if (this->cell_10_voltage_sensor_) { - this->cell_10_voltage_sensor_->publish_state((float) encode_uint16(it[5], it[6]) / 1000); - } - if (this->cell_11_voltage_sensor_) { - this->cell_11_voltage_sensor_->publish_state((float) encode_uint16(it[7], it[8]) / 1000); - } - if (this->cell_12_voltage_sensor_) { - this->cell_12_voltage_sensor_->publish_state((float) encode_uint16(it[9], it[10]) / 1000); - } + PUBLISH_STATE(this->cell_10_voltage_sensor_, (float) encode_uint16(it[5], it[6]) / 1000); + PUBLISH_STATE(this->cell_11_voltage_sensor_, (float) encode_uint16(it[7], it[8]) / 1000); + PUBLISH_STATE(this->cell_12_voltage_sensor_, (float) encode_uint16(it[9], it[10]) / 1000); break; case 5: - if (this->cell_13_voltage_sensor_) { - this->cell_13_voltage_sensor_->publish_state((float) encode_uint16(it[5], it[6]) / 1000); - } - if (this->cell_14_voltage_sensor_) { - this->cell_14_voltage_sensor_->publish_state((float) encode_uint16(it[7], it[8]) / 1000); - } - if (this->cell_15_voltage_sensor_) { - this->cell_15_voltage_sensor_->publish_state((float) encode_uint16(it[9], it[10]) / 1000); - } + PUBLISH_STATE(this->cell_13_voltage_sensor_, (float) encode_uint16(it[5], it[6]) / 1000); + PUBLISH_STATE(this->cell_14_voltage_sensor_, (float) encode_uint16(it[7], it[8]) / 1000); + PUBLISH_STATE(this->cell_15_voltage_sensor_, (float) encode_uint16(it[9], it[10]) / 1000); break; case 6: - if (this->cell_16_voltage_sensor_) { - this->cell_16_voltage_sensor_->publish_state((float) encode_uint16(it[5], it[6]) / 1000); - } - if (this->cell_17_voltage_sensor_) { - this->cell_17_voltage_sensor_->publish_state((float) encode_uint16(it[7], it[8]) / 1000); - } - if (this->cell_18_voltage_sensor_) { - this->cell_18_voltage_sensor_->publish_state((float) encode_uint16(it[9], it[10]) / 1000); - } + PUBLISH_STATE(this->cell_16_voltage_sensor_, (float) encode_uint16(it[5], it[6]) / 1000); + PUBLISH_STATE(this->cell_17_voltage_sensor_, (float) encode_uint16(it[7], it[8]) / 1000); + PUBLISH_STATE(this->cell_18_voltage_sensor_, (float) encode_uint16(it[9], it[10]) / 1000); break; } break; diff --git a/esphome/components/ezo_pmp/ezo_pmp.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index 1209c54cc6b..1fef37d7a10 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -213,8 +213,7 @@ void EzoPMP::read_command_result_() { this->is_dosing_flag_ = parsed_third_parameter.value_or(0) == 1; #ifdef USE_BINARY_SENSOR - if (this->is_dosing_binary_sensor_) - this->is_dosing_binary_sensor_->publish_state(this->is_dosing_flag_); + PUBLISH_STATE(this->is_dosing_binary_sensor_, this->is_dosing_flag_); #endif #ifdef USE_SENSOR @@ -252,8 +251,7 @@ void EzoPMP::read_command_result_() { this->is_paused_flag_ = parsed_second_parameter.value_or(0) == 1; #ifdef USE_BINARY_SENSOR - if (this->is_paused_binary_sensor_) - this->is_paused_binary_sensor_->publish_state(this->is_paused_flag_); + PUBLISH_STATE(this->is_paused_binary_sensor_, this->is_paused_flag_); #endif break; @@ -323,8 +321,7 @@ void EzoPMP::read_command_result_() { case EZO_PMP_COMMAND_STOP_DOSING: // Stop (page 62) this->is_paused_flag_ = false; #ifdef USE_BINARY_SENSOR - if (this->is_paused_binary_sensor_) - this->is_paused_binary_sensor_->publish_state(this->is_paused_flag_); + PUBLISH_STATE(this->is_paused_binary_sensor_, this->is_paused_flag_); #endif #ifdef USE_TEXT_SENSOR publish_dosing_mode(this->dosing_mode_text_sensor_, DOSING_MODE_NONE); diff --git a/esphome/components/max31855/max31855.cpp b/esphome/components/max31855/max31855.cpp index d44b3026729..2407613c090 100644 --- a/esphome/components/max31855/max31855.cpp +++ b/esphome/components/max31855/max31855.cpp @@ -46,9 +46,7 @@ void MAX31855Sensor::read_data_() { } else { ESP_LOGE(TAG, "No data received from MAX31855 (0x%08" PRIX32 "). Check wiring!", mem); this->publish_state(NAN); - if (this->reference_sensor_) { - this->reference_sensor_->publish_state(NAN); - } + PUBLISH_STATE(this->reference_sensor_, NAN); this->status_set_error(); return; } diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index 8a636dacb5d..5ade1cac367 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -55,15 +55,9 @@ void MMC5983Component::update() { // data[4]: Zout[17:10] // data[5]: Zout[9:2] // data[6]: { Xout[1], Xout[0], Yout[1], Yout[0], Zout[1], Zout[0], 0, 0 } - if (this->x_sensor_) { - this->x_sensor_->publish_state(convert_data_to_millitesla(data[0], data[1], (data[6] & 0b11000000) >> 6)); - } - if (this->y_sensor_) { - this->y_sensor_->publish_state(convert_data_to_millitesla(data[2], data[3], (data[6] & 0b00110000) >> 4)); - } - if (this->z_sensor_) { - this->z_sensor_->publish_state(convert_data_to_millitesla(data[4], data[5], (data[6] & 0b00001100) >> 2)); - } + PUBLISH_STATE(this->x_sensor_, convert_data_to_millitesla(data[0], data[1], (data[6] & 0b11000000) >> 6)); + PUBLISH_STATE(this->y_sensor_, convert_data_to_millitesla(data[2], data[3], (data[6] & 0b00110000) >> 4)); + PUBLISH_STATE(this->z_sensor_, convert_data_to_millitesla(data[4], data[5], (data[6] & 0b00001100) >> 2)); } void MMC5983Component::setup() { diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index 6e3b158f2f6..82b3da1344a 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -285,9 +285,7 @@ void Pipsolar::handle_poll_error_(ENUMPollingCommand polling_command) { } void Pipsolar::handle_qpiri_(const char *message) { - if (this->last_qpiri_) { - this->last_qpiri_->publish_state(message); - } + PUBLISH_STATE(this->last_qpiri_, message); size_t pos = 0; this->skip_start_(message, &pos); @@ -325,17 +323,13 @@ void Pipsolar::handle_qpiri_(const char *message) { esphome::optional pv_ok_condition_for_parallel = parse_number(this->read_field_(message, &pos)); esphome::optional pv_power_balance = parse_number(this->read_field_(message, &pos)); - if (this->input_voltage_range_) { - this->input_voltage_range_->publish_state(int_or_nan(input_voltage_range)); - } + PUBLISH_STATE(this->input_voltage_range_, int_or_nan(input_voltage_range)); // special for input voltage range switch if (this->input_voltage_range_switch_ && input_voltage_range.has_value()) { this->input_voltage_range_switch_->publish_state(input_voltage_range.value() == 1); } - if (this->output_source_priority_) { - this->output_source_priority_->publish_state(int_or_nan(output_source_priority)); - } + PUBLISH_STATE(this->output_source_priority_, int_or_nan(output_source_priority)); // special for output source priority switches if (this->output_source_priority_utility_switch_ && output_source_priority.has_value()) { this->output_source_priority_utility_switch_->publish_state(output_source_priority.value() == 0); @@ -350,17 +344,13 @@ void Pipsolar::handle_qpiri_(const char *message) { this->output_source_priority_hybrid_switch_->publish_state(output_source_priority.value() == 3); } - if (this->pv_ok_condition_for_parallel_) { - this->pv_ok_condition_for_parallel_->publish_state(int_or_nan(pv_ok_condition_for_parallel)); - } + PUBLISH_STATE(this->pv_ok_condition_for_parallel_, int_or_nan(pv_ok_condition_for_parallel)); // special for pv ok condition switch if (this->pv_ok_condition_for_parallel_switch_ && pv_ok_condition_for_parallel.has_value()) { this->pv_ok_condition_for_parallel_switch_->publish_state(pv_ok_condition_for_parallel.value() == 1); } - if (this->pv_power_balance_) { - this->pv_power_balance_->publish_state(int_or_nan(pv_power_balance)); - } + PUBLISH_STATE(this->pv_power_balance_, int_or_nan(pv_power_balance)); // special for power balance switch if (this->pv_power_balance_switch_ && pv_power_balance.has_value()) { this->pv_power_balance_switch_->publish_state(pv_power_balance.value() == 1); @@ -368,9 +358,7 @@ void Pipsolar::handle_qpiri_(const char *message) { } void Pipsolar::handle_qpigs_(const char *message) { - if (this->last_qpigs_) { - this->last_qpigs_->publish_state(message); - } + PUBLISH_STATE(this->last_qpigs_, message); size_t pos = 0; this->skip_start_(message, &pos); @@ -408,9 +396,7 @@ void Pipsolar::handle_qpigs_(const char *message) { this->publish_binary_sensor_(this->get_bit_(device_status_1, 7), this->ac_charging_status_); esphome::optional battery_voltage_offset_for_fans_on = parse_number(this->read_field_(message, &pos)); - if (this->battery_voltage_offset_for_fans_on_) { - this->battery_voltage_offset_for_fans_on_->publish_state(int_or_nan(battery_voltage_offset_for_fans_on) / 10.0f); - } + PUBLISH_STATE(this->battery_voltage_offset_for_fans_on_, int_or_nan(battery_voltage_offset_for_fans_on) / 10.0f); this->read_int_sensor_(message, &pos, this->eeprom_version_); this->read_int_sensor_(message, &pos, this->pv_charging_power_); @@ -421,27 +407,19 @@ void Pipsolar::handle_qpigs_(const char *message) { } void Pipsolar::handle_qmod_(const char *message) { - if (this->last_qmod_) { - this->last_qmod_->publish_state(message); - } + PUBLISH_STATE(this->last_qmod_, message); // QMOD response is "(M" where M is the device-mode character. Bail out if the // message is shorter than 2 chars (e.g. empty error response from // handle_poll_error_) — reading message[1] would otherwise be out of bounds. if (message[0] == '\0' || message[1] == '\0') return; - if (this->device_mode_) { - std::string mode; - mode = char(message[1]); - this->device_mode_->publish_state(mode); - } + PUBLISH_STATE(this->device_mode_, &message[1], 1); } void Pipsolar::handle_qflag_(const char *message) { // result like:"(EbkuvxzDajy" // get through all char: ignore first "(" Enable flag on 'E', Disable on 'D') else set the corresponding value - if (this->last_qflag_) { - this->last_qflag_->publish_state(message); - } + PUBLISH_STATE(this->last_qflag_, message); QFLAGValues values = QFLAGValues(); bool enabled = true; @@ -498,9 +476,7 @@ void Pipsolar::handle_qflag_(const char *message) { void Pipsolar::handle_qpiws_(const char *message) { // '(00000000000000000000000000000000' // iterate over all available flag (as not all models have all flags, but at least in the same order) - if (this->last_qpiws_) { - this->last_qpiws_->publish_state(message); - } + PUBLISH_STATE(this->last_qpiws_, message); size_t pos = 0; this->skip_start_(message, &pos); @@ -667,23 +643,13 @@ void Pipsolar::handle_qpiws_(const char *message) { } } -void Pipsolar::handle_qt_(const char *message) { - if (this->last_qt_) { - this->last_qt_->publish_state(message); - } -} +void Pipsolar::handle_qt_(const char *message) { PUBLISH_STATE(this->last_qt_, message); } -void Pipsolar::handle_qmn_(const char *message) { - if (this->last_qmn_) { - this->last_qmn_->publish_state(message); - } -} +void Pipsolar::handle_qmn_(const char *message) { PUBLISH_STATE(this->last_qmn_, message); } // QET and QLT each answer with one integer void Pipsolar::handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value) { - if (raw) { - raw->publish_state(message); - } + PUBLISH_STATE(raw, message); size_t pos = 0; this->skip_start_(message, &pos); From c16f6d21ef36868c71f234811f72f58fd9a04b3d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:49:18 -1000 Subject: [PATCH 074/170] [sdp3x] Print the model once in dump_config and quiet the runtime logs (#22045) --- esphome/components/sdp3x/sdp3x.cpp | 129 +++++++++++------------------ esphome/components/sdp3x/sdp3x.h | 15 ++-- esphome/components/sdp3x/sensor.py | 9 +- 3 files changed, 57 insertions(+), 96 deletions(-) diff --git a/esphome/components/sdp3x/sdp3x.cpp b/esphome/components/sdp3x/sdp3x.cpp index 2f090108a69..38ecb179e31 100644 --- a/esphome/components/sdp3x/sdp3x.cpp +++ b/esphome/components/sdp3x/sdp3x.cpp @@ -1,109 +1,76 @@ #include "sdp3x.h" -#include "esphome/core/hal.h" -#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::sdp3x { ESPHOME_LOG_TAG(TAG, "sdp3x.sensor"); -static const uint16_t SDP3X_SOFT_RESET = 0x0006; -static const uint16_t SDP3X_READ_ID1 = 0x367C; -static const uint16_t SDP3X_READ_ID2 = 0xE102; -static const uint16_t SDP3X_START_DP_AVG = 0x3615; -static const uint16_t SDP3X_START_MASS_FLOW_AVG = 0x3603; -static const uint16_t SDP3X_STOP_MEAS = 0x3FF9; +static constexpr uint16_t SDP3X_SOFT_RESET = 0x0006; +static constexpr uint16_t SDP3X_READ_ID1 = 0x367C; +static constexpr uint16_t SDP3X_READ_ID2 = 0xE102; +static constexpr uint16_t SDP3X_STOP_MEAS = 0x3FF9; +// The first reading is ready about 8 ms after the start command +static constexpr uint32_t FIRST_READING_DELAY_MS = 10; -void SDP3XComponent::update() { this->read_pressure_(); } +// Product numbers from the SDP3x and SDP8xx datasheets +static const LogString *model_name(uint16_t product_id) { + switch (product_id) { + case 0x0101: + return LOG_STR("SDP31-500Pa"); + case 0x0102: + return LOG_STR("SDP32-125Pa"); + case 0x0201: + return LOG_STR("SDP800-500Pa"); + case 0x0202: + return LOG_STR("SDP800-125Pa"); + case 0x0204: + return LOG_STR("SDP801-500Pa"); + case 0x020A: + return LOG_STR("SDP810-500Pa"); + case 0x020B: + return LOG_STR("SDP810-125Pa"); + case 0x020D: + return LOG_STR("SDP811-500Pa"); + default: + return LOG_STR("unknown"); + } +} void SDP3XComponent::setup() { - if (!this->write_command(SDP3X_STOP_MEAS)) { - ESP_LOGW(TAG, "Stop failed"); // This sometimes fails for no good reason - } - - if (!this->write_command(SDP3X_SOFT_RESET)) { - ESP_LOGW(TAG, "Soft Reset failed"); // This sometimes fails for no good reason - } + // Either may be refused when the sensor is idle, so the results are ignored + this->write_command(SDP3X_STOP_MEAS); + this->write_command(SDP3X_SOFT_RESET); this->set_timeout(20, [this] { - if (!this->write_command(SDP3X_READ_ID1)) { - this->mark_failed(LOG_STR("Read ID1 failed")); - return; - } - if (!this->write_command(SDP3X_READ_ID2)) { - this->mark_failed(LOG_STR("Read ID2 failed")); - return; - } - uint16_t data[6]; - if (!this->read_data(data, 6)) { - this->mark_failed(LOG_STR("Read ID failed")); + if (!this->write_command(SDP3X_READ_ID1) || !this->get_register(SDP3X_READ_ID2, data, 6) || + !this->write_command(this->start_command_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } - - // SDP8xx - // ref: - // https://www.sensirion.com/fileadmin/user_upload/customers/sensirion/Dokumente/8_Differential_Pressure/Datasheets/Sensirion_Differential_Pressure_Datasheet_SDP8xx_Digital.pdf - if (data[1] >> 8 == 0x02) { - switch (data[1] & 0xFF) { - case 0x01: // SDP800-500Pa - ESP_LOGCONFIG(TAG, "Sensor is SDP800-500Pa"); - break; - case 0x0A: // SDP810-500Pa - ESP_LOGCONFIG(TAG, "Sensor is SDP810-500Pa"); - break; - case 0x04: // SDP801-500Pa - ESP_LOGCONFIG(TAG, "Sensor is SDP801-500Pa"); - break; - case 0x0D: // SDP811-500Pa - ESP_LOGCONFIG(TAG, "Sensor is SDP811-500Pa"); - break; - case 0x02: // SDP800-125Pa - ESP_LOGCONFIG(TAG, "Sensor is SDP800-125Pa"); - break; - case 0x0B: // SDP810-125Pa - ESP_LOGCONFIG(TAG, "Sensor is SDP810-125Pa"); - break; - } - } else if (data[1] >> 8 == 0x01) { - if ((data[1] & 0xFF) == 0x01) { - ESP_LOGCONFIG(TAG, "Sensor is SDP31-500Pa"); - } else if ((data[1] & 0xFF) == 0x02) { - ESP_LOGCONFIG(TAG, "Sensor is SDP32-125Pa"); - } - } - - if (!this->write_command(measurement_mode_ == DP_AVG ? SDP3X_START_DP_AVG : SDP3X_START_MASS_FLOW_AVG)) { - this->mark_failed(LOG_STR("Start Measurements failed")); - return; - } - ESP_LOGCONFIG(TAG, "started"); + this->product_id_ = data[1]; + this->set_timeout(FIRST_READING_DELAY_MS, [this] { this->update(); }); }); } + void SDP3XComponent::dump_config() { LOG_SENSOR(" ", "SDP3X", this); + if (this->product_id_ != 0) { + ESP_LOGCONFIG(TAG, " Model: %s (0x%04X)", LOG_STR_ARG(model_name(this->product_id_)), this->product_id_); + } LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); } -void SDP3XComponent::read_pressure_() { +void SDP3XComponent::update() { uint16_t data[3]; - if (!this->read_data(data, 3)) { - ESP_LOGW(TAG, "Couldn't read data"); - this->status_set_warning(); + if (!this->status_comm_result(this->read_data(data, 3))) return; - } - - int16_t pressure_raw = data[0]; - int16_t temperature_raw = data[1]; - int16_t scale_factor_raw = data[2]; - // scale factor is in Pa - convert to hPa - float pressure = pressure_raw / (scale_factor_raw * 100.0f); - ESP_LOGV(TAG, "Got raw pressure=%d, raw scale factor =%d, raw temperature=%d ", pressure_raw, scale_factor_raw, - temperature_raw); - ESP_LOGD(TAG, "Got Pressure=%.3f hPa", pressure); - + const auto pressure_raw = static_cast(data[0]); + const auto scale_factor = static_cast(data[2]); + // The scale factor is per Pa; publish hPa + const float pressure = pressure_raw / (scale_factor * 100.0f); + ESP_LOGV(TAG, "Pressure %.3f hPa (raw %d, scale %d)", pressure, pressure_raw, scale_factor); this->publish_state(pressure); - this->status_clear_warning(); } } // namespace esphome::sdp3x diff --git a/esphome/components/sdp3x/sdp3x.h b/esphome/components/sdp3x/sdp3x.h index 19c8d0f6787..f4c017e74de 100644 --- a/esphome/components/sdp3x/sdp3x.h +++ b/esphome/components/sdp3x/sdp3x.h @@ -1,29 +1,24 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" namespace esphome::sdp3x { -enum MeasurementMode { MASS_FLOW_AVG, DP_AVG }; - class SDP3XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice, public sensor::Sensor { public: - /// Schedule temperature+pressure readings. + /// start_command selects the measurement mode (differential pressure or mass flow). + explicit SDP3XComponent(uint16_t start_command) : start_command_(start_command) {} + void update() override; - /// Setup the sensor and test for a connection. void setup() override; void dump_config() override; - void set_measurement_mode(MeasurementMode mode) { measurement_mode_ = mode; } - protected: - /// Internal method to read the pressure from the component after it has been scheduled. - void read_pressure_(); - MeasurementMode measurement_mode_; + uint16_t start_command_; + uint16_t product_id_{0}; }; } // namespace esphome::sdp3x diff --git a/esphome/components/sdp3x/sensor.py b/esphome/components/sdp3x/sensor.py index 8a76ba4b130..48702e5ea40 100644 --- a/esphome/components/sdp3x/sensor.py +++ b/esphome/components/sdp3x/sensor.py @@ -22,10 +22,10 @@ SDP3XComponent = sdp3x_ns.class_( ) -MeasurementMode = sdp3x_ns.enum("MeasurementMode") +# The start command for each mode, with averaging MEASUREMENT_MODE = { - "mass_flow": MeasurementMode.MASS_FLOW_AVG, - "differential_pressure": MeasurementMode.DP_AVG, + "mass_flow": 0x3603, + "differential_pressure": 0x3615, } @@ -50,7 +50,6 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - var = await sensor.new_sensor(config) + var = await sensor.new_sensor(config, config[CONF_MEASUREMENT_MODE]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) From 4027857ab2c479c6744ca3bbe3eeb46a00690afd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:52:35 -1000 Subject: [PATCH 075/170] [cst816][ft5x06] Remove unused button listener declarations (#22065) --- esphome/components/cst816/touchscreen/__init__.py | 2 -- esphome/components/cst816/touchscreen/cst816_touchscreen.h | 5 ----- esphome/components/ft5x06/touchscreen/__init__.py | 1 - 3 files changed, 8 deletions(-) diff --git a/esphome/components/cst816/touchscreen/__init__.py b/esphome/components/cst816/touchscreen/__init__.py index 88d5de6ad1a..1f2a5da6260 100644 --- a/esphome/components/cst816/touchscreen/__init__.py +++ b/esphome/components/cst816/touchscreen/__init__.py @@ -13,8 +13,6 @@ CST816Touchscreen = cst816_ns.class_( i2c.I2CDevice, ) -CST816ButtonListener = cst816_ns.class_("CST816ButtonListener") - CONF_SKIP_PROBE = "skip_probe" CONFIG_SCHEMA = touchscreen.TOUCHSCREEN_SCHEMA.extend( { diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 72dda4e9854..b4e1fd9f6a0 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -32,11 +32,6 @@ static const uint8_t CST816D_CHIP_ID = 0xB6; static const uint8_t CST816T_CHIP_ID = 0xB5; static const uint8_t CST716_CHIP_ID = 0x20; -class CST816ButtonListener { - public: - virtual void update_button(bool state) = 0; -}; - class CST816Touchscreen final : public touchscreen::Touchscreen, public i2c::I2CDevice { public: void setup() override; diff --git a/esphome/components/ft5x06/touchscreen/__init__.py b/esphome/components/ft5x06/touchscreen/__init__.py index b56043ec4d7..16d65f0b64e 100644 --- a/esphome/components/ft5x06/touchscreen/__init__.py +++ b/esphome/components/ft5x06/touchscreen/__init__.py @@ -7,7 +7,6 @@ from esphome.types import ConfigType from .. import ft5x06_ns -FT5x06ButtonListener = ft5x06_ns.class_("FT5x06ButtonListener") FT5x06Touchscreen = ft5x06_ns.class_( "FT5x06Touchscreen", touchscreen.Touchscreen, From 3ac882b0973a204b74f863297407e75dcbf00b98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:58:23 -1000 Subject: [PATCH 076/170] [sensor] Test delta filter with min and max together and a percentage max (#21812) --- .../fixtures/sensor_filters_delta.yaml | 130 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 69 +++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml index b01c8e452bc..e010045a5f3 100644 --- a/tests/integration/fixtures/sensor_filters_delta.yaml +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -38,6 +38,25 @@ sensor: id: source_sensor_6 accuracy_decimals: 1 + - platform: template + name: "Source Sensor 7" + id: source_sensor_7 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 8" + id: source_sensor_8 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 9" + id: source_sensor_9 + accuracy_decimals: 1 + + - platform: template + name: "Inf Done" + id: inf_done + - platform: copy source_id: source_sensor_1 name: "Filter Min" @@ -93,6 +112,33 @@ sensor: filters: - delta: 0 + - platform: copy + source_id: source_sensor_7 + name: "Filter Min Max" + id: filter_min_max + filters: + - delta: + min_value: 2 + max_value: 10 + + - platform: copy + source_id: source_sensor_8 + name: "Filter Percentage Max" + id: filter_percentage_max + filters: + - delta: + max_value: 50% + + # After an infinite value, an absolute min with a percentage max drops every later value + - platform: copy + source_id: source_sensor_9 + name: "Filter Inf Reference" + id: filter_inf_reference + filters: + - delta: + min_value: 1 + max_value: 50% + script: - id: test_filter_min then: @@ -218,6 +264,72 @@ script: id: source_sensor_6 state: 2.0 + - id: test_filter_min_max + then: + - sensor.template.publish: + id: source_sensor_7 + state: 0.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_7 + state: 1.0 # Filtered out (delta=1, need >2) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_7 + state: 2.0 # Filtered out (delta=2, need >2) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_7 + state: 12.0 # Filtered out (delta=12, need <=10) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_7 + state: 10.0 # Passes (delta=10) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_7 + state: 15.0 + + - id: test_filter_percentage_max + then: + - sensor.template.publish: + id: source_sensor_8 + state: 100.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_8 + state: 160.0 # Filtered out (delta=60, need <=50) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_8 + state: 150.0 # Passes (delta=50) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_8 + state: 150.0 # Filtered out (delta=0, need >0) + - delay: 20ms + - sensor.template.publish: + id: source_sensor_8 + state: 75.0 # Passes (delta=75 <= 50% of 150) + + - id: test_filter_inf_reference + then: + - sensor.template.publish: + id: source_sensor_9 + state: !lambda "return INFINITY;" + - delay: 20ms + - sensor.template.publish: + id: source_sensor_9 + state: 5.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_9 + state: 100.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: inf_done + state: 1.0 + button: - platform: template name: "Test Filter Min" @@ -254,3 +366,21 @@ button: id: btn_filter_nan on_press: - script.execute: test_filter_nan + + - platform: template + name: "Test Filter Min Max" + id: btn_filter_min_max + on_press: + - script.execute: test_filter_min_max + + - platform: template + name: "Test Filter Percentage Max" + id: btn_filter_percentage_max + on_press: + - script.execute: test_filter_percentage_max + + - platform: template + name: "Test Filter Inf Reference" + id: btn_filter_inf_reference + on_press: + - script.execute: test_filter_inf_reference diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py index dd9c9061140..fe21671f19b 100644 --- a/tests/integration/test_sensor_filters_delta.py +++ b/tests/integration/test_sensor_filters_delta.py @@ -28,6 +28,9 @@ async def test_sensor_filters_delta( "filter_zero_delta": [], "filter_percentage": [], "filter_nan": [], + "filter_min_max": [], + "filter_percentage_max": [], + "filter_inf_reference": [], } filter_min_done = loop.create_future() @@ -36,12 +39,17 @@ async def test_sensor_filters_delta( filter_zero_delta_done = loop.create_future() filter_percentage_done = loop.create_future() filter_nan_done = loop.create_future() + filter_min_max_done = loop.create_future() + filter_percentage_max_done = loop.create_future() + inf_done = loop.create_future() def on_state(state: EntityState) -> None: if not isinstance(state, SensorState): return sensor_name = key_to_sensor.get(state.key) + if sensor_name == "inf_done" and not inf_done.done(): + inf_done.set_result(True) if sensor_name not in sensor_values: return @@ -90,6 +98,18 @@ async def test_sensor_filters_delta( and not filter_nan_done.done() ): filter_nan_done.set_result(True) + elif ( + sensor_name == "filter_min_max" + and len(sensor_values[sensor_name]) == 3 + and not filter_min_max_done.done() + ): + filter_min_max_done.set_result(True) + elif ( + sensor_name == "filter_percentage_max" + and len(sensor_values[sensor_name]) == 3 + and not filter_percentage_max_done.done() + ): + filter_percentage_max_done.set_result(True) async with ( run_compiled(yaml_config), @@ -106,6 +126,10 @@ async def test_sensor_filters_delta( "filter_zero_delta": "Filter Zero Delta", "filter_percentage": "Filter Percentage", "filter_nan": "Filter NaN", + "filter_min_max": "Filter Min Max", + "filter_percentage_max": "Filter Percentage Max", + "filter_inf_reference": "Filter Inf Reference", + "inf_done": "Inf Done", }, ) @@ -120,13 +144,16 @@ async def test_sensor_filters_delta( "Test Filter Zero Delta": "filter_zero_delta", "Test Filter Percentage": "filter_percentage", "Test Filter NaN": "filter_nan", + "Test Filter Min Max": "filter_min_max", + "Test Filter Percentage Max": "filter_percentage_max", + "Test Filter Inf Reference": "filter_inf_reference", } buttons = {} for entity in entities: if isinstance(entity, ButtonInfo) and entity.name in button_name_map: buttons[button_name_map[entity.name]] = entity.key - assert len(buttons) == 6, f"Expected 6 buttons, found {len(buttons)}" + assert len(buttons) == 9, f"Expected 9 buttons, found {len(buttons)}" # Test 1: Min sensor_values["filter_min"].clear() @@ -210,3 +237,43 @@ async def test_sensor_filters_delta( f"Test 6 failed: NaN not passed through, got {values}" ) assert values[2] == pytest.approx(2.0), f"Test 6 failed: got {values}" + + # Test 7: Min and max together; min is exclusive, max inclusive + sensor_values["filter_min_max"].clear() + client.button_command(buttons["filter_min_max"]) + await wait_or_fail( + filter_min_max_done, + 2.0, + "Test 7 timed out", + values=sensor_values["filter_min_max"], + ) + + expected = [0.0, 10.0, 15.0] + assert sensor_values["filter_min_max"] == pytest.approx(expected), ( + f"Test 7 failed: expected {expected}, got {sensor_values['filter_min_max']}" + ) + + # Test 8: Percentage max (max_value: 50%) + sensor_values["filter_percentage_max"].clear() + client.button_command(buttons["filter_percentage_max"]) + await wait_or_fail( + filter_percentage_max_done, + 2.0, + "Test 8 timed out", + values=sensor_values["filter_percentage_max"], + ) + + expected = [100.0, 150.0, 75.0] + assert sensor_values["filter_percentage_max"] == pytest.approx(expected), ( + f"Test 8 failed: expected {expected}, got {sensor_values['filter_percentage_max']}" + ) + + # Test 9: after an infinite value, absolute min + percentage max drops everything + sensor_values["filter_inf_reference"].clear() + client.button_command(buttons["filter_inf_reference"]) + await wait_or_fail(inf_done, 2.0, "Test 9 timed out") + + values = sensor_values["filter_inf_reference"] + assert len(values) == 1 and values[0] == math.inf, ( + f"Test 9 failed: got {values}" + ) From 84b4ffeddba3e4dacc3987eee78b23a41e441779 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:58:57 -1000 Subject: [PATCH 077/170] [binary_sensor] Put the settle filter flag in the base class padding (#21800) --- esphome/components/binary_sensor/filter.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index b5825b8fbb5..f8128288ca8 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -162,8 +162,8 @@ class SettleFilter : public Filter { protected: void reset() override; + bool steady_{true}; // Before delay_ so it fills the Filter base's tail padding TemplatableFn delay_{}; - bool steady_{true}; }; } // namespace esphome::binary_sensor From 9317c021f542d4234eee4b86f5fe730e608ea6ad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:59:52 -1000 Subject: [PATCH 078/170] [interval] Compile the startup delay out of intervals that don't use it (#21799) --- esphome/components/interval/__init__.py | 8 +++- esphome/components/interval/interval.h | 18 +++++---- tests/component_tests/interval/__init__.py | 0 .../interval/config/interval_test.yaml | 24 ++++++++++++ .../component_tests/interval/test_interval.py | 18 +++++++++ tests/components/interval/common.yaml | 4 ++ .../fixtures/interval_startup_delay.yaml | 37 +++++++++++++++++++ .../test_interval_startup_delay.py | 36 ++++++++++++++++++ 8 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/interval/__init__.py create mode 100644 tests/component_tests/interval/config/interval_test.yaml create mode 100644 tests/component_tests/interval/test_interval.py create mode 100644 tests/integration/fixtures/interval_startup_delay.yaml create mode 100644 tests/integration/test_interval_startup_delay.py diff --git a/esphome/components/interval/__init__.py b/esphome/components/interval/__init__.py index adbe4349c66..4d3c01bca39 100644 --- a/esphome/components/interval/__init__.py +++ b/esphome/components/interval/__init__.py @@ -10,6 +10,7 @@ interval_ns = cg.esphome_ns.namespace("interval") IntervalTrigger = interval_ns.class_( "IntervalTrigger", automation.Trigger.template(), cg.PollingComponent ) +DelayedIntervalTrigger = interval_ns.class_("DelayedIntervalTrigger", IntervalTrigger) CONFIG_SCHEMA = automation.validate_automation( cv.Schema( @@ -26,9 +27,12 @@ CONFIG_SCHEMA = automation.validate_automation( async def to_code(config: list[ConfigType]) -> None: for conf in config: - var = cg.new_Pvariable(conf[CONF_ID]) + if startup_delay := conf[CONF_STARTUP_DELAY].total_milliseconds: + conf[CONF_ID].type = DelayedIntervalTrigger + var = cg.new_Pvariable(conf[CONF_ID], startup_delay) + else: + var = cg.new_Pvariable(conf[CONF_ID]) await cg.register_component(var, conf) await automation.build_automation(var, [], conf) cg.add(var.set_update_interval(conf[CONF_INTERVAL])) - cg.add(var.set_startup_delay(conf[CONF_STARTUP_DELAY])) diff --git a/esphome/components/interval/interval.h b/esphome/components/interval/interval.h index fd59d2a4880..604d5a98505 100644 --- a/esphome/components/interval/interval.h +++ b/esphome/components/interval/interval.h @@ -6,21 +6,23 @@ namespace esphome::interval { -class IntervalTrigger final : public Trigger<>, public PollingComponent { +class IntervalTrigger : public Trigger<>, public PollingComponent { public: void update() override { this->trigger(); } +}; + +/// An interval whose first run waits for a startup delay; only used when the delay is not zero. +class DelayedIntervalTrigger final : public IntervalTrigger { + public: + explicit DelayedIntervalTrigger(uint32_t startup_delay) : startup_delay_(startup_delay) {} void setup() override { - if (this->startup_delay_ != 0) { - this->stop_poller(); - this->set_timeout(this->startup_delay_, [this] { this->start_poller(); }); - } + this->stop_poller(); + this->set_timeout(this->startup_delay_, [this] { this->start_poller(); }); } - void set_startup_delay(const uint32_t startup_delay) { this->startup_delay_ = startup_delay; } - protected: - uint32_t startup_delay_{0}; + const uint32_t startup_delay_; }; } // namespace esphome::interval diff --git a/tests/component_tests/interval/__init__.py b/tests/component_tests/interval/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/interval/config/interval_test.yaml b/tests/component_tests/interval/config/interval_test.yaml new file mode 100644 index 00000000000..972e70a007a --- /dev/null +++ b/tests/component_tests/interval/config/interval_test.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +esp32: + board: esp32dev + +interval: + - id: no_delay + interval: 1s + then: + - logger.log: Tick + - id: zero_delay + interval: 3s + startup_delay: 0s + then: + - logger.log: Tick with zero delay + - id: delayed + interval: 2s + startup_delay: 5s + then: + - logger.log: Tick after startup delay + - lambda: id(no_delay)->stop_poller(); + +logger: diff --git a/tests/component_tests/interval/test_interval.py b/tests/component_tests/interval/test_interval.py new file mode 100644 index 00000000000..9daaa998e6e --- /dev/null +++ b/tests/component_tests/interval/test_interval.py @@ -0,0 +1,18 @@ +"""Tests for the interval component.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_interval_startup_delay_class_per_entry( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Only an interval with a non-zero startup delay uses DelayedIntervalTrigger.""" + main_cpp = generate_main(component_config_path("interval_test.yaml")) + + assert "new(no_delay) interval::IntervalTrigger();" in main_cpp + assert "new(zero_delay) interval::IntervalTrigger();" in main_cpp + assert "new(delayed) interval::DelayedIntervalTrigger(5000);" in main_cpp + assert "static interval::DelayedIntervalTrigger *const delayed = " in main_cpp + assert "set_startup_delay" not in main_cpp diff --git a/tests/components/interval/common.yaml b/tests/components/interval/common.yaml index 2a3c979ae24..dc425bdc29f 100644 --- a/tests/components/interval/common.yaml +++ b/tests/components/interval/common.yaml @@ -2,3 +2,7 @@ interval: - interval: 1s then: - logger.log: Tick + - interval: 2s + startup_delay: 5s + then: + - logger.log: Tick after startup delay diff --git a/tests/integration/fixtures/interval_startup_delay.yaml b/tests/integration/fixtures/interval_startup_delay.yaml new file mode 100644 index 00000000000..ddbe4a21566 --- /dev/null +++ b/tests/integration/fixtures/interval_startup_delay.yaml @@ -0,0 +1,37 @@ +esphome: + name: interval-startup-delay + +host: +api: +logger: + level: DEBUG + +globals: + - id: plain_count + type: int + initial_value: "0" + - id: delayed_count + type: int + initial_value: "0" + +interval: + - id: plain_interval + interval: 100ms + then: + - lambda: |- + id(plain_count) += 1; + if (id(plain_count) == 1) { + ESP_LOGI("test", "PLAIN_FIRST uptime=%u", (unsigned) millis()); + } + - id: delayed_interval + interval: 100ms + startup_delay: 1500ms + then: + - lambda: |- + id(delayed_count) += 1; + if (id(delayed_count) == 1) { + // Lambdas reach the delayed interval through its IntervalTrigger base. + interval::IntervalTrigger *base = id(delayed_interval); + ESP_LOGI("test", "DELAYED_FIRST uptime=%u plain_count=%d update_interval=%u", (unsigned) millis(), + id(plain_count), (unsigned) base->get_update_interval()); + } diff --git a/tests/integration/test_interval_startup_delay.py b/tests/integration/test_interval_startup_delay.py new file mode 100644 index 00000000000..2e3bc803cb9 --- /dev/null +++ b/tests/integration/test_interval_startup_delay.py @@ -0,0 +1,36 @@ +"""Test that only an interval with a startup delay waits before its first run.""" + +from __future__ import annotations + +import re + +import pytest + +from .log_utils import LineWaiter +from .types import RunCompiledFunction + +PLAIN_FIRST = re.compile(r"PLAIN_FIRST uptime=(\d+)") +DELAYED_FIRST = re.compile( + r"DELAYED_FIRST uptime=(\d+) plain_count=(\d+) update_interval=(\d+)" +) + + +@pytest.mark.asyncio +async def test_interval_startup_delay( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """The plain interval runs from boot; the delayed one starts after 1500 ms.""" + waiter = LineWaiter() + plain_future = waiter.expect(PLAIN_FIRST) + delayed_future = waiter.expect(DELAYED_FIRST) + + async with run_compiled(yaml_config, line_callback=waiter.callback): + plain = await waiter.wait(plain_future) + delayed = await waiter.wait(delayed_future) + + # The plain interval first runs within one 100 ms period of boot. + assert int(delayed.group(1)) - int(plain.group(1)) >= 1300 + # The plain interval kept running every 100 ms while the delayed one waited. + assert int(delayed.group(2)) >= 10 + assert int(delayed.group(3)) == 100 From a4ed37bd791406fb1b892c4d209ae6fb12c43e52 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:00:08 -1000 Subject: [PATCH 079/170] [canbus] Add an integration test for on_frame filtering (#21792) --- .../integration/fixtures/canbus_on_frame.yaml | 102 ++++++++++++++++++ .../canbus_mock/__init__.py | 1 + .../external_components/canbus_mock/canbus.py | 19 ++++ .../canbus_mock/canbus_mock.cpp | 22 ++++ .../canbus_mock/canbus_mock.h | 23 ++++ tests/integration/test_canbus_on_frame.py | 47 ++++++++ 6 files changed, 214 insertions(+) create mode 100644 tests/integration/fixtures/canbus_on_frame.yaml create mode 100644 tests/integration/fixtures/external_components/canbus_mock/__init__.py create mode 100644 tests/integration/fixtures/external_components/canbus_mock/canbus.py create mode 100644 tests/integration/fixtures/external_components/canbus_mock/canbus_mock.cpp create mode 100644 tests/integration/fixtures/external_components/canbus_mock/canbus_mock.h create mode 100644 tests/integration/test_canbus_on_frame.py diff --git a/tests/integration/fixtures/canbus_on_frame.yaml b/tests/integration/fixtures/canbus_on_frame.yaml new file mode 100644 index 00000000000..676ed1dc253 --- /dev/null +++ b/tests/integration/fixtures/canbus_on_frame.yaml @@ -0,0 +1,102 @@ +esphome: + name: canbus-on-frame-test + on_boot: + priority: -100 + then: + - canbus.send: + can_id: 0x100 + data: [0x01] + - canbus.send: + can_id: 0x100 + use_extended_id: true + data: [0x02] + - canbus.send: + can_id: 0x125 + data: [0x03] + - canbus.send: + can_id: 0x135 + data: [0x04] + - canbus.send: + can_id: 0x125 + use_extended_id: true + data: [0x05] + - canbus.send: + can_id: 0x200 + remote_transmission_request: true + data: [] + - canbus.send: + can_id: 0x200 + data: [0x07, 0x77] + - canbus.send: + can_id: 0x1ABCDE42 + use_extended_id: true + data: [0x08] + - canbus.send: + can_id: 0x1ABCDF42 + use_extended_id: true + data: [0x09] + - canbus.send: + can_id: 0x7FF + data: [0xAA] + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +canbus: + - platform: canbus_mock + id: mock_can + can_id: 0x10 + on_frame: + - can_id: 0x100 + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame std_100 id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); + - can_id: 0x100 + use_extended_id: true + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame ext_100 id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); + - can_id: 0x120 + can_id_mask: 0x7F0 + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame std_12x id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); + - can_id: 0x200 + remote_transmission_request: true + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame rtr_200 id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); + - can_id: 0x200 + remote_transmission_request: false + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame data_200 id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); + - can_id: 0x200 + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame any_200 id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); + - can_id: 0x1ABCDE00 + can_id_mask: 0x1FFFFF00 + use_extended_id: true + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame ext_masked id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); + - can_id: 0x7FF + then: + - lambda: |- + ESP_LOGI("can_test", "on_frame done id=0x%" PRIX32 " rtr=%d len=%u first=0x%02X", can_id, + remote_transmission_request, (unsigned) x.size(), x.empty() ? 0 : x[0]); diff --git a/tests/integration/fixtures/external_components/canbus_mock/__init__.py b/tests/integration/fixtures/external_components/canbus_mock/__init__.py new file mode 100644 index 00000000000..e8cfff8e1fb --- /dev/null +++ b/tests/integration/fixtures/external_components/canbus_mock/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/tests"] diff --git a/tests/integration/fixtures/external_components/canbus_mock/canbus.py b/tests/integration/fixtures/external_components/canbus_mock/canbus.py new file mode 100644 index 00000000000..50208040565 --- /dev/null +++ b/tests/integration/fixtures/external_components/canbus_mock/canbus.py @@ -0,0 +1,19 @@ +"""Host-only canbus platform that loops sent frames back as received frames.""" + +import esphome.codegen as cg +from esphome.components import canbus +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +canbus_mock_ns = cg.esphome_ns.namespace("canbus_mock") +MockCanbus = canbus_mock_ns.class_("MockCanbus", canbus.CanbusComponent) + +CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(MockCanbus)} +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await canbus.register_canbus(var, config, reports_status=False) diff --git a/tests/integration/fixtures/external_components/canbus_mock/canbus_mock.cpp b/tests/integration/fixtures/external_components/canbus_mock/canbus_mock.cpp new file mode 100644 index 00000000000..2aa62eaa9c9 --- /dev/null +++ b/tests/integration/fixtures/external_components/canbus_mock/canbus_mock.cpp @@ -0,0 +1,22 @@ +#include "canbus_mock.h" + +namespace esphome::canbus_mock { + +canbus::Error MockCanbus::send_message(canbus::CanFrame *frame) { + if (this->count_ == QUEUE_SIZE) + return canbus::ERROR_ALLTXBUSY; + this->queue_[(this->head_ + this->count_) % QUEUE_SIZE] = *frame; + this->count_++; + return canbus::ERROR_OK; +} + +canbus::Error MockCanbus::read_message(canbus::CanFrame *frame) { + if (this->count_ == 0) + return canbus::ERROR_NOMSG; + *frame = this->queue_[this->head_]; + this->head_ = (this->head_ + 1) % QUEUE_SIZE; + this->count_--; + return canbus::ERROR_OK; +} + +} // namespace esphome::canbus_mock diff --git a/tests/integration/fixtures/external_components/canbus_mock/canbus_mock.h b/tests/integration/fixtures/external_components/canbus_mock/canbus_mock.h new file mode 100644 index 00000000000..79db16e29e8 --- /dev/null +++ b/tests/integration/fixtures/external_components/canbus_mock/canbus_mock.h @@ -0,0 +1,23 @@ +#pragma once + +// HOST-ONLY TEST COMPONENT: every sent frame is queued and read back as a received frame. + +#include "esphome/components/canbus/canbus.h" + +#include + +namespace esphome::canbus_mock { + +class MockCanbus : public canbus::Canbus { + protected: + bool setup_internal() override { return true; } + canbus::Error send_message(canbus::CanFrame *frame) override; + canbus::Error read_message(canbus::CanFrame *frame) override; + + static constexpr size_t QUEUE_SIZE = 16; + std::array queue_{}; + size_t head_{0}; + size_t count_{0}; +}; + +} // namespace esphome::canbus_mock diff --git a/tests/integration/test_canbus_on_frame.py b/tests/integration/test_canbus_on_frame.py new file mode 100644 index 00000000000..15bacf59e8d --- /dev/null +++ b/tests/integration/test_canbus_on_frame.py @@ -0,0 +1,47 @@ +"""canbus on_frame matching on can_id, can_id_mask, use_extended_id and remote_transmission_request. + +A host-only canbus platform reads every sent frame back, so the frames sent at boot +reach the on_frame automations, which log the arguments their lambdas receive. +""" + +from __future__ import annotations + +import re + +import pytest + +from .log_utils import LineWaiter +from .types import RunCompiledFunction + +ON_FRAME = re.compile( + r"on_frame (\w+) (id=0x[0-9A-F]+ rtr=\d len=\d+ first=0x[0-9A-F]{2})" +) + +# (entry, can_id rtr len first) in the order the device must log them +EXPECTED = [ + ("std_100", "id=0x100 rtr=0 len=1 first=0x01"), + ("ext_100", "id=0x100 rtr=0 len=1 first=0x02"), + ("std_12x", "id=0x125 rtr=0 len=1 first=0x03"), + # std 0x135 is outside the mask and ext 0x125 is not a standard frame: nothing fires + ("rtr_200", "id=0x200 rtr=1 len=0 first=0x00"), + ("any_200", "id=0x200 rtr=1 len=0 first=0x00"), + ("data_200", "id=0x200 rtr=0 len=2 first=0x07"), + ("any_200", "id=0x200 rtr=0 len=2 first=0x07"), + # can_id is the full received id, not the masked one; 0x1ABCDF42 misses the mask + ("ext_masked", "id=0x1ABCDE42 rtr=0 len=1 first=0x08"), + ("done", "id=0x7FF rtr=0 len=1 first=0xAA"), +] + + +@pytest.mark.asyncio +async def test_canbus_on_frame( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """Each frame fires exactly the on_frame entries whose filters match, in config order.""" + waiter = LineWaiter() + async with run_compiled(yaml_config, line_callback=waiter.callback): + await waiter.wait_count("on_frame done", 1, 10, "last frame not received") + + seen = [m.groups() for line in waiter.lines if (m := ON_FRAME.search(line))] + assert seen == EXPECTED From eec1a0a690838b82ac154ff0ef45bd8494704d73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:02:00 -1000 Subject: [PATCH 080/170] [core] Add StringRef::find for std::string and StringRef needles (#21895) --- esphome/core/string_ref.h | 22 +++++++++------ tests/components/core/test_string_ref.cpp | 33 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index ffebf09a220..e93cef14ecb 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -117,17 +117,23 @@ class StringRef { int compare(const char *s) const { return compare(StringRef(s)); } int compare(const std::string &s) const { return compare(StringRef(s)); } - /// Find first occurrence of substring, returns std::string::npos if not found. - /// Note: Requires the underlying string to be null-terminated. - size_type find(const char *s, size_type pos = 0) const { - if (pos >= len_) + /// Find first occurrence of a substring, returns std::string::npos if not found. + /// Bounded by the lengths, so the view does not need a null terminator. + size_type find(const StringRef &s, size_type pos = 0) const { + if (pos > len_) return std::string::npos; - const char *result = std::strstr(base_ + pos, s); - // Verify entire match is within bounds (strstr searches to null terminator) - if (result && result + std::strlen(s) <= base_ + len_) - return static_cast(result - base_); + if (s.len_ == 0) + return pos; + if (s.len_ > len_ - pos) + return std::string::npos; + for (size_type i = pos, last = len_ - s.len_; i <= last; i++) { + if (std::memcmp(base_ + i, s.base_, s.len_) == 0) + return i; + } return std::string::npos; } + size_type find(const char *s, size_type pos = 0) const { return this->find(StringRef(s), pos); } + size_type find(const std::string &s, size_type pos = 0) const { return this->find(StringRef(s), pos); } size_type find(char c, size_type pos = 0) const { if (pos >= len_) return std::string::npos; diff --git a/tests/components/core/test_string_ref.cpp b/tests/components/core/test_string_ref.cpp index f66800dd178..d6cb031a408 100644 --- a/tests/components/core/test_string_ref.cpp +++ b/tests/components/core/test_string_ref.cpp @@ -85,6 +85,7 @@ TEST(StringRefNullEmpty, BehavesAsEmptyString) { EXPECT_EQ(null_empty.substr(0), std::string()); EXPECT_EQ(null_empty.find('a'), std::string::npos); EXPECT_EQ(null_empty.find("a"), std::string::npos); + EXPECT_EQ(null_empty.find(std::string("a")), std::string::npos); char buf[4] = "xyz"; EXPECT_EQ(null_empty.copy(buf, sizeof(buf)), 0u); EXPECT_EQ(null_empty.begin(), null_empty.end()); @@ -161,4 +162,36 @@ TEST(StringRefNullEmpty, NumericConversionsReturnZero) { EXPECT_EQ(stof(null_empty), 0.0f); } +TEST(StringRefFind, FindsStdStringAndStringRef) { + const StringRef ref("Home Assistant 2026.10.0", 24); + EXPECT_EQ(ref.find(std::string("Home Assistant")), 0u); + EXPECT_EQ(ref.find(std::string("Assistant")), 5u); + EXPECT_EQ(ref.find(std::string("Assistant"), 6), std::string::npos); + EXPECT_EQ(ref.find(std::string("aioesphomeapi")), std::string::npos); + EXPECT_EQ(ref.find(StringRef("2026", 4)), 15u); + // Matches std::string for an empty needle + EXPECT_EQ(ref.find(std::string()), 0u); + EXPECT_EQ(ref.find(std::string(), 24), 24u); + EXPECT_EQ(ref.find(std::string(), 25), std::string::npos); +} + +TEST(StringRefFind, StaysWithinLength) { + // The view ends before "def", so a match past its length is not found + const char *text = "abcdef"; + const StringRef ref(text, 3); + EXPECT_EQ(ref.find(std::string("cd")), std::string::npos); + EXPECT_EQ(ref.find(StringRef("bc", 2)), 1u); + // The needle does not need a null terminator either + EXPECT_EQ(ref.find(StringRef(text + 1, 1)), 1u); + // A literal needle is bounded the same way + EXPECT_EQ(ref.find("cd"), std::string::npos); + EXPECT_EQ(ref.find("c"), 2u); +} + +TEST(StringRefFind, LiteralMatchesStdStringForEmptyNeedle) { + const StringRef ref("abc", 3); + EXPECT_EQ(ref.find("", 3), std::string("abc").find("", 3)); + EXPECT_EQ(ref.find("", 4), std::string::npos); +} + } // namespace esphome::core::testing From 415eb22a2c0c46475ac051e8168d5214d87c2688 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:02:31 -1000 Subject: [PATCH 081/170] [output] Let final_validate_unique_channel check a hub's channel count (#21854) --- esphome/components/output/__init__.py | 25 ++++++- tests/component_tests/output/test_output.py | 73 ++++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index d9c1a284d6f..3ef8cb6843d 100644 --- a/esphome/components/output/__init__.py +++ b/esphome/components/output/__init__.py @@ -132,18 +132,37 @@ BINARY_OUTPUT_ACTION_SCHEMA = maybe_simple_id( def final_validate_unique_channel( - *, platform: str, hub_key: str, channel_key: str = CONF_CHANNEL + *, + platform: str, + hub_key: str, + channel_key: str = CONF_CHANNEL, + channel_count: Callable[[ConfigType], int] | None = None, ) -> Callable[[ConfigType], ConfigType]: """Reject a second output of platform on the same channel of one hub. - Two outputs on one channel would overwrite each other's value. + Two outputs on one channel would overwrite each other's value. With channel_count, + which maps the hub's config to its number of channels, also reject channels past it. """ - return fv.unique_platform_key( + unique = fv.unique_platform_key( domain=DOMAIN, platform=platform, keys=(hub_key, channel_key), message=f"{channel_key.capitalize()} '{{1}}' of '{{0}}' is already used by another output", ) + if channel_count is None: + return unique + + def validator(config: ConfigType) -> ConfigType: + hub_id = config[hub_key] + count = channel_count(fv.get_declaring_config(hub_id)) + if config[channel_key] >= count: + raise cv.Invalid( + f"{channel_key.capitalize()} must be less than {count}, the number of channels of '{hub_id}'", + path=[channel_key], + ) + return unique(config) + + return validator def _enable_power_scaling(config: ConfigType) -> ConfigType: diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py index 43db942a03e..584d9bafeeb 100644 --- a/tests/component_tests/output/test_output.py +++ b/tests/component_tests/output/test_output.py @@ -15,8 +15,15 @@ from esphome.components.mcp4728 import output as mcp4728_output from esphome.components.pca9685 import output as pca9685_output from esphome.components.sm2135 import output as sm2135_output from esphome.config import Config, read_config -from esphome.const import CONF_CHANNEL, CONF_PIN, CONF_PLATFORM, PlatformFramework +from esphome.const import ( + CONF_CHANNEL, + CONF_ID, + CONF_PIN, + CONF_PLATFORM, + PlatformFramework, +) from esphome.core import CORE, ID +from esphome.types import ConfigType from ..types import SetCoreConfigCallable @@ -96,6 +103,70 @@ def test_hub_channel_must_be_unique( module.FINAL_VALIDATE_SCHEMA(entries[3]) +def _hub_config( + platform: str, hub_key: str, hub: ConfigType | None, *channels: int +) -> tuple[list[ConfigType], Config]: + """One output of platform per channel on hub 'a', declared with the hub dict if given.""" + full = Config() + if hub is not None: + hub_id = ID("a", is_declaration=True) + full[platform] = [{CONF_ID: hub_id, **hub}] + full.declare_ids.append((hub_id, [platform, 0, CONF_ID])) + full["output"] = _entries(platform, hub_key, *(("a", c) for c in channels)) + return full["output"], full + + +_FAKE_VALIDATE = output.final_validate_unique_channel( + platform="fake", hub_key="fake_id", channel_count=lambda hub: hub["channels"] +) + + +@pytest.mark.parametrize( + ("validate", "platform", "hub_key", "hub", "count"), + [ + pytest.param(_FAKE_VALIDATE, "fake", "fake_id", {"channels": 4}, 4, id="fake"), + ], +) +def test_hub_channel_count( + set_core_config: SetCoreConfigCallable, + validate: Callable[[ConfigType], ConfigType], + platform: str, + hub_key: str, + hub: ConfigType, + count: int, +) -> None: + """The hub's last channel is accepted; its channel count is past the end.""" + entries, full = _hub_config(platform, hub_key, hub, count - 1, count) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + assert validate(entries[0]) is entries[0] + with pytest.raises( + cv.Invalid, + match=f"Channel must be less than {count}, the number of channels of 'a'", + ) as err: + validate(entries[1]) + assert err.value.path == [CONF_CHANNEL] + + +def test_channel_count_keeps_duplicate_check( + set_core_config: SetCoreConfigCallable, +) -> None: + """In-range channels are still checked for duplicates.""" + entries, full = _hub_config("fake", "fake_id", {"channels": 4}, 1, 1) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + with pytest.raises(cv.Invalid, match="Channel '1' of 'a' is already used"): + _FAKE_VALIDATE(entries[1]) + + +def test_no_channel_count_skips_hub_lookup( + set_core_config: SetCoreConfigCallable, +) -> None: + """Without channel_count the hub is never looked up, so an undeclared hub is fine.""" + entries, full = _hub_config("fake", "fake_id", None, 1000) + set_core_config(PlatformFramework.ESP32_IDF, full_config=full) + validate = output.final_validate_unique_channel(platform="fake", hub_key="fake_id") + assert validate(entries[0]) is entries[0] + + def test_output_button_presses_through_shared_helper( generate_main: Callable[[str | Path], str], component_config_path: Callable[[str], Path], From 956fff4b44409bfa3f7a8a310910262d57af8bdc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:02:31 -1000 Subject: [PATCH 082/170] [my9231][sm16716] Reject channels past num_channels (#21855) --- esphome/components/my9231/output.py | 7 +++++-- esphome/components/sm16716/output.py | 7 +++++-- tests/component_tests/output/test_output.py | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/esphome/components/my9231/output.py b/esphome/components/my9231/output.py index 2b43eb88259..45faffe2eb5 100644 --- a/esphome/components/my9231/output.py +++ b/esphome/components/my9231/output.py @@ -1,6 +1,6 @@ from esphome.components import output import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.const import CONF_CHANNEL, CONF_ID, CONF_NUM_CHANNELS from esphome.types import ConfigType from . import MY9231OutputComponent @@ -19,7 +19,10 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel( - platform="my9231", hub_key=CONF_MY9231_ID + platform="my9231", + hub_key=CONF_MY9231_ID, + # write_channel indexes the hub's num_channels values without a bounds check + channel_count=lambda hub: hub[CONF_NUM_CHANNELS], ) diff --git a/esphome/components/sm16716/output.py b/esphome/components/sm16716/output.py index 399a7ebb58f..63a99a73c0e 100644 --- a/esphome/components/sm16716/output.py +++ b/esphome/components/sm16716/output.py @@ -1,6 +1,6 @@ from esphome.components import output import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.const import CONF_CHANNEL, CONF_ID, CONF_NUM_CHANNELS from esphome.types import ConfigType from . import SM16716 @@ -19,7 +19,10 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( FINAL_VALIDATE_SCHEMA = output.final_validate_unique_channel( - platform="sm16716", hub_key=CONF_SM16716_ID + platform="sm16716", + hub_key=CONF_SM16716_ID, + # write_channel indexes the hub's num_channels values without a bounds check + channel_count=lambda hub: hub[CONF_NUM_CHANNELS], ) diff --git a/tests/component_tests/output/test_output.py b/tests/component_tests/output/test_output.py index 584d9bafeeb..88e3ad1972f 100644 --- a/tests/component_tests/output/test_output.py +++ b/tests/component_tests/output/test_output.py @@ -12,12 +12,15 @@ from esphome.components import output from esphome.components.lp5562 import output as lp5562_output from esphome.components.mcp4461 import output as mcp4461_output from esphome.components.mcp4728 import output as mcp4728_output +from esphome.components.my9231 import output as my9231_output from esphome.components.pca9685 import output as pca9685_output from esphome.components.sm2135 import output as sm2135_output +from esphome.components.sm16716 import output as sm16716_output from esphome.config import Config, read_config from esphome.const import ( CONF_CHANNEL, CONF_ID, + CONF_NUM_CHANNELS, CONF_PIN, CONF_PLATFORM, PlatformFramework, @@ -125,6 +128,22 @@ _FAKE_VALIDATE = output.final_validate_unique_channel( ("validate", "platform", "hub_key", "hub", "count"), [ pytest.param(_FAKE_VALIDATE, "fake", "fake_id", {"channels": 4}, 4, id="fake"), + pytest.param( + my9231_output.FINAL_VALIDATE_SCHEMA, + "my9231", + "my9231_id", + {CONF_NUM_CHANNELS: 6}, + 6, + id="my9231", + ), + pytest.param( + sm16716_output.FINAL_VALIDATE_SCHEMA, + "sm16716", + "sm16716_id", + {CONF_NUM_CHANNELS: 3}, + 3, + id="sm16716", + ), ], ) def test_hub_channel_count( From 041979d4064d3847c3221064bf3c2b0a10bc22b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:02:44 -1000 Subject: [PATCH 083/170] [core] Move owning_args from nextion into automation (#21893) --- esphome/automation.py | 10 +++++ esphome/components/nextion/display.py | 16 ++------ .../nextion/test_custom_trigger_args.py | 33 ----------------- tests/unit_tests/test_automation.py | 37 +++++++++++++++++++ 4 files changed, 51 insertions(+), 45 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 46569958c9a..d91c5f6fd35 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1817,6 +1817,16 @@ def _options_have_non_synchronous_actions(options: Any) -> bool: return False +def owning_args(args: TemplateArgsType, conf: ConfigType) -> TemplateArgsType: + """Pass std::string instead of StringRef to an automation that defers, as it keeps its arguments.""" + if not has_non_synchronous_actions(conf): + return args + return [ + (cg.std_string if type_ is cg.StringRef else type_, name) + for type_, name in args + ] + + def validate_no_deferring_actions( context: str, data: str = "the trigger data" ) -> Callable[[ConfigType], ConfigType]: diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3a2bb1a659d..a84455cb53f 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -10,7 +10,6 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, TimePeriod -from esphome.types import ConfigType, TemplateArgsType from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( @@ -305,15 +304,8 @@ async def to_code(config): for entry in _CUSTOM_CALLBACK_AUTOMATIONS: for conf in config.get(entry.conf_key, []): await automation.build_callback_automation( - var, entry.callback_method, _owning_args(entry.args, conf), conf + var, + entry.callback_method, + automation.owning_args(entry.args, conf), + conf, ) - - -def _owning_args(args: TemplateArgsType, conf: ConfigType) -> TemplateArgsType: - """Pass std::string instead of StringRef to an automation that defers, as it keeps its arguments.""" - if not automation.has_non_synchronous_actions(conf): - return args - return [ - (cg.std_string if type_ is cg.StringRef else type_, name) - for type_, name in args - ] diff --git a/tests/component_tests/nextion/test_custom_trigger_args.py b/tests/component_tests/nextion/test_custom_trigger_args.py index c08f0df83d4..e2693e1c3db 100644 --- a/tests/component_tests/nextion/test_custom_trigger_args.py +++ b/tests/component_tests/nextion/test_custom_trigger_args.py @@ -3,39 +3,6 @@ from collections.abc import Callable from pathlib import Path -import esphome.codegen as cg -from esphome.components.nextion.display import _owning_args -from esphome.core import Lambda - -ARGS = [(cg.StringRef, "key"), (cg.bool_, "value")] -SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} -DEFERRING_ACTION = {"delay": "1s"} -TRUE_CONDITION = {"lambda": Lambda("return true;")} - - -def test_synchronous_automation_keeps_string_ref() -> None: - assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION]}) == ARGS - - -def test_deferring_automation_gets_std_string() -> None: - assert _owning_args(ARGS, {"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}) == [ - (cg.std_string, "key"), - (cg.bool_, "value"), - ] - - -def test_nested_deferring_action_gets_std_string() -> None: - actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] - assert _owning_args(ARGS, {"then": actions})[0] == (cg.std_string, "key") - - -def test_all_string_refs_swapped() -> None: - args = [(cg.StringRef, "key"), (cg.StringRef, "value")] - assert _owning_args(args, {"then": [DEFERRING_ACTION]}) == [ - (cg.std_string, "key"), - (cg.std_string, "value"), - ] - def test_generated_automation_types( generate_main: Callable[[str | Path], str], diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index fb5ebeb0e33..d8ed685a05e 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -55,6 +55,7 @@ from esphome.automation import ( literal_with_length, maybe_simple_id, new_lambda_pvariable, + owning_args, parent_ref, progmem_bytes, register_action, @@ -344,6 +345,42 @@ def test_has_non_synchronous_actions_set_playlist_delay() -> None: assert has_non_synchronous_actions(actions) is False +OWNING_ARGS = [(cg.StringRef, "key"), (cg.bool_, "value")] + + +def test_owning_args_synchronous_keeps_string_ref( + mock_registry: dict[str, RegistryEntry], +) -> None: + assert owning_args(OWNING_ARGS, {CONF_THEN: [{"logger.log": "hi"}]}) == OWNING_ARGS + + +def test_owning_args_deferring_gets_std_string( + mock_registry: dict[str, RegistryEntry], +) -> None: + conf = {CONF_THEN: [{"logger.log": "hi"}, {"delay": "1s"}]} + assert owning_args(OWNING_ARGS, conf) == [ + (cg.std_string, "key"), + (cg.bool_, "value"), + ] + + +def test_owning_args_nested_deferring_gets_std_string( + mock_registry: dict[str, RegistryEntry], +) -> None: + conf = {CONF_THEN: [{"if": {CONF_THEN: [{"delay": "1s"}]}}]} + assert owning_args(OWNING_ARGS, conf)[0] == (cg.std_string, "key") + + +def test_owning_args_swaps_every_string_ref( + mock_registry: dict[str, RegistryEntry], +) -> None: + args = [(cg.StringRef, "key"), (cg.StringRef, "value")] + assert owning_args(args, {CONF_THEN: [{"delay": "1s"}]}) == [ + (cg.std_string, "key"), + (cg.std_string, "value"), + ] + + def test_validate_no_deferring_actions_accepts_synchronous( mock_registry: dict[str, RegistryEntry], ) -> None: From 8a6232827669cbb4b57cf5d49a9c4f96c245d561 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:03:27 -1000 Subject: [PATCH 084/170] [core] Let module platforms inherit their component's FILTER_SOURCE_FILES (#22072) --- esphome/components/debug/sensor.py | 2 +- esphome/components/debug/text_sensor.py | 2 +- esphome/components/epaper_spi/display.py | 6 +- esphome/components/ld2450/binary_sensor.py | 8 +-- esphome/components/ld2450/button.py | 2 +- esphome/components/ld2450/number.py | 2 +- esphome/components/ld2450/select.py | 2 +- esphome/components/ld2450/sensor.py | 2 +- esphome/components/ld2450/switch.py | 2 +- esphome/components/ld2450/text.py | 4 +- esphome/components/ld2450/text_sensor.py | 2 +- esphome/components/nextion/display.py | 2 +- .../remote_receiver/binary_sensor.py | 2 - esphome/components/tas58xx/audio_dac.py | 2 - esphome/components/tas58xx/binary_sensor.py | 1 - esphome/components/tas58xx/select.py | 2 +- esphome/components/tas58xx/switch.py | 1 - esphome/components/toshiba/climate.py | 2 - esphome/components/toshiba/select.py | 1 - esphome/loader.py | 8 ++- tests/unit_tests/test_loader.py | 62 ++++++++++++++----- 21 files changed, 66 insertions(+), 51 deletions(-) diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 07c67a2b937..d3cf4cdc6fe 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -23,7 +23,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import +from . import CONF_DEBUG_ID, DebugComponent DEPENDENCIES = ["debug"] diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index ce45028b7ef..7b82bd9b263 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import +from . import CONF_DEBUG_ID, DebugComponent DEPENDENCIES = ["debug"] diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 98222512f6f..421110eb47f 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -42,11 +42,7 @@ from esphome.const import ( ) from esphome.cpp_generator import RawExpression -from . import ( # pylint: disable=unused-import - FILTER_SOURCE_FILES, # noqa: F401 - models, - register_sources, -) +from . import models, register_sources AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] diff --git a/esphome/components/ld2450/binary_sensor.py b/esphome/components/ld2450/binary_sensor.py index 7a2f0a9c029..43caa7845bf 100644 --- a/esphome/components/ld2450/binary_sensor.py +++ b/esphome/components/ld2450/binary_sensor.py @@ -15,13 +15,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_LD2450_ID, - CONF_POLYGON_ZONE_ID, - CONF_POLYGON_ZONES, - FILTER_SOURCE_FILES, - LD2450Component, -) +from . import CONF_LD2450_ID, CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES, LD2450Component from .text import PolygonZone DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/ld2450/button.py b/esphome/components/ld2450/button.py index a4844d699d3..9cb779f11d2 100644 --- a/esphome/components/ld2450/button.py +++ b/esphome/components/ld2450/button.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_FACTORY_RESET, CONF_ID, CONF_RESTART from esphome.types import ConfigType -from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import +from . import CONF_LD2450_ID, LD2450Component CONFIG_SCHEMA = { cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase), diff --git a/esphome/components/ld2450/number.py b/esphome/components/ld2450/number.py index 059e0306a3b..bed4d193b33 100644 --- a/esphome/components/ld2450/number.py +++ b/esphome/components/ld2450/number.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import +from . import CONF_LD2450_ID, LD2450Component CONF_PRESENCE_TIMEOUT = "presence_timeout" CONF_X1 = "x1" diff --git a/esphome/components/ld2450/select.py b/esphome/components/ld2450/select.py index 3259117e5bd..fe6b9cdef22 100644 --- a/esphome/components/ld2450/select.py +++ b/esphome/components/ld2450/select.py @@ -9,7 +9,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import +from . import CONF_LD2450_ID, LD2450Component CONF_ZONE_TYPE = "zone_type" diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 4600f27ce0d..6f2056330bd 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -17,7 +17,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import +from . import CONF_LD2450_ID, LD2450Component DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/ld2450/switch.py b/esphome/components/ld2450/switch.py index ff789bb89ad..e2c134e33cf 100644 --- a/esphome/components/ld2450/switch.py +++ b/esphome/components/ld2450/switch.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import +from . import CONF_LD2450_ID, LD2450Component CONF_MULTI_TARGET = "multi_target" diff --git a/esphome/components/ld2450/text.py b/esphome/components/ld2450/text.py index f1f278b4e21..5e2c344168f 100644 --- a/esphome/components/ld2450/text.py +++ b/esphome/components/ld2450/text.py @@ -6,12 +6,10 @@ from esphome.const import CONF_ID, ENTITY_CATEGORY_CONFIG import esphome.final_validate as fv from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import +from . import ( CONF_LD2450_ID, CONF_POLYGON_ZONE_ID, CONF_POLYGON_ZONES, - DOMAIN, - FILTER_SOURCE_FILES, LD2450Component, ld2450_ns, ) diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index 8dbe023baaf..2b5c18d03d3 100644 --- a/esphome/components/ld2450/text_sensor.py +++ b/esphome/components/ld2450/text_sensor.py @@ -14,7 +14,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import CONF_LD2450_ID, FILTER_SOURCE_FILES, LD2450Component # noqa: F401 pylint: disable=unused-import +from . import CONF_LD2450_ID, LD2450Component DEPENDENCIES = ["ld2450"] diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index a84455cb53f..8d5b26739d3 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -11,7 +11,7 @@ from esphome.const import ( ) from esphome.core import CORE, TimePeriod -from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import +from . import Nextion, nextion_ref from .base_component import ( CONF_AUTO_WAKE_ON_TOUCH, CONF_COMMAND_SPACING, diff --git a/esphome/components/remote_receiver/binary_sensor.py b/esphome/components/remote_receiver/binary_sensor.py index d4009f396bf..78651427380 100644 --- a/esphome/components/remote_receiver/binary_sensor.py +++ b/esphome/components/remote_receiver/binary_sensor.py @@ -1,8 +1,6 @@ from esphome.components import binary_sensor, remote_base from esphome.types import ConfigType -from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import - DEPENDENCIES = ["remote_receiver"] CONFIG_SCHEMA = remote_base.validate_binary_sensor diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py index 48c7133b941..0828ddbc009 100644 --- a/esphome/components/tas58xx/audio_dac.py +++ b/esphome/components/tas58xx/audio_dac.py @@ -12,8 +12,6 @@ from esphome.const import CONF_ENABLE_PIN, CONF_ID, CONF_MODEL from esphome.cpp_generator import MockObj from esphome.types import ConfigType -from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import - _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["i2c"] diff --git a/esphome/components/tas58xx/binary_sensor.py b/esphome/components/tas58xx/binary_sensor.py index dcd6412bf25..22afeaebde5 100644 --- a/esphome/components/tas58xx/binary_sensor.py +++ b/esphome/components/tas58xx/binary_sensor.py @@ -5,7 +5,6 @@ from esphome.const import CONF_MODEL, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAG import esphome.final_validate as fv from esphome.types import ConfigType -from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import from .audio_dac import CONF_TAS58XX_ID, DAC_TAS5825M, TAS58xx, tas58xx_ns CONF_HAVE_FAULT = "have_fault" diff --git a/esphome/components/tas58xx/select.py b/esphome/components/tas58xx/select.py index 45245c65b01..1c4d7f34328 100644 --- a/esphome/components/tas58xx/select.py +++ b/esphome/components/tas58xx/select.py @@ -6,7 +6,7 @@ from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType -from . import DOMAIN, FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from . import DOMAIN from .audio_dac import ( CONF_DAC_MODE, CONF_MIXER_MODE, diff --git a/esphome/components/tas58xx/switch.py b/esphome/components/tas58xx/switch.py index 798e7a4a813..69a8cb2c6cb 100644 --- a/esphome/components/tas58xx/switch.py +++ b/esphome/components/tas58xx/switch.py @@ -10,7 +10,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import from .audio_dac import CONF_TAS58XX_ID, TAS58xx CONF_ACTIVATE = "activate" diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 51f294febe8..792babbd959 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -5,8 +5,6 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_MODEL from esphome.types import ConfigType -from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import - AUTO_LOAD = ["climate_ir"] CODEOWNERS = ["@kbx81"] diff --git a/esphome/components/toshiba/select.py b/esphome/components/toshiba/select.py index bec0e9c5821..963b829d092 100644 --- a/esphome/components/toshiba/select.py +++ b/esphome/components/toshiba/select.py @@ -3,7 +3,6 @@ from esphome.components import select import esphome.config_validation as cv from esphome.types import ConfigType -from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import from .climate import ToshibaRas25j2kvsgNdClimate DEPENDENCIES = ["climate.toshiba"] diff --git a/esphome/loader.py b/esphome/loader.py index 295d9a4febd..a59acd3bd72 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -180,11 +180,17 @@ class ComponentManifest: constructed with ``recursive_sources=True`` also descend into non-subpackage subdirectories (subdirectories without an ``__init__.py``), so core code can live under ``esphome/core//`` without every component paying the cost. + + A module platform (``a4988/stepper.py``) shares its component's directory, so + without its own ``FILTER_SOURCE_FILES`` it uses the component's. """ ret: list[FileResource] = [] - # Get filter function for source files filter_source_files_func = getattr(self.module, "FILTER_SOURCE_FILES", None) + if filter_source_files_func is None and self.module.__name__ != self.package: + filter_source_files_func = getattr( + sys.modules[self.package], "FILTER_SOURCE_FILES", None + ) # Get list of files to exclude excluded_files = ( diff --git a/tests/unit_tests/test_loader.py b/tests/unit_tests/test_loader.py index 90c61e059a8..7b5b67eede1 100644 --- a/tests/unit_tests/test_loader.py +++ b/tests/unit_tests/test_loader.py @@ -201,6 +201,14 @@ def _mock_file(filename: str) -> MagicMock: return m +def _mock_component_module(package: str) -> MagicMock: + """Mock a component's ``__init__`` module without ``FILTER_SOURCE_FILES``.""" + m = MagicMock() + m.__package__ = m.__name__ = package + del m.FILTER_SOURCE_FILES + return m + + def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock: """Mock a directory entry with an iterdir() and joinpath('__init__.py').""" d = MagicMock() @@ -216,10 +224,7 @@ def _mock_dir(dirname: str, children: list, has_init: bool = False) -> MagicMock def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: """Default (recursive_sources=False) does not descend into subdirectories.""" - mock_module = MagicMock() - mock_module.__package__ = "esphome.components.test_component" - # No FILTER_SOURCE_FILES. - del mock_module.FILTER_SOURCE_FILES + mock_module = _mock_component_module("esphome.components.test_component") manifest = ComponentManifest(mock_module) # recursive_sources defaults to False @@ -239,9 +244,7 @@ def test_component_manifest_resources_non_recursive_skips_subdirs() -> None: def test_component_manifest_resources_recursive_walks_non_subpackage_subdirs() -> None: """With recursive_sources=True, a subdir without __init__.py is walked.""" - mock_module = MagicMock() - mock_module.__package__ = "esphome.core" - del mock_module.FILTER_SOURCE_FILES + mock_module = _mock_component_module("esphome.core") manifest = ComponentManifest(mock_module, recursive_sources=True) @@ -272,9 +275,7 @@ def test_component_manifest_resources_recursive_skips_subpackages() -> None: """Subdirectories that ARE Python subpackages (contain __init__.py) are skipped even with recursive_sources=True — those load as their own ComponentManifest and would otherwise be double-counted.""" - mock_module = MagicMock() - mock_module.__package__ = "esphome.components.haier" - del mock_module.FILTER_SOURCE_FILES + mock_module = _mock_component_module("esphome.components.haier") manifest = ComponentManifest(mock_module, recursive_sources=True) @@ -299,9 +300,7 @@ def test_component_manifest_resources_recursive_skips_subpackages() -> None: def test_component_manifest_resources_recursive_skips_pycache() -> None: """__pycache__ inside a recursive walk must never be descended into.""" - mock_module = MagicMock() - mock_module.__package__ = "esphome.core" - del mock_module.FILTER_SOURCE_FILES + mock_module = _mock_component_module("esphome.core") manifest = ComponentManifest(mock_module, recursive_sources=True) @@ -350,6 +349,39 @@ def test_component_manifest_resources_recursive_filter_source_files_supports_sub assert names == ["wake/wake_freertos.cpp"] +@pytest.mark.parametrize( + ("package_platform", "platform_filter", "expected"), + [ + (False, None, ["hub.cpp"]), + (False, ["hub.cpp"], ["hub_extra.cpp"]), + (True, None, ["hub.cpp", "hub_extra.cpp"]), + ], + ids=["inherits_parent_filter", "keeps_own_filter", "package_ignores_parent"], +) +def test_component_manifest_resources_platform_filter( + package_platform: bool, platform_filter: list[str] | None, expected: list[str] +) -> None: + """A module platform (sensor.py) without its own filter uses its component's; + a package platform (sensor/__init__.py) has its own directory and does not.""" + parent = ModuleType("esphome.components._test_parent") + parent.FILTER_SOURCE_FILES = lambda: ["hub_extra.cpp"] + platform = ModuleType("esphome.components._test_parent.sensor") + platform.__package__ = platform.__name__ if package_platform else parent.__name__ + if platform_filter is not None: + platform.FILTER_SOURCE_FILES = lambda: platform_filter + with ( + patch.dict(sys.modules, {parent.__name__: parent}), + patch("importlib.resources.files") as mock_files_func, + ): + mock_files_func.return_value.iterdir.return_value = [ + _mock_file("hub.cpp"), + _mock_file("hub_extra.cpp"), + ] + names = [r.resource for r in ComponentManifest(platform).resources] + + assert names == expected + + # --------------------------------------------------------------------------- # Component aliases (renamed-platform back-compat) # --------------------------------------------------------------------------- @@ -731,9 +763,7 @@ def test_component_manifest_metadata_defaults() -> None: def test_component_manifest_resources_recursive_skips_non_file_entries() -> None: """Nested directories inside a walked subdirectory are ignored.""" - mock_module = MagicMock() - mock_module.__package__ = "esphome.core" - del mock_module.FILTER_SOURCE_FILES + mock_module = _mock_component_module("esphome.core") manifest = ComponentManifest(mock_module, recursive_sources=True) group = _mock_dir( From 956a633c48fceb41a2e3de55b28eade21a93afd1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:03:44 -1000 Subject: [PATCH 085/170] [core] Explain a failed clone of a missing or private repository (#22074) --- esphome/git.py | 42 ++++++++- tests/unit_tests/test_git.py | 160 +++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/esphome/git.py b/esphome/git.py index ba42e110415..a691d2fb2b5 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -111,6 +111,15 @@ _TRANSIENT_GIT_ERROR_PATTERNS: tuple[str, ...] = ( # simple rule since git hosts rarely emit them. _PERMANENT_HTTP_ERROR_RE = re.compile(r"(?:http |returned error: )4(?!29)\d\d") +# How git reports a clone of a repository it cannot see. GitHub answers a +# missing repository like a private one, asking for credentials, so without a +# terminal git fails on the username prompt rather than saying "not found". +_MISSING_REPOSITORY_RE = re.compile( + r"could not read (?:username|password)|repository not found" + r"|authentication failed|repository '[^']*' not found", + re.IGNORECASE, +) + # Network commands get 3 attempts with 2s/4s backoff. Worst case is ~3x # the command's own duration plus 6s of sleep, held under the cache entry # lock; peers with a complete entry fall back to it after @@ -158,6 +167,32 @@ def _redact_url_credentials(text: str) -> str: return URL_USERINFO_RE.sub("://***@", text) +def _explain_clone_failure( + err: GitCommandError, url: str, original_url: str +) -> GitCommandError | None: + """Return a clearer error for a clone of a repository git cannot see. + + ``url`` is the URL git was given, with any credentials inserted, and + ``original_url`` the one from the config. Returns ``None`` for any other + failure, so the caller re-raises git's own error. + """ + if not _MISSING_REPOSITORY_RE.search(err.stderr): + return None + if not url.startswith(("http://", "https://")): + cause, check_also = "the SSH key used cannot access it", ", and the key" + elif URL_USERINFO_RE.search(url): + cause = "the username and password used cannot access it" + check_also = ", and the credentials" + else: + cause, check_also = "it is private and needs a username and password", "" + return GitCommandError( + f"Could not clone {_redact_url_credentials(original_url)}: the repository " + f"does not exist, or {cause}. Check the owner and repository name in the " + f"source{check_also}. Git reported: {_redact_url_credentials(str(err))}", + stderr=err.stderr, + ) + + def strip_url_credentials(url: str) -> str: """Return ``url`` without its userinfo (``user:token@``), for URLs that are published.""" parsed = urllib.parse.urlsplit(url) @@ -740,7 +775,12 @@ def _clone_or_update_locked( try: cmd = ["git", "clone", "--depth=1"] cmd += ["--", url, str(repo_dir)] - run_git_command(cmd, network=True, retry_cleanup=repo_dir) + try: + run_git_command(cmd, network=True, retry_cleanup=repo_dir) + except GitCommandError as err: + if explained := _explain_clone_failure(err, url, original_url): + raise explained from err + raise if ref is not None: # We need to fetch the PR branch first, otherwise git will complain diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index 7bf7fa3fce4..6b8257d5df5 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -982,6 +982,166 @@ def test_clone_or_update_clones_missing_repo( assert len(clone_calls) > 0 +_PRIVATE_HINT = ( + "Could not clone https://github.com/test/repo: the repository does not exist, " + "or it is private and needs a username and password. Check the owner and " + "repository name in the source." +) +_CREDENTIALS_HINT = ( + "Could not clone https://***@github.com/test/repo: the repository does not " + "exist, or the username and password used cannot access it. Check the owner " + "and repository name in the source, and the credentials." +) + + +@pytest.mark.parametrize( + ("url", "username", "password", "stderr", "expected"), + [ + ( + "https://github.com/test/repo", + None, + None, + ( + b"fatal: could not read Username for 'https://github.com': " + b"No such device or address\n" + ), + _PRIVATE_HINT, + ), + ( + "https://github.com/test/repo", + None, + None, + ( + b"fatal: could not read Username for 'https://github.com': " + b"terminal prompts disabled\n" + ), + _PRIVATE_HINT, + ), + ( + "https://github.com/test/repo", + None, + None, + ( + b"remote: Repository not found.\n" + b"fatal: repository 'https://github.com/test/repo/' not found\n" + ), + _PRIVATE_HINT, + ), + ( + "https://github.com/test/repo", + None, + None, + b"fatal: repository 'https://github.com/test/repo/' not found\n", + _PRIVATE_HINT, + ), + ( + "https://github.com/test/repo", + None, + None, + b"fatal: Authentication failed for 'https://github.com/test/repo/'\n", + _PRIVATE_HINT, + ), + ( + "https://github.com/test/repo", + None, + None, + ( + b"fatal: could not read Password for 'https://user@github.com': " + b"terminal prompts disabled\n" + ), + _PRIVATE_HINT, + ), + ( + "https://github.com/test/repo", + "user", + "secret", + ( + b"fatal: Authentication failed for " + b"'https://user:secret@github.com/test/repo/'\n" + ), + _CREDENTIALS_HINT.replace("https://***@", "https://"), + ), + ( + "https://user:secret@github.com/test/repo", + None, + None, + ( + b"fatal: Authentication failed for " + b"'https://user:secret@github.com/test/repo/'\n" + ), + _CREDENTIALS_HINT, + ), + ( + "git@github.com:test/repo.git", + None, + None, + ( + b"ERROR: Repository not found.\n" + b"fatal: Could not read from remote repository.\n" + ), + ( + "Could not clone git@github.com:test/repo.git: the repository does not " + "exist, or the SSH key used cannot access it. Check the owner and " + "repository name in the source, and the key." + ), + ), + ], +) +def test_clone_or_update_explains_missing_repository( + tmp_path: Path, + mock_subprocess_run: Mock, + url: str, + username: str | None, + password: str | None, + stderr: bytes, + expected: str, +) -> None: + """A clone of a repository git cannot see names the URL and the likely cause.""" + CORE.config_path = tmp_path / "test.yaml" + repo_dir = _compute_repo_dir(url, None, "test") + + def fail_clone(cmd: list[str], **kwargs: Any) -> Mock: + # Leave a partial clone behind, as git does. + _simulate_cloned_repo(repo_dir) + return _git_failure(stderr) + + mock_subprocess_run.side_effect = fail_clone + + with pytest.raises(GitCommandError) as exc_info: + git.clone_or_update( + url=url, + ref=None, + refresh=None, + domain="test", + username=username, + password=password, + ) + + message = str(exc_info.value) + assert message.split(" Git reported: ", maxsplit=1)[0] == expected + assert "secret" not in message + assert exc_info.value.stderr == stderr.decode() + assert not repo_dir.exists() + + +def test_clone_or_update_keeps_other_clone_errors( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """Clone failures unrelated to a missing repository keep git's message.""" + CORE.config_path = tmp_path / "test.yaml" + stderr = "fatal: couldn't find remote ref refs/heads/nope" + mock_run_git_command.side_effect = GitCommandError( + "couldn't find remote ref refs/heads/nope", stderr=stderr + ) + + with pytest.raises(GitCommandError) as exc_info: + git.clone_or_update( + url="https://github.com/test/repo", ref=None, refresh=None, domain="test" + ) + + assert str(exc_info.value) == "couldn't find remote ref refs/heads/nope" + + def test_clone_or_update_with_none_refresh_always_updates( tmp_path: Path, mock_run_git_command: Mock ) -> None: From 4d50e1ba22de5bbbc56863b94040dd54bdcf91d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:04:02 -1000 Subject: [PATCH 086/170] [bluetooth_proxy] Release held connections when a new client replaces the subscriber (#22077) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 28 +++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 2 ++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index e4b94870747..9c8f3d15a46 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -635,16 +635,8 @@ void BluetoothProxy::loop() { #endif if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { -#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS - // The API subscriber is gone: tear down any connections it left behind - // (disconnect() on an already-disconnecting slot is a no-op). - for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0) { - connection->disconnect(); - } - } -#endif + // The API subscriber is gone: tear down any connections it left behind. + this->disconnect_all_connections_(); return; } @@ -737,6 +729,17 @@ void BluetoothProxy::reset_owed_replies_() { #endif } +void BluetoothProxy::disconnect_all_connections_() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0) { + connection->disconnect(); + } + } +#endif +} + void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (api_connection != this->api_connection_) { if (this->api_connection_ != nullptr) { @@ -754,8 +757,11 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection // Stale retry latches belong to the previous subscriber's session; a // re-subscribe by the current one keeps what it is still owed. this->reset_owed_replies_(); + this->api_connection_ = api_connection; + // Held links belong to a client that no longer gets their events; free them so idle-only + // advertisers are seen again. After the swap, so a sync slot free reports to the new client. + this->disconnect_all_connections_(); } - this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). this->send_scanner_state_(this->hub_->get_scanner_state()); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index a47a2cd3d1e..7efff344ee0 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -328,6 +328,8 @@ class BluetoothProxy final : public Component { /// Drops state only, never sends: api_connection_ is the departing /// subscriber on subscribe and nullptr on unsubscribe. void reset_owed_replies_(); + /// Tear down every held link; repeat calls are no-ops. + void disconnect_all_connections_(); #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// Report a reply we deliberately do not latch, so no drop is silent. void log_reply_dropped_(const char *what, uint64_t address); From 174016df57e7e1b5cf632f4f56af66e591d2a3bb Mon Sep 17 00:00:00 2001 From: Ardumine <61353807+Ardumine@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:15:21 +0000 Subject: [PATCH 087/170] [udp] Add Zephyr/nRF52 IPv6 support (#17838) --- esphome/components/udp/__init__.py | 24 ++++- esphome/components/udp/udp_component.cpp | 58 ++++++++++-- esphome/components/udp/udp_component.h | 16 +++- esphome/core/defines.h | 1 + .../udp/test_udp_omr_source.py | 89 +++++++++++++++++++ 5 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/udp/test_udp_omr_source.py diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index d9a01335433..c6673659a6d 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,4 +1,4 @@ -from ipaddress import IPv4Address, IPv6Address +from ipaddress import IPv4Address, IPv6Address, IPv6Network from typing import Any from esphome import automation @@ -16,7 +16,7 @@ from esphome.components.packet_transport import ( ) import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID -from esphome.core import ID +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -123,6 +123,18 @@ def _ipv6_addresses(config: ConfigType) -> list[IPv6Address]: return [addr for addr in config[CONF_ADDRESSES] if addr.version == 6] +_UNIQUE_LOCAL = IPv6Network("fc00::/7") +# Multicast scopes up to realm-local (3) stay inside a Thread mesh +_MAX_MESH_MULTICAST_SCOPE = 3 + + +def _needs_routable_source(addr: IPv6Address) -> bool: + """Whether the destination is off the Thread mesh, so a mesh-local source would be rejected.""" + if addr.is_multicast: + return (addr.packed[1] & 0x0F) > _MAX_MESH_MULTICAST_SCOPE + return not addr.is_link_local and addr not in _UNIQUE_LOCAL + + def _require_ipv6_for_ipv6_addresses(config: ConfigType) -> ConfigType: if _ipv6_addresses(config) or config[CONF_LISTEN_ADDRESS].version == 6: network.require_ipv6("udp")(config) @@ -201,6 +213,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_ipv6_send(any(addr.is_multicast for addr in ipv6_addresses))) if not _has_ipv4_address(config): cg.add(var.set_ipv4_send(False)) + # Thread on Zephyr cannot pick a routable source for an unbound socket + if ( + CORE.using_zephyr + and "openthread" in CORE.loaded_integrations + and any(_needs_routable_source(addr) for addr in ipv6_addresses) + ): + cg.add_define("USE_UDP_OMR_SOURCE") + cg.add(var.set_omr_source()) # Shared flash table ended by nullptr, so the component stores only a pointer. if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]: cg.add( diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 38cad2003b0..6f6179968ef 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -3,6 +3,9 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" #include "esphome/components/network/util.h" +#ifdef USE_UDP_OMR_SOURCE +#include "esphome/components/openthread/openthread.h" +#endif #include "udp_component.h" namespace esphome::udp { @@ -64,6 +67,11 @@ void UDPComponent::setup() { } #endif } +#ifdef USE_UDP_OMR_SOURCE + // set_omr_source() asked for the bind; without a send socket there is nothing to bind + if (this->send_socket_v6_ == nullptr) + this->poll_pending_ &= ~POLL_SRC_BIND; +#endif // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { @@ -148,7 +156,7 @@ bool UDPComponent::network_poll_() { this->poll_pending_ &= ~POLL_JOIN; this->poll_failed_ &= ~POLL_JOIN; } else { - this->set_poll_warning_(POLL_JOIN, LOG_STR("Failed to join multicast group")); + this->set_poll_warning_(POLL_JOIN, LOG_STR("Failed to join multicast group"), errno); ok = false; } } @@ -163,7 +171,7 @@ bool UDPComponent::network_poll_() { this->poll_pending_ &= ~POLL_BIND; this->poll_failed_ &= ~POLL_BIND; } else { - this->set_poll_warning_(POLL_BIND, LOG_STR("Unable to bind socket")); + this->set_poll_warning_(POLL_BIND, LOG_STR("Unable to bind socket"), errno); ok = false; } } @@ -177,10 +185,33 @@ bool UDPComponent::network_poll_() { this->poll_pending_ &= ~POLL_MCAST_IF; this->poll_failed_ &= ~POLL_MCAST_IF; } else { - this->set_poll_warning_(POLL_MCAST_IF, LOG_STR("Failed to set IPv6 multicast interface")); + this->set_poll_warning_(POLL_MCAST_IF, LOG_STR("Failed to set IPv6 multicast interface"), errno); ok = false; } } +#endif +#ifdef USE_UDP_OMR_SOURCE + if (this->poll_pending_ & POLL_SRC_BIND) { + // An unbound socket picks a mesh-local source that global destinations reject; bind the OMR address + auto *ot = openthread::global_openthread_component; + auto omr = ot != nullptr ? ot->get_omr_address() : std::nullopt; + if (!omr.has_value()) { + // Nothing failed yet, so there is no errno to report + this->set_poll_warning_(POLL_SRC_BIND, LOG_STR("No routable IPv6 source address"), 0); + ok = false; + } else { + struct sockaddr_in6 src{}; + src.sin6_family = AF_INET6; + memcpy(&src.sin6_addr, omr->mFields.m8, sizeof(src.sin6_addr)); + if (this->send_socket_v6_->bind((struct sockaddr *) &src, sizeof(src)) == 0) { + this->poll_pending_ &= ~POLL_SRC_BIND; + this->poll_failed_ &= ~POLL_SRC_BIND; + } else { + this->set_poll_warning_(POLL_SRC_BIND, LOG_STR("Unable to bind the OMR source address"), errno); + ok = false; + } + } + } #endif if (!ok) return false; @@ -191,12 +222,16 @@ bool UDPComponent::network_poll_() { return true; } -void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) { +void UDPComponent::set_poll_warning_(PollStep step, const LogString *what, int err) { // Log the first failure of each step only; the poll keeps retrying every second if (this->poll_failed_ & step) return; this->poll_failed_ |= step; - ESP_LOGW(TAG, "%s. Error %d", LOG_STR_ARG(what), errno); + if (err != 0) { + ESP_LOGW(TAG, "%s. Error %d", LOG_STR_ARG(what), err); + } else { + ESP_LOGW(TAG, "%s", LOG_STR_ARG(what)); + } // One warning bit: only a warning raised here is cleared on success, one from setup() stays if (!this->status_has_warning()) { this->status_set_warning(what); @@ -258,6 +293,19 @@ bool UDPComponent::send_packet(const uint8_t *data, size_t size) { ESP_LOGV(TAG, "Egress interface not set yet, multicast packet dropped"); continue; } +#ifdef USE_UDP_OMR_SOURCE + // Zephyr registers the socket on its first sendto() and bind() then fails with EISCONN, so even + // mesh-scope destinations wait for the routable one + if (v6 && (this->poll_pending_ & POLL_SRC_BIND)) { + if ((this->poll_failed_ & POLL_SEND_HELD) == 0) { + this->poll_failed_ |= POLL_SEND_HELD; + ESP_LOGW(TAG, "IPv6 sends are held until a Thread border router provides an OMR address. " + "Put mesh-only destinations in a separate udp block to send them without waiting"); + } + ESP_LOGV(TAG, "IPv6 source not bound yet, packet dropped"); + continue; + } +#endif socket::UDPSendSocket *sock = v6 ? this->send_socket_v6_.get() : this->broadcast_socket_.get(); socklen_t sockaddr_size = v6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in); #else diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index f6c0181a0d7..99d5b3761e1 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -30,6 +30,11 @@ class UDPComponent final : public Component { } /// An instance whose destinations are all IPv6 needs no IPv4 send socket void set_ipv4_send(bool ipv4_send) { this->ipv4_send_ = ipv4_send; } +#endif +#ifdef USE_UDP_OMR_SOURCE + /// A destination is off the Thread mesh, so the IPv6 send socket must be bound to the OMR address first. + /// Kept as the pending poll bit, as the bool members leave no padding. + void set_omr_source() { this->poll_pending_ |= POLL_SRC_BIND; } #endif template void add_listener(F &&listener) { this->packet_listeners_.add(std::forward(listener)); } void setup() override; @@ -41,7 +46,13 @@ class UDPComponent final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }; protected: - enum PollStep : uint8_t { POLL_JOIN = 1, POLL_BIND = 2, POLL_MCAST_IF = 4 }; + enum PollStep : uint8_t { + POLL_JOIN = 1, + POLL_BIND = 2, + POLL_MCAST_IF = 4, + POLL_SRC_BIND = 8, + POLL_SEND_HELD = 16, // only in poll_failed_: the held IPv6 sends were logged + }; union SockaddrEntry { sockaddr sa; @@ -52,7 +63,8 @@ class UDPComponent final : public Component { /// One pass of the network poll; true once nothing is left to do. bool network_poll_(); - void set_poll_warning_(PollStep step, const LogString *what); + /// err is the errno of the failed call, or 0 when no call failed. + void set_poll_warning_(PollStep step, const LogString *what, int err); LazyCallbackManager)> packet_listeners_{}; std::unique_ptr broadcast_socket_ = nullptr; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 9e4af419aea..842e6994a3e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -688,6 +688,7 @@ #define USE_OPENTHREAD #define USE_OPENTHREAD_SRP #define USE_OPENTHREAD_TLVS "0e080000000000010000" +#define USE_UDP_OMR_SOURCE #define USE_ZIGBEE #endif #if defined(USE_ESP32_VARIANT_ESP32S3) diff --git a/tests/component_tests/udp/test_udp_omr_source.py b/tests/component_tests/udp/test_udp_omr_source.py new file mode 100644 index 00000000000..5461ac74ad0 --- /dev/null +++ b/tests/component_tests/udp/test_udp_omr_source.py @@ -0,0 +1,89 @@ +"""Tests for when the udp component binds its IPv6 send socket to the OMR address.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +CONFIG = """ +esphome: + name: test + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +openthread: + tlv: 0E080000000000010000 + +network: + enable_ipv6: true + +udp: + addresses: [{addresses}] +""" + + +@pytest.mark.parametrize( + ("addresses", "expected"), + [ + pytest.param(["2001:db8::1"], True, id="global_unicast"), + pytest.param(["ff05::1"], True, id="site_multicast"), + pytest.param(["ff03::1", "ff02::1"], False, id="mesh_multicast"), + pytest.param(["fd11:22::1"], False, id="unique_local"), + pytest.param(["ff03::1", "fd11:22::1", "2001:db8::1"], True, id="mixed"), + ], +) +def test_omr_source_only_for_off_mesh_destinations( + generate_main: Callable[[str | Path], str], + tmp_path: Path, + addresses: list[str], + expected: bool, +) -> None: + """Only a destination outside the Thread mesh needs the routable source bind.""" + config = tmp_path / "udp_omr.yaml" + config.write_text(CONFIG.format(addresses=", ".join(f'"{a}"' for a in addresses))) + main_cpp = generate_main(config) + + assert any(d.name == "USE_UDP_OMR_SOURCE" for d in CORE.defines) is expected + assert ("set_omr_source()" in main_cpp) is expected + + +TWO_INSTANCES = """ +esphome: + name: test + +nrf52: + board: adafruit_itsybitsy_nrf52840 + bootloader: adafruit_nrf52_sd140_v6 + +openthread: + tlv: 0E080000000000010000 + +network: + enable_ipv6: true + +udp: + - id: udp_mesh + port: 18511 + addresses: ["ff03::1", "fd11:22::1"] + - id: udp_global + port: 18512 + addresses: ["2001:db8::1"] +""" + + +def test_omr_source_setter_only_for_instances_that_need_it( + generate_main: Callable[[str | Path], str], + tmp_path: Path, +) -> None: + """Only the instance with an off-mesh destination waits for the OMR source bind.""" + config = tmp_path / "udp_omr_two.yaml" + config.write_text(TWO_INSTANCES) + main_cpp = generate_main(config) + + assert "udp_global->set_omr_source();" in main_cpp + assert "udp_mesh->set_omr_source();" not in main_cpp + assert main_cpp.count("set_omr_source()") == 1 From dddacfa239ed8e71a23922543bc6616feec50f48 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:16:26 -1000 Subject: [PATCH 088/170] [libretiny] Flag a BK7238 board layout its bootloader will not apply updates from (#21743) --- esphome/components/libretiny/lt_component.cpp | 23 +++++++++++++++++++ .../ota/ota_backend_arduino_libretiny.cpp | 7 ++++++ tests/components/ota/test.bk7238-ard.yaml | 3 +++ 3 files changed, 33 insertions(+) create mode 100644 tests/components/ota/test.bk7238-ard.yaml diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index e1bedb342b8..5cb993acc39 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -8,6 +8,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#if defined(USE_LIBRETINY_VARIANT_BK7238) && defined(USE_OTA) +#include +#endif + namespace esphome::libretiny { ESPHOME_LOG_TAG(TAG, "libretiny"); @@ -55,6 +59,25 @@ void LTComponent::dump_config() { } #endif +#if defined(USE_LIBRETINY_VARIANT_BK7238) && defined(USE_OTA) && \ + (FLASH_DOWNLOAD_OFFSET == 0x132000 || FLASH_DOWNLOAD_OFFSET == 0x12C000) + // The other layout's bootloader, known by the CRC-32 of its first 257 bytes as bk7231tools identifies it, + // applies updates from its own download offset, so this build's updates are never applied. +#if FLASH_DOWNLOAD_OFFSET == 0x132000 + static constexpr uint32_t OTHER_BOOTLOADER_CRC = 0xF42F8C32; + static constexpr const char *OTHER_BOOTLOADER = "Tuya T1 2.0.0; set board: generic-bk7238-tuya"; +#else + static constexpr uint32_t OTHER_BOOTLOADER_CRC = 0x6BEB0924; + static constexpr const char *OTHER_BOOTLOADER = "Beken 1.0.14; set board: generic-bk7238"; +#endif + uint8_t bootloader[257]; + if (lt_flash_read(0, bootloader, sizeof(bootloader)) == sizeof(bootloader) && + fdb_calc_crc32(0, bootloader, sizeof(bootloader)) == OTHER_BOOTLOADER_CRC) { + ESP_LOGE(TAG, "OTA updates are not applied: bootloader is %s, upload, then reset (the upload reports an error)", + OTHER_BOOTLOADER); + } +#endif + #ifdef USE_TEXT_SENSOR PUBLISH_STATE(this->version_text_sensor_, <_BANNER_STR[10]); #endif // USE_TEXT_SENSOR diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 33af42b6a7a..6a6d58c31fd 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -64,6 +64,13 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { } uint8_t error = Update.getError(); +#ifdef USE_BK72XX + // The image was written to its own layout's download partition, not this build's + if (error == UPDATE_ERROR_ACTIVATE) { + ESP_LOGE(TAG, "End error: %d, image is for another flash layout; check that board: matches this module", error); + return OTA_RESPONSE_ERROR_UPDATE_END; + } +#endif ESP_LOGE(TAG, "End error: %d", error); return OTA_RESPONSE_ERROR_UPDATE_END; diff --git a/tests/components/ota/test.bk7238-ard.yaml b/tests/components/ota/test.bk7238-ard.yaml new file mode 100644 index 00000000000..3b32457cf40 --- /dev/null +++ b/tests/components/ota/test.bk7238-ard.yaml @@ -0,0 +1,3 @@ +packages: + wifi: !include wifi.yaml + ota: !include common.yaml From 2019ebb49f99f4a70188face8c02f4c8fe491c77 Mon Sep 17 00:00:00 2001 From: Pat Satyshur Date: Sun, 1 Nov 2026 16:38:23 -0600 Subject: [PATCH 089/170] [ht16k33_char] Add support for HT16K33 based character displays (#8520) Co-authored-by: Djordje Mandic <6750655+DjordjeMandic@users.noreply.github.com> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/ht16k33_char/__init__.py | 2 + esphome/components/ht16k33_char/display.py | 114 ++++++++ .../components/ht16k33_char/ht16k33_char.cpp | 251 ++++++++++++++++++ .../components/ht16k33_char/ht16k33_char.h | 106 ++++++++ esphome/core/defines.h | 2 + tests/components/ht16k33_char/common.yaml | 21 ++ .../ht16k33_char/test.esp32-idf.yaml | 3 + .../ht16k33_char/test.esp8266-ard.yaml | 3 + .../ht16k33_char/test.rp2040-ard.yaml | 3 + 10 files changed, 506 insertions(+) create mode 100644 esphome/components/ht16k33_char/__init__.py create mode 100644 esphome/components/ht16k33_char/display.py create mode 100644 esphome/components/ht16k33_char/ht16k33_char.cpp create mode 100644 esphome/components/ht16k33_char/ht16k33_char.h create mode 100644 tests/components/ht16k33_char/common.yaml create mode 100644 tests/components/ht16k33_char/test.esp32-idf.yaml create mode 100644 tests/components/ht16k33_char/test.esp8266-ard.yaml create mode 100644 tests/components/ht16k33_char/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 3515f55c8fc..9fe56375e88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -294,6 +294,7 @@ esphome/components/honeywellabp2_i2c/* @jpfaff esphome/components/host/* @clydebarrow @esphome/core esphome/components/host/time/* @clydebarrow esphome/components/hrxl_maxsonar_wr/* @netmikey +esphome/components/ht16k33_char/* @ilikecake esphome/components/hte501/* @Stock-M esphome/components/http_request/ota/* @oarcher esphome/components/http_request/update/* @jesserockz diff --git a/esphome/components/ht16k33_char/__init__.py b/esphome/components/ht16k33_char/__init__.py new file mode 100644 index 00000000000..a6e55ab3550 --- /dev/null +++ b/esphome/components/ht16k33_char/__init__.py @@ -0,0 +1,2 @@ +DOMAIN = "ht16k33_char" +CODEOWNERS = ["@ilikecake"] diff --git a/esphome/components/ht16k33_char/display.py b/esphome/components/ht16k33_char/display.py new file mode 100644 index 00000000000..cf862103cf6 --- /dev/null +++ b/esphome/components/ht16k33_char/display.py @@ -0,0 +1,114 @@ +import esphome.codegen as cg +from esphome.components import display, i2c +import esphome.config_validation as cv +from esphome.const import CONF_ADDRESS, CONF_DEVICE, CONF_ID, CONF_INTENSITY +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["unicode"] + +CONF_MAX_BUFFER_LENGTH = "max_buffer_length" +CONF_SECONDARY_DISPLAYS = "secondary_displays" +CONF_ADD_CHARACTERS = "add_characters" +CONF_MESSAGE_ID = "message_id" + +# The only board so far +DEVICE_ADAFRUIT_7_SEG_056IN = "ADAFRUIT_7_SEG_.56IN" + +# Addresses 0x70 to 0x77: the primary plus seven +MAX_SECONDARY_DISPLAYS = 7 +# FORMAT_BUFFER_SIZE in ht16k33_char.cpp is one more +MAX_BUFFER_LENGTH = 64 + +ht16k33_char_ns = cg.esphome_ns.namespace("ht16k33_char") +HT16K33CharDisplay = ht16k33_char_ns.class_( + "HT16K33CharDisplay", cg.PollingComponent, i2c.I2CDevice +) +HT16K33CharDisplayRef = HT16K33CharDisplay.operator("ref") + +_request_secondary_slot = cg.slot_counter("HT16K33_CHAR_SECONDARY_COUNT") + + +def glyph_character(value: object) -> str: + """One character the glyph table can hold; a plain function, as the schema builder needs for a key.""" + value = cv.string_strict(value) + # 0 ends the table and can never be printed + if len(value) != 1 or not 0 < ord(value) <= 0xFFFF: + raise cv.Invalid("Expected one character from U+0001 to U+FFFF") + if value in ".:": + raise cv.Invalid( + "'.' and ':' light the decimal point and colon and cannot be redefined" + ) + return value + + +SECONDARY_DISPLAY_SCHEMA = cv.Schema({cv.Required(CONF_ADDRESS): cv.i2c_address}) + + +def _validate(config: ConfigType) -> ConfigType: + addresses = [config[CONF_ADDRESS]] + [ + conf[CONF_ADDRESS] for conf in config.get(CONF_SECONDARY_DISPLAYS, []) + ] + if len(set(addresses)) != len(addresses): + raise cv.Invalid("Every display needs its own address") + return config + + +CONFIG_SCHEMA = cv.All( + display.BASIC_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(HT16K33CharDisplay), + cv.GenerateID(CONF_MESSAGE_ID): cv.declare_id(cg.char), + cv.Required(CONF_DEVICE): cv.one_of( + DEVICE_ADAFRUIT_7_SEG_056IN, upper=True + ), + cv.Optional(CONF_MAX_BUFFER_LENGTH, default=8): cv.int_range( + min=4, max=MAX_BUFFER_LENGTH + ), + cv.Optional(CONF_INTENSITY, default=15): cv.int_range(min=0, max=15), + cv.Optional(CONF_SECONDARY_DISPLAYS): cv.All( + cv.ensure_list(SECONDARY_DISPLAY_SCHEMA), + cv.Length(max=MAX_SECONDARY_DISPLAYS), + ), + cv.Optional(CONF_ADD_CHARACTERS): cv.Schema( + {glyph_character: cv.hex_uint8_t} + ), + } + ) + .extend(cv.polling_component_schema("10s")) + .extend(i2c.i2c_device_schema(0x70)), + _validate, +) + + +async def to_code(config: ConfigType) -> None: + capacity = config[CONF_MAX_BUFFER_LENGTH] + message = config[CONF_MESSAGE_ID].id + # Room for the terminator + cg.add_global(cg.RawStatement(f"static char {message}[{capacity + 1}];")) + var = cg.new_Pvariable( + config[CONF_ID], cg.RawExpression(message), capacity, config[CONF_INTENSITY] + ) + await display.register_display(var, config) + await i2c.register_i2c_device(var, config) + + for conf in config.get(CONF_SECONDARY_DISPLAYS, []): + _request_secondary_slot(str(var)) + cg.add(var.add_secondary_display(conf[CONF_ADDRESS])) + + glyphs = { + ord(char): segments + for char, segments in config.get(CONF_ADD_CHARACTERS, {}).items() + } + if glyphs: + # One word per entry, code point low and segments high; 0 ends the table + table = cg.shared_progmem_array( + "ht16k33_char_glyphs", + cg.uint32, + [codepoint | segments << 16 for codepoint, segments in glyphs.items()] + + [0], + ) + cg.add_define("USE_HT16K33_CHAR_GLYPH_OVERRIDES") + cg.add(var.set_glyph_overrides(table)) + + await display.add_writer(var, config, HT16K33CharDisplayRef) diff --git a/esphome/components/ht16k33_char/ht16k33_char.cpp b/esphome/components/ht16k33_char/ht16k33_char.cpp new file mode 100644 index 00000000000..82a2a680a1f --- /dev/null +++ b/esphome/components/ht16k33_char/ht16k33_char.cpp @@ -0,0 +1,251 @@ +#include "ht16k33_char.h" + +#include "esphome/components/unicode/unicode.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::ht16k33_char { + +ESPHOME_LOG_TAG(TAG, "display.ht16k33_char"); + +// Commands; the low bits carry the argument +static constexpr uint8_t COMMAND_DISPLAY_DATA = 0x00; +static constexpr uint8_t COMMAND_SYSTEM_SETUP = 0x20; // bit 0 runs the oscillator +static constexpr uint8_t COMMAND_DISPLAY_SETUP = 0x80; // bit 0 turns the display on, bits 1 and 2 pick the blink rate +static constexpr uint8_t BLINK_MASK = 0x06; +static constexpr uint8_t DISPLAY_SETUP_MASK = 0x07; +static constexpr uint8_t STANDBY_FLAG = 0x80; // kept in display_setup_, not sent +static constexpr uint8_t COMMAND_DIMMING = 0xE0; // bits 0 to 3 are the duty cycle, 1/16 to 16/16 +static constexpr uint8_t OSCILLATOR_ON = 0x01; +static constexpr uint8_t DISPLAY_ON = 0x01; +static constexpr uint8_t MAX_BLINK = 3; +static constexpr uint8_t MAX_INTENSITY = 15; + +// MAX_BUFFER_LENGTH in display.py plus the terminator +static constexpr size_t FORMAT_BUFFER_SIZE = 64 + 1; + +// A frame is the data address followed by the 16 bytes of display memory +static constexpr uint8_t DISPLAY_RAM_SIZE = 16; +static constexpr uint8_t FRAME_SIZE = DISPLAY_RAM_SIZE + 1; + +// Adafruit 0.56" backpack: digits in bytes 0, 2, 6 and 8, colon in byte 4 +static constexpr uint8_t DIGITS_PER_CHIP = 4; +static constexpr uint8_t DIGIT_RAM_OFFSET[DIGITS_PER_CHIP] PROGMEM = {0, 2, 6, 8}; +static constexpr uint8_t COLON_RAM_OFFSET = 4; +static constexpr uint8_t COLON_BIT = 0x02; +static constexpr uint8_t COLON_DIGIT = 2; // the colon sits between the second and third digit +static constexpr uint8_t DECIMAL_POINT_BIT = 0x80; + +// ASCII 0x20 to 0x7E; 0 is a dark digit +// +// A +// --- +// F | | B +// -G- +// E | | C +// --- +// D +// bit 0 is A, bit 6 is G +static constexpr uint8_t FONT[] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // space ! " # $ % & ' + 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, // ( ) * + , - . / + 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, // 0 1 2 3 4 5 6 7 + 0x7F, 0x6F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 8 9 : ; < = > ? + 0x00, 0x77, 0x00, 0x39, 0x00, 0x79, 0x71, 0x3D, // @ A B C D E F G + 0x76, 0x30, 0x0E, 0x00, 0x38, 0x00, 0x37, 0x3F, // H I J K L M N O + 0x73, 0x00, 0x00, 0x6D, 0x00, 0x3E, 0x00, 0x00, // P Q R S T U V W + 0x00, 0x6E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, // X Y Z [ \ ] ^ _ + 0x00, 0x00, 0x7C, 0x58, 0x5E, 0x00, 0x00, 0x00, // ` a b c d e f g + 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5C, // h i j k l m n o + 0x00, 0x00, 0x50, 0x00, 0x78, 0x1C, 0x00, 0x00, // p q r s t u v w + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // x y z { | } ~ +}; + +void HT16K33CharDisplay::setup() { + // Cleared before the display turns on, so a message from before an MCU-only reboot never shows + if (!this->write_all_(this->system_setup_()) || !this->write_frames_(0) || + !this->write_all_(this->display_setup_command_()) || !this->write_all_(COMMAND_DIMMING | this->intensity_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void HT16K33CharDisplay::update() { + if (this->writer_.has_value()) { + this->clear(); + (*this->writer_)(*this); + } + this->status_comm_result(this->write_frames_(this->length_)); +} + +void HT16K33CharDisplay::dump_config() { + ESP_LOGCONFIG(TAG, + "HT16K33 Character Display:\n" + " Message buffer: %u bytes\n" + " Intensity: %u", + this->capacity_, this->intensity_); + LOG_I2C_DEVICE(this); +#ifdef HT16K33_CHAR_SECONDARY_COUNT + for (uint8_t address : this->secondary_addresses_) { + ESP_LOGCONFIG(TAG, " Secondary address: 0x%02X", address); + } +#endif + LOG_UPDATE_INTERVAL(this); +} + +uint8_t HT16K33CharDisplay::system_setup_() const { + return COMMAND_SYSTEM_SETUP | ((this->display_setup_ & STANDBY_FLAG) ? 0 : OSCILLATOR_ON); +} + +uint8_t HT16K33CharDisplay::display_setup_command_() const { + return COMMAND_DISPLAY_SETUP | (this->display_setup_ & DISPLAY_SETUP_MASK); +} + +bool HT16K33CharDisplay::write_all_(uint8_t command) { + bool ok = true; + this->for_each_chip_([&](uint8_t address) { + if (this->bus_->write(address, &command, 1) != i2c::ERROR_OK) + ok = false; + }); + return ok; +} + +void HT16K33CharDisplay::send_(uint8_t command) { + if (this->is_ready()) + this->status_comm_result(this->write_all_(command)); +} + +uint8_t HT16K33CharDisplay::find_segments_(uint32_t codepoint) const { +#ifdef USE_HT16K33_CHAR_GLYPH_OVERRIDES + // The define is global; a display without add_characters has no table + if (this->glyph_overrides_ != nullptr) { + for (const uint32_t *entry = this->glyph_overrides_; *entry != 0; entry++) { + if ((*entry & 0xFFFF) == codepoint) + return *entry >> 16; + } + } +#endif + if (codepoint < ' ' || codepoint > '~') + return 0; + return progmem_read_byte(&FONT[codepoint - ' ']); +} + +// A decimal point lights on the digit before it and a colon only in its place; neither takes a digit +bool HT16K33CharDisplay::write_frames_(uint8_t length) { + uint8_t pos = 0; + bool ok = true; + this->for_each_chip_([&](uint8_t address) { + uint8_t frame[FRAME_SIZE] = {COMMAND_DISPLAY_DATA}; + uint8_t *ram = frame + 1; + uint8_t digit = 0; + while (pos < length) { + size_t consumed; + uint32_t codepoint = unicode::extract_unicode_codepoint(this->message_ + pos, &consumed); + if (consumed == 0) { + consumed = 1; + codepoint = 0; + } + if (codepoint == '.') { + if (digit > 0) + ram[progmem_read_byte(&DIGIT_RAM_OFFSET[digit - 1])] |= DECIMAL_POINT_BIT; + } else if (codepoint == ':') { + if (digit == COLON_DIGIT) + ram[COLON_RAM_OFFSET] |= COLON_BIT; + } else { + if (digit == DIGITS_PER_CHIP) + break; // the next chip takes this character + ram[progmem_read_byte(&DIGIT_RAM_OFFSET[digit++])] = this->find_segments_(codepoint); + } + pos += consumed; + } + if (this->bus_->write(address, frame, FRAME_SIZE) != i2c::ERROR_OK) + ok = false; + }); + return ok; +} + +uint8_t HT16K33CharDisplay::print(uint8_t pos, const char *str) { + if (pos >= this->capacity_) + return 0; + if (pos > this->length_) + memset(this->message_ + this->length_, ' ', pos - this->length_); + uint8_t len = std::min(strlen(str), this->capacity_ - pos); + // Do not cut a multi-byte character; continuation bytes are 10xxxxxx + while (len > 0 && (str[len] & 0xC0) == 0x80) + len--; + memcpy(this->message_ + pos, str, len); + this->length_ = std::max(this->length_, pos + len); + this->message_[this->length_] = '\0'; + return len; +} + +uint8_t HT16K33CharDisplay::vprintf_(uint8_t pos, const char *format, va_list arg) { + char buffer[FORMAT_BUFFER_SIZE]; + if (vsnprintf(buffer, sizeof(buffer), format, arg) <= 0) + return 0; + return this->print(pos, buffer); +} + +uint8_t HT16K33CharDisplay::printf(uint8_t pos, const char *format, ...) { + va_list arg; + va_start(arg, format); + const uint8_t written = this->vprintf_(pos, format, arg); + va_end(arg); + return written; +} + +uint8_t HT16K33CharDisplay::printf(const char *format, ...) { + va_list arg; + va_start(arg, format); + const uint8_t written = this->vprintf_(0, format, arg); + va_end(arg); + return written; +} + +uint8_t HT16K33CharDisplay::strftime(uint8_t pos, const char *format, ESPTime time) { + char buffer[FORMAT_BUFFER_SIZE]; + if (time.strftime(buffer, sizeof(buffer), format) == 0) + return 0; + return this->print(pos, buffer); +} + +uint8_t HT16K33CharDisplay::clock_display(uint8_t pos, bool show_leading_zero, bool use_ampm, ESPTime time) { + char buffer[6]; + if (time.strftime(buffer, sizeof(buffer), use_ampm ? "%I:%M" : "%H:%M") == 0) + return 0; + if (!show_leading_zero && buffer[0] == '0') + buffer[0] = ' '; + return this->print(pos, buffer); +} + +void HT16K33CharDisplay::set_intensity(uint8_t intensity) { + this->intensity_ = std::min(intensity, MAX_INTENSITY); + this->send_(COMMAND_DIMMING | this->intensity_); +} + +void HT16K33CharDisplay::set_blink(uint8_t blink) { + if (blink > MAX_BLINK) + blink = 0; + this->display_setup_ = (this->display_setup_ & ~BLINK_MASK) | (blink << 1); + this->send_(this->display_setup_command_()); +} + +void HT16K33CharDisplay::display_off(bool turn_off) { + assign_bits(this->display_setup_, DISPLAY_ON, !turn_off); + this->send_(this->display_setup_command_()); +} + +void HT16K33CharDisplay::display_standby(bool standby) { + assign_bits(this->display_setup_, STANDBY_FLAG, standby); + this->send_(this->system_setup_()); +} + +void HT16K33CharDisplay::blank() { + if (this->is_ready()) + this->status_comm_result(this->write_frames_(0)); +} + +} // namespace esphome::ht16k33_char diff --git a/esphome/components/ht16k33_char/ht16k33_char.h b/esphome/components/ht16k33_char/ht16k33_char.h new file mode 100644 index 00000000000..03a1438ccc0 --- /dev/null +++ b/esphome/components/ht16k33_char/ht16k33_char.h @@ -0,0 +1,106 @@ +#pragma once + +#include "esphome/components/display/display.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/time.h" + +namespace esphome::ht16k33_char { + +class HT16K33CharDisplay; +using ht16k33_char_writer_t = display::DisplayWriter; + +class HT16K33CharDisplay final : public PollingComponent, public i2c::I2CDevice { + public: + /// message holds capacity bytes of UTF-8 text; intensity is 0 (dimmest) to 15 + HT16K33CharDisplay(char *message, uint8_t capacity, uint8_t intensity) + : message_(message), capacity_(capacity), intensity_(intensity) {} + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + void set_writer(ht16k33_char_writer_t &&writer) { this->writer_ = std::move(writer); } + /// 0 (dimmest) to 15, clamped + void set_intensity(uint8_t intensity); +#ifdef HT16K33_CHAR_SECONDARY_COUNT + void add_secondary_display(uint8_t address) { this->secondary_addresses_.push_back(address); } +#endif +#ifdef USE_HT16K33_CHAR_GLYPH_OVERRIDES + /// add_characters entries, each a word with the code point in the low half and the segments in the high half, + /// ending with 0. A word is read from flash directly on every platform. + void set_glyph_overrides(const uint32_t *glyphs) { this->glyph_overrides_ = glyphs; } +#endif + + /// For printing from automations; the lambda starts from an empty message + void clear() { this->length_ = 0; } + /// Write str at byte offset pos, where a `.`, a `:` and each byte of a multi-byte character count as one. + /// Returns the bytes written. + uint8_t print(uint8_t pos, const char *str); + uint8_t print(const char *str) { return this->print(0, str); } + uint8_t printf(uint8_t pos, const char *format, ...) __attribute__((format(printf, 3, 4))); + uint8_t printf(const char *format, ...) __attribute__((format(printf, 2, 3))); + uint8_t strftime(uint8_t pos, const char *format, ESPTime time) __attribute__((format(strftime, 3, 0))); + uint8_t strftime(const char *format, ESPTime time) __attribute__((format(strftime, 2, 0))) { + return this->strftime(0, format, time); + } + /// HH:MM, or H:MM without the leading zero; 12-hour when use_ampm + uint8_t clock_display(uint8_t pos, bool show_leading_zero, bool use_ampm, ESPTime time); + + /// 0 steady, 1 at 2 Hz, 2 at 1 Hz, 3 at 0.5 Hz; before setup only the value is kept + void set_blink(uint8_t blink); + void display_off(bool turn_off); + /// Stop the oscillator, the lowest power state; before setup only the value is kept + void display_standby(bool standby); + /// Clear the display memory; the next update redraws the message + void blank(); + + protected: + uint8_t chip_count_() const { +#ifdef HT16K33_CHAR_SECONDARY_COUNT + return 1 + this->secondary_addresses_.size(); +#else + return 1; +#endif + } + uint8_t chip_address_(uint8_t chip) const { +#ifdef HT16K33_CHAR_SECONDARY_COUNT + if (chip > 0) + return this->secondary_addresses_[chip - 1]; +#endif + return this->address_; + } + // One call site, so a small f inlines + template void for_each_chip_(F &&f) { + for (uint8_t chip = 0, count = this->chip_count_(); chip < count; chip++) + f(this->chip_address_(chip)); + } + uint8_t system_setup_() const; + uint8_t display_setup_command_() const; + bool write_all_(uint8_t command); + /// Send a command to every chip once set up, and report the result + void send_(uint8_t command); + /// Draw the first length bytes of the message; the chips it does not reach are cleared + bool write_frames_(uint8_t length); + uint8_t find_segments_(uint32_t codepoint) const; + uint8_t vprintf_(uint8_t pos, const char *format, va_list arg); + + char *const message_; + const uint8_t capacity_; + uint8_t length_{0}; + uint8_t intensity_; + /// Display setup register: on bit and blink rate; bit 7 remembers standby, which the register does not use + uint8_t display_setup_{1}; + ht16k33_char_writer_t writer_{}; +#ifdef HT16K33_CHAR_SECONDARY_COUNT + StaticVector secondary_addresses_; +#endif +#ifdef USE_HT16K33_CHAR_GLYPH_OVERRIDES + const uint32_t *glyph_overrides_{nullptr}; +#endif +}; + +} // namespace esphome::ht16k33_char diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 842e6994a3e..83279dee76d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -114,6 +114,8 @@ #define USE_HOERMANN_HCP_IDENTITY #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE +#define USE_HT16K33_CHAR_GLYPH_OVERRIDES +#define HT16K33_CHAR_SECONDARY_COUNT 1 #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_FULL_DUPLEX #define USE_I2S_AUDIO_SPDIF_MODE diff --git a/tests/components/ht16k33_char/common.yaml b/tests/components/ht16k33_char/common.yaml new file mode 100644 index 00000000000..d5f28bacc0f --- /dev/null +++ b/tests/components/ht16k33_char/common.yaml @@ -0,0 +1,21 @@ +display: + - platform: ht16k33_char + device: ADAFRUIT_7_SEG_.56IN + address: 0x70 + intensity: 10 + max_buffer_length: 12 + secondary_displays: + - address: 0x71 + add_characters: + "°": 0x63 + "r": 0x50 + "Y": 0 + lambda: |- + it.print("12:34"); + it.printf(5, "%d°", 21); + # No add_characters, beside one that has them + - platform: ht16k33_char + device: ADAFRUIT_7_SEG_.56IN + address: 0x72 + lambda: |- + it.print("1234"); diff --git a/tests/components/ht16k33_char/test.esp32-idf.yaml b/tests/components/ht16k33_char/test.esp32-idf.yaml new file mode 100644 index 00000000000..ff0a9fe2a8a --- /dev/null +++ b/tests/components/ht16k33_char/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + ht16k33_char: !include common.yaml diff --git a/tests/components/ht16k33_char/test.esp8266-ard.yaml b/tests/components/ht16k33_char/test.esp8266-ard.yaml new file mode 100644 index 00000000000..e29e48286d7 --- /dev/null +++ b/tests/components/ht16k33_char/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + ht16k33_char: !include common.yaml diff --git a/tests/components/ht16k33_char/test.rp2040-ard.yaml b/tests/components/ht16k33_char/test.rp2040-ard.yaml new file mode 100644 index 00000000000..19ac81ebb57 --- /dev/null +++ b/tests/components/ht16k33_char/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + ht16k33_char: !include common.yaml From acaf582bf6d3a896be773ac79bfd6f1af8c20453 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:59:41 +0000 Subject: [PATCH 090/170] Bump aioesphomeapi from 46.25.0 to 46.25.1 (#22137) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7079bc85bba..ff467008ac3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.6.0 click==8.5.0 -aioesphomeapi==46.25.0 +aioesphomeapi==46.25.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 23e66187a049d5013e060b7be38a5a9329a9c7cc Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:18:13 +1300 Subject: [PATCH 091/170] [epaper_spi] Add Good Display GDEY029T94 2.9" model (#22128) --- .../components/epaper_spi/models/ssd1683.py | 11 ++++++ tests/component_tests/epaper_spi/test_init.py | 30 ++++++++++++++++ .../epaper_spi/test.esp32-s3-idf.yaml | 35 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/esphome/components/epaper_spi/models/ssd1683.py b/esphome/components/epaper_spi/models/ssd1683.py index 52a17074910..f6cadd7d143 100644 --- a/esphome/components/epaper_spi/models/ssd1683.py +++ b/esphome/components/epaper_spi/models/ssd1683.py @@ -31,3 +31,14 @@ goodisplay_gdey042t81 = ssd1683.extend( width=400, height=300, ) + +# Good Display GDEY029T94, 2.9" mono 128x296 (SSD1680), also sold by Seeed as 029BN-T94-D2. +# Replaces the waveshare_epaper model gdey029t94. +goodisplay_gdey029t94 = ssd1683.extend( + "goodisplay-gdey029t94-2.9", + width=128, + height=296, +) + +# Name printed on the panel's label as sold by Seeed +goodisplay_gdey029t94.extend("029bn-t94-d2") diff --git a/tests/component_tests/epaper_spi/test_init.py b/tests/component_tests/epaper_spi/test_init.py index 9b45adbbad8..c6fd1293f5e 100644 --- a/tests/component_tests/epaper_spi/test_init.py +++ b/tests/component_tests/epaper_spi/test_init.py @@ -404,6 +404,36 @@ def test_ssd1681_defaults_and_driver_class( assert model.get_dimensions(config) == (200, 200) +def test_ssd1683_named_models( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The named SSD1683 models use the SSD1683 driver class with their own dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + + set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19}) + + for name, dimensions in ( + ("goodisplay-gdey042t81-4.2", (400, 300)), + ("goodisplay-gdey029t94-2.9", (128, 296)), + ("029BN-T94-D2", (128, 296)), + ): + model = MODELS[name.upper()] + assert model.class_name == "EPaperSSD1683" + config = run_schema_validation( + { + "id": "test_display", + "model": name, + "dc_pin": 21, + "full_update_every": 5, + } + ) + assert model.get_dimensions(config) == dimensions + + def test_ssd1681_needs_reset_pin( set_core_config: SetCoreConfigCallable, set_component_config: Callable[[str, Any], None], diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index 4abb91ad962..d60201b6a4f 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -287,6 +287,41 @@ display: allow_other_uses: true number: GPIO4 + # Good Display GDEY029T94 2.9" mono e-paper (128x296, SSD1680) + - platform: epaper_spi + spi_id: spi_bus + model: goodisplay-gdey029t94-2.9 + full_update_every: 5 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + + # Seeed 029BN-T94-D2, the GDEY029T94 panel under its label name + - platform: epaper_spi + spi_id: spi_bus + model: 029BN-T94-D2 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + # Waveshare 1.54" V2 mono e-paper (200x200, SSD1681) - platform: epaper_spi spi_id: spi_bus From 2c5cb6b06321476d3b50e8739a8d682601598b92 Mon Sep 17 00:00:00 2001 From: Francesco Pace Date: Mon, 2 Nov 2026 00:29:40 +0100 Subject: [PATCH 092/170] [espectre] Update to SDK 3.1.0; add runtime config entities and CSI diagnostics (#22132) --- esphome/components/espectre/__init__.py | 92 +++++- esphome/components/espectre/espectre.cpp | 261 +++++++++++++----- esphome/components/espectre/espectre.h | 71 ++++- esphome/components/espectre/number.py | 66 +++++ esphome/components/espectre/select.py | 71 ++++- .../components/espectre/sensor/__init__.py | 7 + .../espectre/sensor/espectre_diagnostics.cpp | 6 + .../espectre/sensor/espectre_diagnostics.h | 3 + esphome/components/espectre/switch.py | 34 +++ esphome/idf_component.yml | 2 +- tests/components/espectre/__init__.py | 13 +- tests/components/espectre/common.yaml | 29 +- tests/components/espectre/espectre_sdk.h | 99 +++++++ .../sensor/espectre_diagnostics_test.cpp | 4 + .../espectre/test.esp32-c6-idf.yaml | 9 + .../espectre/test.esp32-s2-idf.yaml | 1 + .../espectre/test.esp32-s3-idf.yaml | 2 + tests/components/espectre/test_espectre.cpp | 154 +++++++++++ .../espectre/validate-legacy.esp32-idf.yaml | 10 + .../components/espectre/test_init.py | 20 ++ 20 files changed, 872 insertions(+), 82 deletions(-) create mode 100644 esphome/components/espectre/number.py create mode 100644 esphome/components/espectre/switch.py create mode 100644 tests/components/espectre/validate-legacy.esp32-idf.yaml diff --git a/esphome/components/espectre/__init__.py b/esphome/components/espectre/__init__.py index 8323a56db0a..ea2214f6614 100644 --- a/esphome/components/espectre/__init__.py +++ b/esphome/components/espectre/__init__.py @@ -2,6 +2,7 @@ from ipaddress import IPv4Address import esphome.codegen as cg from esphome.components import esp32, wifi +from esphome.components.const import CONF_ENABLED, CONF_WINDOW from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, @@ -12,7 +13,7 @@ from esphome.components.esp32.const import ( ) from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_THRESHOLD, CONF_WINDOW_SIZE from esphome.core import CORE from esphome.types import ConfigType @@ -25,9 +26,18 @@ CONF_DETECTION_ALGORITHM = "detection_algorithm" CONF_CSI_CAPTURE_PROFILE = "csi_capture_profile" CONF_TRAFFIC_GENERATOR_MODE = "traffic_generator_mode" CONF_TRAFFIC_GENERATOR_TARGET_IP = "traffic_generator_target_ip" +CONF_TRAFFIC_GENERATOR_TARGET_MAC = "traffic_generator_target_mac" +CONF_CSI_SAMPLE_SPACING = "csi_sample_spacing" CONF_CSI_TRAFFIC_MULTICAST_GROUP = "csi_traffic_multicast_group" CONF_MOTION_ON_HITS = "motion_on_hits" CONF_MOTION_OFF_HITS = "motion_off_hits" +CONF_CSI_TARGET_PPS = "csi_target_pps" +CONF_EVALUATION_INTERVAL = "evaluation_interval" +CONF_LOWPASS_FILTER = "lowpass_filter" +CONF_HAMPEL_FILTER = "hampel_filter" +CONF_CUTOFF = "cutoff" +MOTION_HITS_MIN = 1 +MOTION_HITS_MAX = 20 # Fully qualified: a bare espectre:: in main.cpp would clash with the SDK namespace. espectre_ns = cg.global_ns.namespace("esphome").namespace("espectre") @@ -36,10 +46,12 @@ sdk_ns = cg.global_ns.namespace("::espectre") DetectionAlgorithm = sdk_ns.enum("DetectionAlgorithm", is_class=True) CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True) TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True) +TemporalCsiSpacing = sdk_ns.enum("TemporalCsiSpacing", is_class=True) WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True) DETECTION_ALGORITHMS = DetectionAlgorithm.options("lightweight", "high_accuracy") CSI_CAPTURE_PROFILES = CsiCapturePolicy.options("auto", "lltf", "ht_vht") +CSI_SAMPLE_SPACINGS = TemporalCsiSpacing.options("half_slot", "distinct") TRAFFIC_GENERATOR_MODES = { "ping": TrafficGeneratorMode.PING, "dns": TrafficGeneratorMode.DNS, @@ -57,6 +69,15 @@ def validate_target_ip(value: str) -> str: return value +def validate_target_mac(value: str) -> str: + mac = cv.mac_address(value) + if mac.parts[0] & 1 or not any(mac.parts): + raise cv.Invalid( + "ESPectre traffic target must be a nonzero unicast MAC address" + ) + return str(mac).lower() + + def validate_multicast_group(value: str) -> str: """An IPv4 multicast group, or an empty string to skip joining one.""" if not (value := cv.string_strict(value).strip()): @@ -83,6 +104,14 @@ def validate_config(config: ConfigType) -> ConfigType: "wifi_raw traffic is not supported on ESP32-C6 " "or with the ht_vht CSI capture profile" ) + if ( + CONF_TRAFFIC_GENERATOR_TARGET_MAC in config + and "wifi_raw" not in supported_traffic_generator_modes(config) + ): + raise cv.Invalid( + f"{CONF_TRAFFIC_GENERATOR_TARGET_MAC} requires wifi_raw traffic, " + "which is not supported on ESP32-C6 or with the ht_vht CSI capture profile" + ) if mode in ("wifi_raw", "external") and CONF_TRAFFIC_GENERATOR_TARGET_IP in config: raise cv.Invalid(f"{mode} traffic does not use a target IP address") if mode != "external" and CONF_CSI_TRAFFIC_MULTICAST_GROUP in config: @@ -107,9 +136,47 @@ CONFIG_SCHEMA = cv.All( TRAFFIC_GENERATOR_MODES, lower=True ), cv.Optional(CONF_TRAFFIC_GENERATOR_TARGET_IP): validate_target_ip, + cv.Optional(CONF_TRAFFIC_GENERATOR_TARGET_MAC): validate_target_mac, + cv.Optional(CONF_CSI_SAMPLE_SPACING, default="half_slot"): cv.enum( + CSI_SAMPLE_SPACINGS, lower=True + ), cv.Optional(CONF_CSI_TRAFFIC_MULTICAST_GROUP): validate_multicast_group, - cv.Optional(CONF_MOTION_ON_HITS): cv.int_range(min=1, max=20), - cv.Optional(CONF_MOTION_OFF_HITS): cv.int_range(min=1, max=20), + cv.Optional(CONF_MOTION_ON_HITS): cv.int_range( + min=MOTION_HITS_MIN, max=MOTION_HITS_MAX + ), + cv.Optional(CONF_MOTION_OFF_HITS): cv.int_range( + min=MOTION_HITS_MIN, max=MOTION_HITS_MAX + ), + cv.Optional(CONF_WINDOW_SIZE): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriod(milliseconds=1000), + max=cv.TimePeriod(milliseconds=2000), + ), + ), + cv.Optional(CONF_CSI_TARGET_PPS): cv.int_range(min=1, max=500), + cv.Optional(CONF_EVALUATION_INTERVAL): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriod(milliseconds=10), + max=cv.TimePeriod(milliseconds=10000), + ), + ), + cv.Optional(CONF_LOWPASS_FILTER): cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + cv.Optional(CONF_CUTOFF): cv.All( + cv.frequency, cv.float_range(min=5.0, max=20.0) + ), + } + ), + cv.Optional(CONF_HAMPEL_FILTER): cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + cv.Optional(CONF_WINDOW): cv.int_range(min=3, max=11), + cv.Optional(CONF_THRESHOLD): cv.float_range(min=1.0, max=10.0), + } + ), } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( @@ -149,10 +216,25 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_traffic_generator_mode(config[CONF_TRAFFIC_GENERATOR_MODE])) if (target_ip := config.get(CONF_TRAFFIC_GENERATOR_TARGET_IP)) is not None: cg.add(var.set_traffic_generator_target_ip(target_ip)) + if (target_mac := config.get(CONF_TRAFFIC_GENERATOR_TARGET_MAC)) is not None: + cg.add(var.set_traffic_generator_target_mac(target_mac)) + cg.add(var.set_csi_sample_spacing(config[CONF_CSI_SAMPLE_SPACING])) if (group := config.get(CONF_CSI_TRAFFIC_MULTICAST_GROUP)) is not None: cg.add(var.set_csi_traffic_multicast_group(group)) cg.add_optional(var.set_motion_on_hits, config.get(CONF_MOTION_ON_HITS)) cg.add_optional(var.set_motion_off_hits, config.get(CONF_MOTION_OFF_HITS)) + if (window := config.get(CONF_WINDOW_SIZE)) is not None: + cg.add(var.set_window_size_ms(window.total_milliseconds)) + cg.add_optional(var.set_csi_target_pps, config.get(CONF_CSI_TARGET_PPS)) + if (interval := config.get(CONF_EVALUATION_INTERVAL)) is not None: + cg.add(var.set_evaluation_interval_ms(interval.total_milliseconds)) + if (lowpass := config.get(CONF_LOWPASS_FILTER)) is not None: + cg.add(var.set_lowpass_enabled(lowpass[CONF_ENABLED])) + cg.add_optional(var.set_lowpass_cutoff, lowpass.get(CONF_CUTOFF)) + if (hampel := config.get(CONF_HAMPEL_FILTER)) is not None: + cg.add(var.set_hampel_enabled(hampel[CONF_ENABLED])) + cg.add_optional(var.set_hampel_window, hampel.get(CONF_WINDOW)) + cg.add_optional(var.set_hampel_threshold, hampel.get(CONF_THRESHOLD)) if esp32.get_esp32_variant() == VARIANT_ESP32C5: band = CORE.config[WIFI_DOMAIN].get(wifi.CONF_BAND_MODE, "AUTO") cg.add( @@ -166,7 +248,7 @@ async def to_code(config: ConfigType) -> None: ) wifi.enable_runtime_roaming_suppression() - esp32.add_idf_component(name="francescopace/espectre", ref="3.0.0") + esp32.add_idf_component(name="francescopace/espectre", ref="3.1.0") esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_CSI_ENABLED", True) # CSI is reported once per received transmission, so aggregation hides frames from sensing. # Disabling TX aggregation also lets the SDK fix the station TX rate (6.5 Mbps on ESP32). @@ -175,3 +257,5 @@ async def to_code(config: ConfigType) -> None: esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", False) # Keep the radio awake while disconnected too, matching force_power_save_off(). esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE", False) + # External traffic may ping the multicast group; the reply is the uplink. + esp32.add_idf_sdkconfig_option("CONFIG_LWIP_MULTICAST_PING", True) diff --git a/esphome/components/espectre/espectre.cpp b/esphome/components/espectre/espectre.cpp index b7546fe7766..b1e74d4ac6f 100644 --- a/esphome/components/espectre/espectre.cpp +++ b/esphome/components/espectre/espectre.cpp @@ -4,7 +4,6 @@ #include #include -#include #include "esphome/core/application.h" #include "esphome/core/log.h" #if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) @@ -15,27 +14,6 @@ namespace esphome::espectre { ESPHOME_LOG_TAG(TAG, "espectre"); -struct TrafficModeOption { - const char *name; - ::espectre::TrafficGeneratorMode mode; -}; -// Option names match the traffic_generator_mode YAML values. -static constexpr TrafficModeOption TRAFFIC_MODE_OPTIONS[] = { - {"ping", ::espectre::TrafficGeneratorMode::PING}, - {"dns", ::espectre::TrafficGeneratorMode::DNS}, - {"dns_tcp", ::espectre::TrafficGeneratorMode::DNS_TCP}, - {"wifi_raw", ::espectre::TrafficGeneratorMode::WIFI_RAW}, - {"external", ::espectre::TrafficGeneratorMode::EXTERNAL_HOST}, -}; - -static const char *traffic_mode_name(::espectre::TrafficGeneratorMode mode) { - for (const auto &option : TRAFFIC_MODE_OPTIONS) { - if (option.mode == mode) - return option.name; - } - return nullptr; -} - static const char *csi_capture_profile_name(::espectre::CsiCapturePolicy profile) { switch (profile) { case ::espectre::CsiCapturePolicy::LLTF: @@ -49,12 +27,15 @@ static const char *csi_capture_profile_name(::espectre::CsiCapturePolicy profile #ifdef USE_SELECT void ESPectreComponent::request_traffic_generator_mode(const char *name) { - for (const auto &option : TRAFFIC_MODE_OPTIONS) { - if (strcmp(option.name, name) == 0) { - this->pending_traffic_mode_ = option.mode; - return; - } - } + this->pending_traffic_mode_ = ::espectre::parse_traffic_generator_mode(name); +} + +void ESPectreComponent::request_detection_algorithm(const char *name) { + this->pending_detection_algorithm_ = ::espectre::parse_detection_algorithm(name); +} + +void ESPectreComponent::request_csi_sample_spacing(const char *name) { + this->pending_csi_sample_spacing_ = ::espectre::parse_csi_sample_spacing(name); } #endif @@ -76,9 +57,7 @@ static int log_level(::espectre::LogLevel level) { void ESPectreComponent::setup() { // ESPHome owns scan results, including scans requested by the SDK's CSI recovery. this->runtime_.config().wifi_scan_results_managed_externally = true; -#ifdef USE_SELECT - this->restore_traffic_mode_(); -#endif + this->restore_controls_(); this->start_runtime_(); } @@ -108,6 +87,7 @@ void ESPectreComponent::start_runtime_() { #ifdef USE_SELECT this->publish_traffic_mode_(); #endif + this->publish_controls_(); } void ESPectreComponent::schedule_restart_() { @@ -120,45 +100,172 @@ void ESPectreComponent::schedule_restart_() { } #ifdef USE_SELECT -void ESPectreComponent::restore_traffic_mode_() { - if (this->traffic_mode_select_ == nullptr) +// Selections are saved as SDK enum values, so renumbering an enum would restore the wrong option. +static_assert(static_cast(::espectre::TrafficGeneratorMode::PING) == 0 && + static_cast(::espectre::TrafficGeneratorMode::DNS) == 1 && + static_cast(::espectre::TrafficGeneratorMode::DNS_TCP) == 2 && + static_cast(::espectre::TrafficGeneratorMode::WIFI_RAW) == 3 && + static_cast(::espectre::TrafficGeneratorMode::EXTERNAL_HOST) == 4, + "Saved traffic generator modes depend on these values"); +static_assert(static_cast(::espectre::DetectionAlgorithm::LIGHTWEIGHT) == 0 && + static_cast(::espectre::DetectionAlgorithm::HIGH_ACCURACY) == 1, + "Saved detection algorithms depend on these values"); +static_assert(static_cast(::espectre::TemporalCsiSpacing::HALF_SLOT) == 0 && + static_cast(::espectre::TemporalCsiSpacing::DISTINCT) == 1, + "Saved CSI sample spacings depend on these values"); + +template +static void restore_select_choice(select::Select *select, ESPPreferenceObject &pref, T &value, F accept) { + if (select == nullptr) return; - // Keyed by the YAML mode, so changing it in YAML discards a mode saved from the select. - const auto yaml_mode = this->runtime_.config().traffic_generator_mode; - this->traffic_mode_pref_ = - this->traffic_mode_select_->make_entity_preference(static_cast(yaml_mode) + 1); + pref = select->make_entity_preference(static_cast(value) + 1); uint8_t saved; - if (!this->traffic_mode_pref_.load(&saved)) - return; - const auto mode = static_cast<::espectre::TrafficGeneratorMode>(saved); - const char *name = traffic_mode_name(mode); - if (name != nullptr && this->traffic_mode_select_->has_option(name)) - this->runtime_.config().traffic_generator_mode = mode; + if (pref.load(&saved) && accept(static_cast(saved))) + value = static_cast(saved); +} + +// Returns false when no choice was queued; otherwise the caller republishes the active option. +template +static bool apply_select_choice(::espectre::RuntimeFrontendController &runtime, optional &pending, + ESPPreferenceObject &pref, bool (::espectre::RuntimeFrontendController::*set)(T), + const char *(*name)(T)) { + if (!pending.has_value()) + return false; + const T value = *pending; + pending.reset(); + if ((runtime.*set)(value)) { + const auto saved = static_cast(value); + pref.save(&saved); + } else { + ESP_LOGW(TAG, "Option %s was rejected", name(value)); + } + return true; } void ESPectreComponent::apply_pending_traffic_mode_() { - if (!this->pending_traffic_mode_.has_value()) - return; - const auto mode = *this->pending_traffic_mode_; - this->pending_traffic_mode_.reset(); - if (this->runtime_.set_traffic_generator_mode(mode)) { - const auto saved = static_cast(mode); - this->traffic_mode_pref_.save(&saved); - } else { - ESP_LOGW(TAG, "Traffic generator mode %s was rejected", traffic_mode_name(mode)); - } - this->publish_traffic_mode_(); + if (apply_select_choice(this->runtime_, this->pending_traffic_mode_, this->traffic_mode_pref_, + &::espectre::RuntimeFrontendController::set_traffic_generator_mode, + ::espectre::traffic_generator_mode_name)) + this->publish_traffic_mode_(); } void ESPectreComponent::publish_traffic_mode_() { if (this->traffic_mode_select_ == nullptr) return; - const char *name = traffic_mode_name(this->runtime_.config().traffic_generator_mode); - if (name != nullptr) - this->traffic_mode_select_->publish_state(name); + this->traffic_mode_select_->publish_state( + ::espectre::traffic_generator_mode_name(this->runtime_.config().traffic_generator_mode)); } #endif +#ifdef USE_NUMBER +static void restore_motion_hits(number::Number *number, ESPPreferenceObject &pref, uint8_t &hits) { + if (number == nullptr) + return; + pref = number->make_entity_preference(hits); + uint8_t saved; + if (pref.load(&saved) && saved >= ::espectre::RUNTIME_MOTION_HITS_MIN && saved <= ::espectre::RUNTIME_MOTION_HITS_MAX) + hits = saved; +} +#endif + +void ESPectreComponent::restore_controls_() { + // Saved values are keyed by the YAML value, so a YAML change discards what the entities saved. + auto &config = this->runtime_.config(); +#ifdef USE_NUMBER + restore_motion_hits(this->motion_on_hits_number_, this->motion_on_hits_pref_, config.motion_on_hits); + restore_motion_hits(this->motion_off_hits_number_, this->motion_off_hits_pref_, config.motion_off_hits); +#endif +#ifdef USE_SELECT + restore_select_choice(this->traffic_mode_select_, this->traffic_mode_pref_, config.traffic_generator_mode, + [this](::espectre::TrafficGeneratorMode mode) { + return ::espectre::runtime_traffic_generator_mode_valid(mode) && + this->traffic_mode_select_->has_option(::espectre::traffic_generator_mode_name(mode)); + }); + // Without this the SDK rejects every detector change once the runtime runs. + if (this->detection_algorithm_select_ != nullptr) + config.runtime_detector_selection_enabled = true; + restore_select_choice(this->detection_algorithm_select_, this->detection_algorithm_pref_, config.detection_algorithm, + ::espectre::runtime_detection_algorithm_valid); + restore_select_choice(this->csi_sample_spacing_select_, this->csi_sample_spacing_pref_, config.csi_sample_spacing, + ::espectre::runtime_csi_sample_spacing_valid); +#endif +#ifdef USE_SWITCH + // Applied before the first runtime starts, so a disabled sensor never starts sensing. + if (this->sensing_switch_ != nullptr) + this->runtime_.set_services_armed(this->sensing_switch_->get_initial_state_with_restore_mode().value_or(true)); +#endif +} + +void ESPectreComponent::apply_pending_controls_() { +#ifdef USE_NUMBER + if (!std::isnan(this->pending_threshold_)) { + const float threshold = this->pending_threshold_; + this->pending_threshold_ = NAN; + if (!this->runtime_.set_threshold(threshold)) { + ESP_LOGW(TAG, "Threshold %.2f was rejected", threshold); + } + // Republished even when rejected, so the entity drops the value it was asked for. + PUBLISH_STATE(this->threshold_number_, this->runtime_.snapshot().threshold); + } + if (this->pending_motion_on_hits_ != 0 || this->pending_motion_off_hits_ != 0) { + const auto &config = this->runtime_.config(); + const uint8_t on_hits = this->pending_motion_on_hits_ != 0 ? this->pending_motion_on_hits_ : config.motion_on_hits; + const uint8_t off_hits = + this->pending_motion_off_hits_ != 0 ? this->pending_motion_off_hits_ : config.motion_off_hits; + this->pending_motion_on_hits_ = 0; + this->pending_motion_off_hits_ = 0; + if (this->runtime_.set_motion_hits(on_hits, off_hits)) { + this->motion_on_hits_pref_.save(&on_hits); + this->motion_off_hits_pref_.save(&off_hits); + } else { + ESP_LOGW(TAG, "Motion on/off hits %u/%u were rejected", on_hits, off_hits); + } + PUBLISH_STATE(this->motion_on_hits_number_, config.motion_on_hits); + PUBLISH_STATE(this->motion_off_hits_number_, config.motion_off_hits); + } +#endif +#ifdef USE_SELECT + // Only the changed entity is republished, because numbers and selects run on_value on every publish. + // loop() republishes the threshold that a detector change replaces. + if (apply_select_choice(this->runtime_, this->pending_detection_algorithm_, this->detection_algorithm_pref_, + &::espectre::RuntimeFrontendController::set_detection_algorithm, + ::espectre::detection_algorithm_name)) { + PUBLISH_STATE(this->detection_algorithm_select_, + ::espectre::detection_algorithm_name(this->runtime_.config().detection_algorithm)); + } + if (apply_select_choice(this->runtime_, this->pending_csi_sample_spacing_, this->csi_sample_spacing_pref_, + &::espectre::RuntimeFrontendController::set_csi_sample_spacing, + ::espectre::csi_sample_spacing_name)) { + PUBLISH_STATE(this->csi_sample_spacing_select_, + ::espectre::csi_sample_spacing_name(this->runtime_.config().csi_sample_spacing)); + } +#endif +#ifdef USE_SWITCH + if (this->pending_sensing_.has_value()) { + this->runtime_.set_services_armed(*this->pending_sensing_); + this->pending_sensing_.reset(); + PUBLISH_STATE(this->sensing_switch_, this->runtime_.services_armed()); + } +#endif +} + +void ESPectreComponent::publish_controls_() { +#ifdef USE_NUMBER + PUBLISH_STATE(this->threshold_number_, this->runtime_.snapshot().threshold); + PUBLISH_STATE(this->motion_on_hits_number_, this->runtime_.config().motion_on_hits); + PUBLISH_STATE(this->motion_off_hits_number_, this->runtime_.config().motion_off_hits); +#endif +#ifdef USE_SELECT + PUBLISH_STATE(this->detection_algorithm_select_, + ::espectre::detection_algorithm_name(this->runtime_.config().detection_algorithm)); + PUBLISH_STATE(this->csi_sample_spacing_select_, + ::espectre::csi_sample_spacing_name(this->runtime_.config().csi_sample_spacing)); +#endif +#ifdef USE_SWITCH + PUBLISH_STATE(this->sensing_switch_, this->runtime_.services_armed()); +#endif +} + void ESPectreComponent::loop() { if (this->runtime_fault_) { this->stop_(); @@ -184,12 +291,18 @@ void ESPectreComponent::loop() { #ifdef USE_SELECT this->apply_pending_traffic_mode_(); #endif + this->apply_pending_controls_(); this->runtime_.loop(); if (this->runtime_fault_) return; // Read once after the SDK finishes dispatching callbacks, including readiness changes. const auto &snapshot = this->runtime_.snapshot(); +#ifdef USE_NUMBER + // Calibration and a detector change both replace the threshold. + if (this->threshold_number_ != nullptr && this->threshold_number_->state != snapshot.threshold) + this->threshold_number_->publish_state(snapshot.threshold); +#endif #ifdef USE_BINARY_SENSOR if (this->calibrating_binary_sensor_ != nullptr && (!this->calibrating_published_ || this->calibrating_state_ != snapshot.calibrating)) { @@ -257,7 +370,7 @@ void ESPectreComponent::stop_() { #endif this->roaming_suppressed_ = false; this->running_ = false; - // The next runtime starts from the default threshold and publishes its calibration again. + // The next runtime reports its own calibration, so forget this one's. this->calibrated_ = false; this->calibrating_published_ = false; this->runtime_.shutdown(); @@ -281,12 +394,22 @@ void ESPectreComponent::dump_config() { " Detection algorithm: %s\n" " CSI capture profile: %s\n" " Traffic generator mode: %s\n" - " Motion on/off hits: %u/%u", - config.detection_algorithm == ::espectre::DetectionAlgorithm::LIGHTWEIGHT - ? LOG_STR_LITERAL("lightweight") - : LOG_STR_LITERAL("high_accuracy"), - csi_capture_profile_name(config.csi_capture_policy), traffic_mode_name(config.traffic_generator_mode), - config.motion_on_hits, config.motion_off_hits); + " Traffic generator target MAC: %s\n" + " CSI sample spacing: %s\n" + " Motion on/off hits: %u/%u\n" + " Window size: %" PRIu32 " ms\n" + " CSI target rate: %" PRIu32 " pps\n" + " Evaluation interval: %" PRIu32 " ms\n" + " Low-pass filter: %s (%.1f Hz)\n" + " Hampel filter: %s (window %u, threshold %.1f)", + ::espectre::detection_algorithm_name(config.detection_algorithm), + csi_capture_profile_name(config.csi_capture_policy), + ::espectre::traffic_generator_mode_name(config.traffic_generator_mode), + config.traffic_generator_target_mac.c_str(), + ::espectre::csi_sample_spacing_name(config.csi_sample_spacing), config.motion_on_hits, + config.motion_off_hits, config.window_size_ms, config.csi_target_pps, config.evaluation_interval_ms, + ONOFF(config.lowpass_enabled), config.lowpass_cutoff, ONOFF(config.hampel_enabled), + config.hampel_window, config.hampel_threshold); #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Motion", this->motion_binary_sensor_); LOG_BINARY_SENSOR(" ", "Calibrating", this->calibrating_binary_sensor_); @@ -294,8 +417,18 @@ void ESPectreComponent::dump_config() { #ifdef USE_SENSOR LOG_SENSOR(" ", "Movement score", this->movement_sensor_); #endif +#ifdef USE_NUMBER + LOG_NUMBER(" ", "Threshold", this->threshold_number_); + LOG_NUMBER(" ", "Motion on hits", this->motion_on_hits_number_); + LOG_NUMBER(" ", "Motion off hits", this->motion_off_hits_number_); +#endif #ifdef USE_SELECT LOG_SELECT(" ", "Traffic generator mode", this->traffic_mode_select_); + LOG_SELECT(" ", "Detection algorithm", this->detection_algorithm_select_); + LOG_SELECT(" ", "CSI sample spacing", this->csi_sample_spacing_select_); +#endif +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Sensing", this->sensing_switch_); #endif } diff --git a/esphome/components/espectre/espectre.h b/esphome/components/espectre/espectre.h index 4b1637f1da0..43e461bee6c 100644 --- a/esphome/components/espectre/espectre.h +++ b/esphome/components/espectre/espectre.h @@ -7,24 +7,41 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/optional.h" +#include "esphome/core/preferences.h" #include +#include #include #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif #ifdef USE_SELECT #include "esphome/components/select/select.h" -#include "esphome/core/preferences.h" #endif #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif namespace esphome::espectre { class ESPectreComponent final : public Component, public ::espectre::IRuntimeListener { +#ifdef USE_NUMBER + SUB_NUMBER(threshold) + SUB_NUMBER(motion_on_hits) + SUB_NUMBER(motion_off_hits) +#endif #ifdef USE_SELECT SUB_SELECT(traffic_mode) + SUB_SELECT(detection_algorithm) + SUB_SELECT(csi_sample_spacing) +#endif +#ifdef USE_SWITCH + SUB_SWITCH(sensing) #endif public: @@ -47,18 +64,46 @@ class ESPectreComponent final : public Component, public ::espectre::IRuntimeLis void set_traffic_generator_target_ip(const std::string &value) { this->runtime_.config().traffic_generator_target_ip = value; } + void set_traffic_generator_target_mac(const std::string &value) { + this->runtime_.config().traffic_generator_target_mac = value; + } + void set_csi_sample_spacing(::espectre::TemporalCsiSpacing value) { + this->runtime_.config().csi_sample_spacing = value; + } void set_csi_traffic_multicast_group(const std::string &value) { this->runtime_.config().csi_traffic_multicast_group = value; } void set_motion_on_hits(uint8_t value) { this->runtime_.config().motion_on_hits = value; } void set_motion_off_hits(uint8_t value) { this->runtime_.config().motion_off_hits = value; } + void set_window_size_ms(uint32_t value) { this->runtime_.config().window_size_ms = value; } + void set_csi_target_pps(uint32_t value) { this->runtime_.config().csi_target_pps = value; } + void set_evaluation_interval_ms(uint32_t value) { this->runtime_.config().evaluation_interval_ms = value; } + void set_lowpass_enabled(bool value) { this->runtime_.config().lowpass_enabled = value; } + void set_lowpass_cutoff(float value) { this->runtime_.config().lowpass_cutoff = value; } + void set_hampel_enabled(bool value) { this->runtime_.config().hampel_enabled = value; } + void set_hampel_window(uint8_t value) { this->runtime_.config().hampel_window = value; } + void set_hampel_threshold(float value) { this->runtime_.config().hampel_threshold = value; } void set_wifi_band_policy(::espectre::WifiBandPolicy value) { this->runtime_.config().wifi_band_policy = value; } // Queue controls so entity automations cannot re-enter the SDK from a listener callback. void recalibrate() { this->recalibrate_pending_ = true; } +#ifdef USE_NUMBER + void request_threshold(float value) { this->pending_threshold_ = value; } + void request_motion_on_hits(float value) { this->pending_motion_on_hits_ = static_cast(std::lround(value)); } + void request_motion_off_hits(float value) { + this->pending_motion_off_hits_ = static_cast(std::lround(value)); + } +#endif #ifdef USE_SELECT void request_traffic_generator_mode(::espectre::TrafficGeneratorMode mode) { this->pending_traffic_mode_ = mode; } - /// Request a mode by its traffic_generator_mode YAML name; unknown names are ignored. + /// Request a mode by its traffic_generator_mode YAML name, as the select offers it. void request_traffic_generator_mode(const char *name); + /// Request an algorithm by its detection_algorithm YAML name, as the select offers it. + void request_detection_algorithm(const char *name); + /// Request a spacing rule by its csi_sample_spacing YAML name, as the select offers it. + void request_csi_sample_spacing(const char *name); +#endif +#ifdef USE_SWITCH + void request_sensing(bool enabled) { this->pending_sensing_ = enabled; } #endif #ifdef USE_BINARY_SENSOR @@ -72,7 +117,7 @@ class ESPectreComponent final : public Component, public ::espectre::IRuntimeLis const ::espectre::RuntimeDiagnosticsSample *diagnostics_sample() const { return this->runtime_.diagnostics_sample(); } protected: - // Called once per SDK detector evaluation (every 250 ms), which bounds the movement publish rate. + // Called once per SDK detector evaluation (every evaluation_interval), which bounds the movement publish rate. void on_live_telemetry(const ::espectre::RuntimeSnapshot &snapshot) override { this->movement_pending_ = true; } void on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) override; void on_runtime_fault(const char *message) override; @@ -81,18 +126,36 @@ class ESPectreComponent final : public Component, public ::espectre::IRuntimeLis void invalidate_sensing_(); void stop_(); #ifdef USE_SELECT - void restore_traffic_mode_(); void apply_pending_traffic_mode_(); void publish_traffic_mode_(); #endif + // Threshold, motion hits, detection algorithm, sample spacing and sensing. + void restore_controls_(); + void apply_pending_controls_(); + void publish_controls_(); static constexpr uint32_t RESTART_DELAY_MS = 30000; ::espectre::RuntimeFrontendController runtime_; uint32_t restart_requested_ms_{0}; +#ifdef USE_NUMBER + ESPPreferenceObject motion_on_hits_pref_; + ESPPreferenceObject motion_off_hits_pref_; + float pending_threshold_{NAN}; + // Zero means no request; the runtime accepts 1 to 20. + uint8_t pending_motion_on_hits_{0}; + uint8_t pending_motion_off_hits_{0}; +#endif #ifdef USE_SELECT ESPPreferenceObject traffic_mode_pref_; + ESPPreferenceObject detection_algorithm_pref_; + ESPPreferenceObject csi_sample_spacing_pref_; optional<::espectre::TrafficGeneratorMode> pending_traffic_mode_; + optional<::espectre::DetectionAlgorithm> pending_detection_algorithm_; + optional<::espectre::TemporalCsiSpacing> pending_csi_sample_spacing_; +#endif +#ifdef USE_SWITCH + optional pending_sensing_; #endif bool recalibrate_pending_{false}; bool movement_pending_{false}; diff --git a/esphome/components/espectre/number.py b/esphome/components/espectre/number.py new file mode 100644 index 00000000000..5df7c34d09b --- /dev/null +++ b/esphome/components/espectre/number.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import number +import esphome.config_validation as cv +from esphome.const import CONF_THRESHOLD, ENTITY_CATEGORY_CONFIG, ICON_PULSE +from esphome.types import ConfigType + +from . import ( + CONF_ESPECTRE_ID, + CONF_MOTION_OFF_HITS, + CONF_MOTION_ON_HITS, + MOTION_HITS_MAX, + MOTION_HITS_MIN, + ESPectreComponent, +) + +DEPENDENCIES = ["espectre"] + + +def _config_number_schema(icon: str) -> cv.Schema: + return number.number_schema( + number.ApplyNumber, entity_category=ENTITY_CATEGORY_CONFIG, icon=icon + ) + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent), + cv.Optional(CONF_THRESHOLD): _config_number_schema(ICON_PULSE), + cv.Optional(CONF_MOTION_ON_HITS): _config_number_schema( + "mdi:motion-play-outline" + ), + cv.Optional(CONF_MOTION_OFF_HITS): _config_number_schema( + "mdi:motion-pause-outline" + ), + } + ), + cv.has_at_least_one_key(CONF_THRESHOLD, CONF_MOTION_ON_HITS, CONF_MOTION_OFF_HITS), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ESPECTRE_ID]) + numbers = number.sub_numbers(config, parent=hub) + # The hub publishes each value once the runtime has applied it. + await numbers( + CONF_THRESHOLD, + hub.set_threshold_number, + min_value=0.0, + max_value=1.0, + step=0.01, + apply="request_threshold(value)", + ) + hits = {"min_value": MOTION_HITS_MIN, "max_value": MOTION_HITS_MAX, "step": 1} + await numbers( + CONF_MOTION_ON_HITS, + hub.set_motion_on_hits_number, + **hits, + apply="request_motion_on_hits(value)", + ) + await numbers( + CONF_MOTION_OFF_HITS, + hub.set_motion_off_hits_number, + **hits, + apply="request_motion_off_hits(value)", + ) diff --git a/esphome/components/espectre/select.py b/esphome/components/espectre/select.py index 953202acfc8..c4fc5b351f7 100644 --- a/esphome/components/espectre/select.py +++ b/esphome/components/espectre/select.py @@ -1,3 +1,5 @@ +import logging + import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv @@ -6,26 +8,83 @@ from esphome.core import CORE from esphome.types import ConfigType from . import ( + CONF_CSI_SAMPLE_SPACING, + CONF_DETECTION_ALGORITHM, CONF_ESPECTRE_ID, + CONF_TRAFFIC_GENERATOR_MODE, + CSI_SAMPLE_SPACINGS, + DETECTION_ALGORITHMS, DOMAIN, ESPectreComponent, supported_traffic_generator_modes, ) +_LOGGER = logging.getLogger(__name__) + DEPENDENCIES = ["espectre"] +SELECT_KEYS = ( + CONF_TRAFFIC_GENERATOR_MODE, + CONF_DETECTION_ALGORITHM, + CONF_CSI_SAMPLE_SPACING, +) -CONFIG_SCHEMA = select.select_schema( + +def _move_to_traffic_generator_mode(config: ConfigType) -> ConfigType: + """Move a select configured directly on the platform under traffic_generator_mode.""" + # Remove before 2027.5.0 + if not isinstance(config, dict) or any(key in config for key in SELECT_KEYS): + return config + _LOGGER.warning( + "[espectre] Configuring the select directly on the platform is deprecated, " + "move it under '%s'. Will be removed in 2027.5.0", + CONF_TRAFFIC_GENERATOR_MODE, + ) + entity = dict(config) + migrated = {CONF_TRAFFIC_GENERATOR_MODE: entity} + if CONF_ESPECTRE_ID in entity: + migrated[CONF_ESPECTRE_ID] = entity.pop(CONF_ESPECTRE_ID) + return migrated + + +SELECT_SCHEMA = select.select_schema( select.ApplySelect, entity_category=ENTITY_CATEGORY_CONFIG -).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)}) +) + + +CONFIG_SCHEMA = cv.All( + _move_to_traffic_generator_mode, + cv.Schema( + { + cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent), + cv.Optional(CONF_TRAFFIC_GENERATOR_MODE): SELECT_SCHEMA, + cv.Optional(CONF_DETECTION_ALGORITHM): SELECT_SCHEMA, + cv.Optional(CONF_CSI_SAMPLE_SPACING): SELECT_SCHEMA, + } + ), + cv.has_at_least_one_key(*SELECT_KEYS), +) async def to_code(config: ConfigType) -> None: - options = supported_traffic_generator_modes(CORE.config[DOMAIN]) hub = await cg.get_variable(config[CONF_ESPECTRE_ID]) - await select.sub_selects(config, parent=hub).create( - config, + selects = select.sub_selects(config, parent=hub) + # The hub publishes each option once the runtime has applied it. + await selects( + CONF_TRAFFIC_GENERATOR_MODE, hub.set_traffic_mode_select, - options=options, + options=supported_traffic_generator_modes(CORE.config[DOMAIN]), apply="request_traffic_generator_mode(option)", ) + await selects( + CONF_DETECTION_ALGORITHM, + hub.set_detection_algorithm_select, + options=list(DETECTION_ALGORITHMS), + apply="request_detection_algorithm(option)", + ) + await selects( + CONF_CSI_SAMPLE_SPACING, + hub.set_csi_sample_spacing_select, + options=list(CSI_SAMPLE_SPACINGS), + apply="request_csi_sample_spacing(option)", + ) diff --git a/esphome/components/espectre/sensor/__init__.py b/esphome/components/espectre/sensor/__init__.py index c0efac62042..00cea022cd0 100644 --- a/esphome/components/espectre/sensor/__init__.py +++ b/esphome/components/espectre/sensor/__init__.py @@ -18,9 +18,13 @@ CONF_DIAGNOSTICS = "diagnostics" CONF_GENERATOR_RATE = "generator_rate" CONF_TRAFFIC_TX_RATE = "traffic_tx_rate" CONF_TRAFFIC_RX_RATE = "traffic_rx_rate" +CONF_CSI_CALLBACK_RATE = "csi_callback_rate" CONF_CSI_ACCEPTED_RATE = "csi_accepted_rate" +CONF_CSI_MISSING_SLOT_RATE = "csi_missing_slot_rate" +CONF_CSI_EXCESS_RATE = "csi_excess_rate" CONF_CSI_OCCUPANCY = "csi_occupancy" UNIT_PACKETS_PER_SECOND = "pps" +UNIT_SLOTS_PER_SECOND = "slot/s" DiagnosticsUpdater = espectre_ns.class_("DiagnosticsUpdater", cg.PollingComponent) @@ -38,7 +42,10 @@ DIAGNOSTIC_SENSORS = { CONF_GENERATOR_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), CONF_TRAFFIC_TX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), CONF_TRAFFIC_RX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_CSI_CALLBACK_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), CONF_CSI_ACCEPTED_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_CSI_MISSING_SLOT_RATE: _diagnostic_schema(UNIT_SLOTS_PER_SECOND, 1), + CONF_CSI_EXCESS_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), CONF_CSI_OCCUPANCY: _diagnostic_schema(UNIT_PERCENT, 0), } diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.cpp b/esphome/components/espectre/sensor/espectre_diagnostics.cpp index 77b4d0ab727..4ffcff38375 100644 --- a/esphome/components/espectre/sensor/espectre_diagnostics.cpp +++ b/esphome/components/espectre/sensor/espectre_diagnostics.cpp @@ -20,7 +20,10 @@ void DiagnosticsUpdater::update() { publish_diagnostic(this->generator_rate_sensor_, sample, &Sample::generator_pps); publish_diagnostic(this->traffic_tx_rate_sensor_, sample, &Sample::traffic_tx_pps); publish_diagnostic(this->traffic_rx_rate_sensor_, sample, &Sample::traffic_rx_pps); + publish_diagnostic(this->csi_callback_rate_sensor_, sample, &Sample::csi_callback_pps); publish_diagnostic(this->csi_accepted_rate_sensor_, sample, &Sample::csi_accepted_pps); + publish_diagnostic(this->csi_missing_slot_rate_sensor_, sample, &Sample::csi_missing_slots_pps); + publish_diagnostic(this->csi_excess_rate_sensor_, sample, &Sample::csi_excess_pps); publish_diagnostic(this->csi_occupancy_sensor_, sample, &Sample::csi_occupancy_ratio, 100.0f); } @@ -30,7 +33,10 @@ void DiagnosticsUpdater::dump_config() { LOG_SENSOR(" ", "Generator rate", this->generator_rate_sensor_); LOG_SENSOR(" ", "Traffic TX rate", this->traffic_tx_rate_sensor_); LOG_SENSOR(" ", "Traffic RX rate", this->traffic_rx_rate_sensor_); + LOG_SENSOR(" ", "CSI callback rate", this->csi_callback_rate_sensor_); LOG_SENSOR(" ", "CSI accepted rate", this->csi_accepted_rate_sensor_); + LOG_SENSOR(" ", "CSI missing slot rate", this->csi_missing_slot_rate_sensor_); + LOG_SENSOR(" ", "CSI excess rate", this->csi_excess_rate_sensor_); LOG_SENSOR(" ", "CSI occupancy", this->csi_occupancy_sensor_); } diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.h b/esphome/components/espectre/sensor/espectre_diagnostics.h index fde1c461137..6d7eae56c79 100644 --- a/esphome/components/espectre/sensor/espectre_diagnostics.h +++ b/esphome/components/espectre/sensor/espectre_diagnostics.h @@ -20,7 +20,10 @@ class DiagnosticsUpdater final : public PollingComponent { SUB_SENSOR(generator_rate) SUB_SENSOR(traffic_tx_rate) SUB_SENSOR(traffic_rx_rate) + SUB_SENSOR(csi_callback_rate) SUB_SENSOR(csi_accepted_rate) + SUB_SENSOR(csi_missing_slot_rate) + SUB_SENSOR(csi_excess_rate) SUB_SENSOR(csi_occupancy) protected: diff --git a/esphome/components/espectre/switch.py b/esphome/components/espectre/switch.py new file mode 100644 index 00000000000..225bd47aff2 --- /dev/null +++ b/esphome/components/espectre/switch.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from . import CONF_ESPECTRE_ID, ESPectreComponent + +DEPENDENCIES = ["espectre"] + +CONF_SENSING = "sensing" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent), + cv.Optional(CONF_SENSING): switch.switch_schema( + switch.ApplySwitch, + block_inverted=True, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="RESTORE_DEFAULT_ON", + icon="mdi:radar", + ), + } + ), + cv.has_at_least_one_key(CONF_SENSING), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ESPECTRE_ID]) + switches = switch.sub_switches(config, parent=hub) + # The hub restores the state before the runtime starts and publishes it once applied. + await switches(CONF_SENSING, hub.set_sensing_switch, apply="request_sensing(state)") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 62da7c7cf71..809ea4aea26 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -46,7 +46,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" francescopace/espectre: - version: "3.0.0" + version: "3.1.0" rules: - if: "target in [esp32, esp32s2, esp32s3, esp32c3, esp32c5, esp32c6]" zorxx/multipart-parser: diff --git a/tests/components/espectre/__init__.py b/tests/components/espectre/__init__.py index 5e0c2c776d9..497fe945fe5 100644 --- a/tests/components/espectre/__init__.py +++ b/tests/components/espectre/__init__.py @@ -7,7 +7,14 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: # The real schema needs an ESP32 target and Wi-Fi; the host test build has neither. - manifest.dependencies = ["binary_sensor", "sensor", "button", "select"] + manifest.dependencies = [ + "binary_sensor", + "sensor", + "button", + "number", + "select", + "switch", + ] manifest.config_schema = cv.Schema({}) manifest.final_validate_schema = None @@ -16,9 +23,11 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_build_flag(f"-I{CORE.relative_src_path('espectre')}") cg.add_define("USE_ESPECTRE") # No entity platforms are configured, so count them to emit USE_BINARY_SENSOR, - # USE_SENSOR and USE_SELECT for the entity code under test. + # USE_SENSOR, USE_SELECT, USE_NUMBER and USE_SWITCH for the entity code under test. CORE.platform_counts["binary_sensor"] += 2 CORE.platform_counts["sensor"] += 1 CORE.platform_counts["select"] += 1 + CORE.platform_counts["number"] += 1 + CORE.platform_counts["switch"] += 1 manifest.to_code = to_code_testing diff --git a/tests/components/espectre/common.yaml b/tests/components/espectre/common.yaml index 5eb256f5590..75c805842e3 100644 --- a/tests/components/espectre/common.yaml +++ b/tests/components/espectre/common.yaml @@ -27,15 +27,42 @@ sensor: name: Traffic TX Rate traffic_rx_rate: name: Traffic RX Rate + csi_callback_rate: + name: CSI Callback Rate csi_accepted_rate: name: CSI Accepted Rate + csi_missing_slot_rate: + name: CSI Missing Slot Rate + csi_excess_rate: + name: CSI Excess Rate csi_occupancy: name: CSI Temporal Occupancy +number: + - platform: espectre + espectre_id: espectre_hub + threshold: + name: Threshold + motion_on_hits: + name: Motion On Hits + motion_off_hits: + name: Motion Off Hits + select: - platform: espectre espectre_id: espectre_hub - name: CSI Traffic Source + traffic_generator_mode: + name: CSI Traffic Source + detection_algorithm: + name: Detection Profile + csi_sample_spacing: + name: CSI Packet Spacing + +switch: + - platform: espectre + espectre_id: espectre_hub + sensing: + name: Sensing Enabled button: - platform: espectre diff --git a/tests/components/espectre/espectre_sdk.h b/tests/components/espectre/espectre_sdk.h index 6a7949d88aa..6fea7c31ce2 100644 --- a/tests/components/espectre/espectre_sdk.h +++ b/tests/components/espectre/espectre_sdk.h @@ -6,6 +6,7 @@ // SDK test double: hardware behavior belongs to the SDK; these tests exercise the ESPHome adapter. #include #include +#include #include #include @@ -14,10 +15,64 @@ namespace espectre { enum class DetectionAlgorithm { LIGHTWEIGHT, HIGH_ACCURACY }; enum class CsiCapturePolicy { AUTO, LLTF, HT_VHT }; enum class TrafficGeneratorMode { PING, DNS, DNS_TCP, WIFI_RAW, EXTERNAL_HOST }; +enum class TemporalCsiSpacing : uint8_t { HALF_SLOT, DISTINCT }; enum class WifiBandPolicy { BAND_2G, BAND_5G, AUTO }; enum class MotionState { IDLE, MOTION }; enum class LogLevel { ERROR, WARNING, INFO, DEBUG, VERBOSE }; +constexpr uint8_t RUNTIME_MOTION_HITS_MIN = 1; +constexpr uint8_t RUNTIME_MOTION_HITS_MAX = 20; + +constexpr bool runtime_detection_algorithm_valid(DetectionAlgorithm algorithm) { + return algorithm == DetectionAlgorithm::LIGHTWEIGHT || algorithm == DetectionAlgorithm::HIGH_ACCURACY; +} +constexpr bool runtime_traffic_generator_mode_valid(TrafficGeneratorMode mode) { + return mode == TrafficGeneratorMode::PING || mode == TrafficGeneratorMode::DNS || + mode == TrafficGeneratorMode::DNS_TCP || mode == TrafficGeneratorMode::WIFI_RAW || + mode == TrafficGeneratorMode::EXTERNAL_HOST; +} +constexpr bool runtime_csi_sample_spacing_valid(TemporalCsiSpacing spacing) { + return spacing == TemporalCsiSpacing::HALF_SLOT || spacing == TemporalCsiSpacing::DISTINCT; +} + +// Like the SDK, names never return nullptr and parsing falls back to the default. +inline const char *traffic_generator_mode_name(TrafficGeneratorMode mode) { + switch (mode) { + case TrafficGeneratorMode::DNS: + return "dns"; + case TrafficGeneratorMode::DNS_TCP: + return "dns_tcp"; + case TrafficGeneratorMode::WIFI_RAW: + return "wifi_raw"; + case TrafficGeneratorMode::EXTERNAL_HOST: + return "external"; + default: + return "ping"; + } +} +inline TrafficGeneratorMode parse_traffic_generator_mode(const char *mode) { + for (auto candidate : {TrafficGeneratorMode::DNS, TrafficGeneratorMode::DNS_TCP, TrafficGeneratorMode::WIFI_RAW, + TrafficGeneratorMode::EXTERNAL_HOST}) { + if (mode != nullptr && std::strcmp(mode, traffic_generator_mode_name(candidate)) == 0) + return candidate; + } + return TrafficGeneratorMode::PING; +} +inline const char *detection_algorithm_name(DetectionAlgorithm algorithm) { + return algorithm == DetectionAlgorithm::HIGH_ACCURACY ? "high_accuracy" : "lightweight"; +} +inline DetectionAlgorithm parse_detection_algorithm(const char *algorithm) { + return algorithm != nullptr && std::strcmp(algorithm, "high_accuracy") == 0 ? DetectionAlgorithm::HIGH_ACCURACY + : DetectionAlgorithm::LIGHTWEIGHT; +} +inline const char *csi_sample_spacing_name(TemporalCsiSpacing spacing) { + return spacing == TemporalCsiSpacing::DISTINCT ? "distinct" : "half_slot"; +} +inline TemporalCsiSpacing parse_csi_sample_spacing(const char *spacing) { + return spacing != nullptr && std::strcmp(spacing, "distinct") == 0 ? TemporalCsiSpacing::DISTINCT + : TemporalCsiSpacing::HALF_SLOT; +} + constexpr float runtime_default_threshold(DetectionAlgorithm algorithm) { return algorithm == DetectionAlgorithm::HIGH_ACCURACY ? 0.5f : 0.66218545f; } @@ -25,12 +80,23 @@ constexpr float runtime_default_threshold(DetectionAlgorithm algorithm) { struct RuntimeConfig { DetectionAlgorithm detection_algorithm{DetectionAlgorithm::LIGHTWEIGHT}; float threshold{runtime_default_threshold(DetectionAlgorithm::LIGHTWEIGHT)}; + bool runtime_detector_selection_enabled{false}; CsiCapturePolicy csi_capture_policy{CsiCapturePolicy::AUTO}; TrafficGeneratorMode traffic_generator_mode{TrafficGeneratorMode::PING}; std::string traffic_generator_target_ip; + std::string traffic_generator_target_mac{"auto"}; + TemporalCsiSpacing csi_sample_spacing{TemporalCsiSpacing::HALF_SLOT}; std::string csi_traffic_multicast_group{"239.255.0.1"}; uint8_t motion_on_hits{4}; uint8_t motion_off_hits{3}; + uint32_t window_size_ms{1000}; + uint32_t csi_target_pps{100}; + uint32_t evaluation_interval_ms{250}; + bool lowpass_enabled{false}; + float lowpass_cutoff{11.0f}; + bool hampel_enabled{true}; + uint8_t hampel_window{7}; + float hampel_threshold{5.0f}; WifiBandPolicy wifi_band_policy{WifiBandPolicy::AUTO}; bool wifi_scan_results_managed_externally{false}; bool persist_runtime_overrides{false}; @@ -39,7 +105,10 @@ struct RuntimeDiagnosticsSample { float generator_pps{0.0f}; float traffic_tx_pps{0.0f}; float traffic_rx_pps{0.0f}; + float csi_callback_pps{0.0f}; float csi_accepted_pps{0.0f}; + float csi_missing_slots_pps{0.0f}; + float csi_excess_pps{0.0f}; float csi_occupancy_ratio{0.0f}; }; struct RuntimeSnapshot { @@ -47,6 +116,7 @@ struct RuntimeSnapshot { bool calibrating{false}; MotionState motion_state{MotionState::IDLE}; float movement_metric{0.0f}; + float threshold{runtime_default_threshold(DetectionAlgorithm::LIGHTWEIGHT)}; }; class IRuntimeListener { public: @@ -88,12 +158,41 @@ class RuntimeFrontendController { config_.traffic_generator_mode = mode; return true; } + bool set_threshold(float threshold) { + if (threshold < 0.0f || threshold > 1.0f) + return false; + snapshot_.threshold = threshold; + return true; + } + bool set_motion_hits(uint8_t motion_on_hits, uint8_t motion_off_hits) { + if (motion_on_hits < RUNTIME_MOTION_HITS_MIN || motion_on_hits > RUNTIME_MOTION_HITS_MAX || + motion_off_hits < RUNTIME_MOTION_HITS_MIN || motion_off_hits > RUNTIME_MOTION_HITS_MAX) + return false; + config_.motion_on_hits = motion_on_hits; + config_.motion_off_hits = motion_off_hits; + return true; + } + bool set_detection_algorithm(DetectionAlgorithm algorithm) { + // Like the SDK, the setting at setup decides whether the runtime can switch detectors. + if (!config_at_setup_.runtime_detector_selection_enabled) + return false; + config_.detection_algorithm = algorithm; + snapshot_.threshold = runtime_default_threshold(algorithm); + return true; + } + bool set_csi_sample_spacing(TemporalCsiSpacing spacing) { + config_.csi_sample_spacing = spacing; + return true; + } + void set_services_armed(bool armed) { services_armed_ = armed; } + bool services_armed() const { return services_armed_; } inline static RuntimeFrontendController *instance{nullptr}; RuntimeConfig config_; RuntimeConfig config_at_setup_; RuntimeSnapshot snapshot_; RuntimeDiagnosticsSample diagnostics_sample_; + bool services_armed_{true}; IRuntimeListener *listener{nullptr}; std::function loop_hook; bool setup_result{true}; diff --git a/tests/components/espectre/sensor/espectre_diagnostics_test.cpp b/tests/components/espectre/sensor/espectre_diagnostics_test.cpp index 1fea66ec968..9668b13fef9 100644 --- a/tests/components/espectre/sensor/espectre_diagnostics_test.cpp +++ b/tests/components/espectre/sensor/espectre_diagnostics_test.cpp @@ -9,17 +9,21 @@ TEST(ESPectreDiagnosticsTest, PublishOnUpdate) { ESPectreComponent component; auto *sdk = ::espectre::RuntimeFrontendController::instance; sensor::Sensor accepted; + sensor::Sensor missing; sensor::Sensor occupancy; DiagnosticsUpdater updater(&component); updater.set_csi_accepted_rate_sensor(&accepted); + updater.set_csi_missing_slot_rate_sensor(&missing); updater.set_csi_occupancy_sensor(&occupancy); component.setup(); sdk->diagnostics_sample_.csi_accepted_pps = 98.5f; + sdk->diagnostics_sample_.csi_missing_slots_pps = 1.5f; sdk->diagnostics_sample_.csi_occupancy_ratio = 0.9f; component.loop(); EXPECT_FALSE(accepted.has_state()); updater.update(); EXPECT_FLOAT_EQ(accepted.state, 98.5f); + EXPECT_FLOAT_EQ(missing.state, 1.5f); EXPECT_FLOAT_EQ(occupancy.state, 90.0f); } diff --git a/tests/components/espectre/test.esp32-c6-idf.yaml b/tests/components/espectre/test.esp32-c6-idf.yaml index cc93956dc0c..457742faec2 100644 --- a/tests/components/espectre/test.esp32-c6-idf.yaml +++ b/tests/components/espectre/test.esp32-c6-idf.yaml @@ -6,3 +6,12 @@ espectre: csi_traffic_multicast_group: 239.255.0.1 motion_on_hits: 2 motion_off_hits: 5 + csi_sample_spacing: distinct + window_size: 1500ms + csi_target_pps: 200 + evaluation_interval: 500ms + lowpass_filter: + cutoff: 8Hz + hampel_filter: + window: 5 + threshold: 3.0 diff --git a/tests/components/espectre/test.esp32-s2-idf.yaml b/tests/components/espectre/test.esp32-s2-idf.yaml index 2467109f325..46fb8c0707f 100644 --- a/tests/components/espectre/test.esp32-s2-idf.yaml +++ b/tests/components/espectre/test.esp32-s2-idf.yaml @@ -4,3 +4,4 @@ packages: espectre: csi_capture_profile: lltf traffic_generator_mode: wifi_raw + traffic_generator_target_mac: "AA:BB:CC:DD:EE:02" diff --git a/tests/components/espectre/test.esp32-s3-idf.yaml b/tests/components/espectre/test.esp32-s3-idf.yaml index 8ddf4ab1246..7bd74745f58 100644 --- a/tests/components/espectre/test.esp32-s3-idf.yaml +++ b/tests/components/espectre/test.esp32-s3-idf.yaml @@ -5,3 +5,5 @@ espectre: detection_algorithm: high_accuracy csi_capture_profile: ht_vht traffic_generator_mode: dns_tcp + hampel_filter: + enabled: false diff --git a/tests/components/espectre/test_espectre.cpp b/tests/components/espectre/test_espectre.cpp index bca8bfe54d7..d812210189d 100644 --- a/tests/components/espectre/test_espectre.cpp +++ b/tests/components/espectre/test_espectre.cpp @@ -7,9 +7,30 @@ namespace esphome::espectre::testing { +class TestNumber : public number::Number { + protected: + void control(float value) override {} +}; + +class TestSelect : public select::Select { + protected: + void control(size_t index) override {} +}; + +class TestSwitch : public switch_::Switch { + protected: + void write_state(bool state) override {} +}; + +static constexpr const char *const DETECTION_ALGORITHM_OPTIONS[] = {"lightweight", "high_accuracy"}; +static constexpr const char *const CSI_SAMPLE_SPACING_OPTIONS[] = {"half_slot", "distinct"}; + class ESPectreTest : public ::testing::Test { protected: void SetUp() override { + // Each test starts without the controls an earlier test saved. + if (global_preferences != nullptr) + global_preferences->reset(); component_.set_motion_binary_sensor(&motion_); component_.set_calibrating_binary_sensor(&calibrating_); component_.set_movement_sensor(&movement_); @@ -22,11 +43,30 @@ class ESPectreTest : public ::testing::Test { component_.loop(); } + void add_controls_() { + component_.set_threshold_number(&threshold_); + component_.set_motion_on_hits_number(&motion_on_hits_); + component_.set_motion_off_hits_number(&motion_off_hits_); + detection_algorithm_.traits.set_options_static(DETECTION_ALGORITHM_OPTIONS, 2); + component_.set_detection_algorithm_select(&detection_algorithm_); + csi_sample_spacing_.traits.set_options_static(CSI_SAMPLE_SPACING_OPTIONS, 2); + component_.set_csi_sample_spacing_select(&csi_sample_spacing_); + // The YAML default; the Switch class itself defaults to off. + sensing_.set_restore_mode(switch_::SWITCH_RESTORE_DEFAULT_ON); + component_.set_sensing_switch(&sensing_); + } + ESPectreComponent component_; ::espectre::RuntimeFrontendController *sdk_{::espectre::RuntimeFrontendController::instance}; binary_sensor::BinarySensor motion_; binary_sensor::BinarySensor calibrating_; sensor::Sensor movement_; + TestNumber threshold_; + TestNumber motion_on_hits_; + TestNumber motion_off_hits_; + TestSelect detection_algorithm_; + TestSelect csi_sample_spacing_; + TestSwitch sensing_; }; TEST_F(ESPectreTest, StartupDoesNotPublishFalseMotion) { @@ -41,6 +81,14 @@ TEST_F(ESPectreTest, SetupHandsScanResultsAndPersistenceToEspHome) { component_.setup(); EXPECT_TRUE(sdk_->config_at_setup_.wifi_scan_results_managed_externally); EXPECT_FALSE(sdk_->config_at_setup_.persist_runtime_overrides); + // Only the detection algorithm select needs runtime detector selection. + EXPECT_FALSE(sdk_->config_at_setup_.runtime_detector_selection_enabled); +} + +TEST_F(ESPectreTest, DetectionAlgorithmSelectEnablesDetectorSelection) { + add_controls_(); + component_.setup(); + EXPECT_TRUE(sdk_->config_at_setup_.runtime_detector_selection_enabled); } TEST_F(ESPectreTest, ReadinessLossAndRecovery) { @@ -159,6 +207,112 @@ TEST_F(ESPectreTest, ShutdownInvalidatesEntities) { EXPECT_TRUE(std::isnan(movement_.state)); } +TEST_F(ESPectreTest, ControlRequestsApplyInLoop) { + add_controls_(); + component_.setup(); + EXPECT_FLOAT_EQ(motion_on_hits_.state, 4.0f); + EXPECT_STREQ(detection_algorithm_.current_option().c_str(), "lightweight"); + EXPECT_STREQ(csi_sample_spacing_.current_option().c_str(), "half_slot"); + EXPECT_TRUE(sensing_.state); + + component_.request_motion_off_hits(7.0f); + component_.request_detection_algorithm("high_accuracy"); + component_.request_csi_sample_spacing("distinct"); + component_.request_sensing(false); + EXPECT_TRUE(sdk_->services_armed()); + component_.loop(); + EXPECT_EQ(sdk_->config_.motion_on_hits, 4); + EXPECT_EQ(sdk_->config_.motion_off_hits, 7); + EXPECT_FLOAT_EQ(motion_off_hits_.state, 7.0f); + EXPECT_STREQ(detection_algorithm_.current_option().c_str(), "high_accuracy"); + EXPECT_EQ(sdk_->config_.csi_sample_spacing, ::espectre::TemporalCsiSpacing::DISTINCT); + EXPECT_STREQ(csi_sample_spacing_.current_option().c_str(), "distinct"); + // A detector change applies that detector's default threshold. + EXPECT_FLOAT_EQ(threshold_.state, + ::espectre::runtime_default_threshold(::espectre::DetectionAlgorithm::HIGH_ACCURACY)); + EXPECT_FALSE(sdk_->services_armed()); + EXPECT_FALSE(sensing_.state); +} + +TEST_F(ESPectreTest, ControlChangeRepublishesOnlyItsEntity) { + add_controls_(); + component_.setup(); + int spacing_publishes = 0; + int on_hits_publishes = 0; + csi_sample_spacing_.add_on_state_callback([&spacing_publishes](size_t) { spacing_publishes++; }); + motion_on_hits_.add_on_state_callback([&on_hits_publishes](float) { on_hits_publishes++; }); + component_.request_motion_off_hits(7.0f); + component_.request_detection_algorithm("high_accuracy"); + component_.loop(); + EXPECT_FLOAT_EQ(motion_off_hits_.state, 7.0f); + EXPECT_STREQ(detection_algorithm_.current_option().c_str(), "high_accuracy"); + // Both motion hits numbers republish together, since the SDK sets them as a pair. + EXPECT_EQ(on_hits_publishes, 1); + EXPECT_EQ(spacing_publishes, 0); +} + +TEST_F(ESPectreTest, ControlRequestsWithoutEntities) { + component_.setup(); + component_.request_threshold(0.4f); + component_.request_sensing(false); + // Without the select, the SDK does not allow detector changes, so the request is rejected. + component_.request_detection_algorithm("high_accuracy"); + component_.loop(); + EXPECT_FLOAT_EQ(sdk_->snapshot_.threshold, 0.4f); + EXPECT_FALSE(sdk_->services_armed()); + EXPECT_EQ(sdk_->config_.detection_algorithm, ::espectre::DetectionAlgorithm::LIGHTWEIGHT); +} + +TEST_F(ESPectreTest, ThresholdPublishesRuntimeValue) { + add_controls_(); + component_.setup(); + component_.request_threshold(0.4f); + component_.loop(); + EXPECT_FLOAT_EQ(threshold_.state, 0.4f); + + threshold_.publish_state(2.0f); + component_.request_threshold(2.0f); + component_.loop(); + EXPECT_FLOAT_EQ(threshold_.state, 0.4f); + + sdk_->loop_hook = [this]() { sdk_->snapshot_.threshold = 0.3f; }; + component_.loop(); + EXPECT_FLOAT_EQ(threshold_.state, 0.3f); +} + +TEST_F(ESPectreTest, SavedControlsRestoreUntilYamlChanges) { + add_controls_(); + component_.setup(); + component_.request_motion_on_hits(9.0f); + component_.request_detection_algorithm("high_accuracy"); + component_.request_csi_sample_spacing("distinct"); + component_.loop(); + + ESPectreComponent same_yaml; + same_yaml.set_motion_on_hits_number(&motion_on_hits_); + same_yaml.set_detection_algorithm_select(&detection_algorithm_); + same_yaml.set_csi_sample_spacing_select(&csi_sample_spacing_); + same_yaml.setup(); + const auto &restored = ::espectre::RuntimeFrontendController::instance->config_at_setup_; + EXPECT_EQ(restored.motion_on_hits, 9); + EXPECT_EQ(restored.detection_algorithm, ::espectre::DetectionAlgorithm::HIGH_ACCURACY); + EXPECT_EQ(restored.csi_sample_spacing, ::espectre::TemporalCsiSpacing::DISTINCT); + + ESPectreComponent changed_yaml; + changed_yaml.set_motion_on_hits(2); + changed_yaml.set_motion_on_hits_number(&motion_on_hits_); + changed_yaml.setup(); + EXPECT_EQ(::espectre::RuntimeFrontendController::instance->config_at_setup_.motion_on_hits, 2); +} + +TEST_F(ESPectreTest, SensingRestoreModeAppliesBeforeStartup) { + sensing_.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + component_.set_sensing_switch(&sensing_); + component_.setup(); + EXPECT_FALSE(sdk_->services_armed()); + EXPECT_FALSE(sensing_.state); +} + } // namespace esphome::espectre::testing #endif // ESPECTRE_SDK_TEST_DOUBLE diff --git a/tests/components/espectre/validate-legacy.esp32-idf.yaml b/tests/components/espectre/validate-legacy.esp32-idf.yaml new file mode 100644 index 00000000000..2ea85d0f88e --- /dev/null +++ b/tests/components/espectre/validate-legacy.esp32-idf.yaml @@ -0,0 +1,10 @@ +wifi: + ssid: MySSID + password: password1 + +espectre: + +# The select configured directly on the platform, before the detection algorithm select existed. +select: + - platform: espectre + name: CSI Traffic Source diff --git a/tests/unit_tests/components/espectre/test_init.py b/tests/unit_tests/components/espectre/test_init.py index a28adde1034..1f2bdc9f9e1 100644 --- a/tests/unit_tests/components/espectre/test_init.py +++ b/tests/unit_tests/components/espectre/test_init.py @@ -69,6 +69,13 @@ def test_arduino_framework_version(variant: str) -> None: {"csi_traffic_multicast_group": "239.255.0.1"}, "requires traffic_generator_mode: external", ), + ( + { + "csi_capture_profile": "ht_vht", + "traffic_generator_target_mac": "AA:BB:CC:DD:EE:02", + }, + "requires wifi_raw traffic", + ), ], ) def test_incompatible_traffic_options(config: ConfigType, message: str) -> None: @@ -96,6 +103,19 @@ def test_invalid_traffic_targets(address: str) -> None: espectre.CONFIG_SCHEMA({"traffic_generator_target_ip": address}) +@pytest.mark.parametrize("address", ["00:00:00:00:00:00", "01:00:5E:00:00:01"]) +def test_invalid_traffic_target_macs(address: str) -> None: + with pytest.raises(cv.Invalid, match="unicast MAC"): + espectre.CONFIG_SCHEMA({"traffic_generator_target_mac": address}) + + +def test_traffic_target_mac_is_lowercase() -> None: + config = espectre.CONFIG_SCHEMA( + {"traffic_generator_target_mac": "AA:BB:CC:DD:EE:02"} + ) + assert config["traffic_generator_target_mac"] == "aa:bb:cc:dd:ee:02" + + def test_multicast_group_must_be_multicast() -> None: with pytest.raises(cv.Invalid, match="multicast address"): espectre.CONFIG_SCHEMA( From ab95d504fa9c50fb4fc77c17eb42c2c1b4745ecd Mon Sep 17 00:00:00 2001 From: Giorgio Aresu Date: Mon, 2 Nov 2026 01:55:40 +0100 Subject: [PATCH 093/170] [mipi_rgb] Update pins for ZX3D95CE01S-TR-4848 (#22134) --- esphome/components/mipi_rgb/models/panlee.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/mipi_rgb/models/panlee.py b/esphome/components/mipi_rgb/models/panlee.py index 4d579b13e16..717df3e7bf5 100644 --- a/esphome/components/mipi_rgb/models/panlee.py +++ b/esphome/components/mipi_rgb/models/panlee.py @@ -15,6 +15,7 @@ gc9503v.extend( reset_pin={"number": 41, "allow_other_uses": True}, cs_pin=38, de_pin=40, + enable_pin=5, hsync_pin=42, vsync_pin={"number": 41, "allow_other_uses": True}, pclk_pin=39, From e5f6dbe6bfcf7c357f12518483fe811b845fc7ca Mon Sep 17 00:00:00 2001 From: chunckus-aurelius <158745765+chunckus-aurelius@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:35:11 -0500 Subject: [PATCH 094/170] [motion] Add shared tap and double tap support (#22144) --- esphome/components/lis3dh/motion.py | 25 ++--- esphome/components/motion/__init__.py | 92 ++++++++++++++++++- .../components/motion/motion_component.cpp | 51 ++++++++++ esphome/components/motion/motion_component.h | 31 +++++++ esphome/core/defines.h | 1 + tests/component_tests/motion/test_tap.py | 83 +++++++++++++++++ 6 files changed, 266 insertions(+), 17 deletions(-) create mode 100644 tests/component_tests/motion/test_tap.py diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py index 07c6babfc19..d1328e61057 100644 --- a/esphome/components/lis3dh/motion.py +++ b/esphome/components/lis3dh/motion.py @@ -8,7 +8,14 @@ from esphome.components.const import ( CONF_ACCELEROMETER_RANGE, CONF_ACTIVE_HIGH, ) -from esphome.components.motion import AXES, motion_schema, new_motion_component +from esphome.components.motion import ( + ACCELERATION_G, + AXES, + CONF_AXES, + motion_schema, + new_motion_component, + validate_axes, +) import esphome.config_validation as cv from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD from esphome.types import ConfigType @@ -18,7 +25,6 @@ from . import LIS3DHComponent, lis3dh_ns _LOGGER = logging.getLogger(__name__) CONF_OPERATING_MODE = "operating_mode" -CONF_AXES = "axes" CONF_LATCHED = "latched" CONF_HIGH_PASS_FILTER = "high_pass_filter" @@ -84,26 +90,15 @@ def _duration_register(duration_ms: int, odr_key: str) -> int: return math.floor(duration_ms * ODR_HZ[odr_key] / 1000 + 0.5) -def _axes(value: object) -> list[str]: - value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))( - value - ) - if len(set(value)) != len(value): - raise cv.Invalid("Each axis may be listed only once") - return value - - # Without high_pass_filter the threshold includes gravity, so it should exceed 1g. INTERRUPT_SCHEMA = cv.Schema( { cv.Optional(CONF_PIN, default="INT1"): cv.enum( INTERRUPT_PIN_OPTIONS, upper=True ), - cv.Optional(CONF_THRESHOLD, default="1.1g"): cv.All( - cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float - ), + cv.Optional(CONF_THRESHOLD, default="1.1g"): ACCELERATION_G, cv.Optional(CONF_DURATION, default="0ms"): cv.positive_time_period_milliseconds, - cv.Optional(CONF_AXES, default=AXES): _axes, + cv.Optional(CONF_AXES, default=AXES): validate_axes, cv.Optional(CONF_LATCHED, default=True): cv.boolean, cv.Optional(CONF_ACTIVE_HIGH, default=True): cv.boolean, cv.Optional(CONF_HIGH_PASS_FILTER, default=False): cv.boolean, diff --git a/esphome/components/motion/__init__.py b/esphome/components/motion/__init__.py index b7f39dd25dc..0d4e0ffd44c 100644 --- a/esphome/components/motion/__init__.py +++ b/esphome/components/motion/__init__.py @@ -4,12 +4,20 @@ import re from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_ON_DOUBLE_TAP, CONF_ON_TAP, CONF_TAP import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS, CONF_UPDATE_INTERVAL -from esphome.core import ID +from esphome.const import ( + CONF_ID, + CONF_ON_ERROR, + CONF_ON_SUCCESS, + CONF_THRESHOLD, + CONF_UPDATE_INTERVAL, +) +from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv from esphome.helpers import fnv1_hash_object_id +from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -24,6 +32,7 @@ MotionComponent = motion_ns.class_("MotionComponent", cg.PollingComponent) AXES = ["x", "y", "z"] +CONF_AXES = "axes" CONF_AXIS_MAP = "axis_map" CONF_MOTION_ID = "motion_id" CONF_TRANSFORM_MATRIX = "transform_matrix" @@ -33,6 +42,9 @@ CalibrateHeadingAction = motion_ns.class_("CalibrateHeadingAction", automation.A KEY_ACCELEROMETER = "accelerometer" KEY_GYROSCOPE = "gyroscope" +KEY_TAP_THRESHOLD_IDS = "motion_tap_threshold_ids" + +ACCELERATION_G = cv.All(cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float) SENSOR_SCHEMA = cv.Schema( { @@ -83,6 +95,16 @@ def check_has_accelerometer( ) +def validate_axes(value: object) -> list[str]: + """A list of distinct axes.""" + value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))( + value + ) + if len(set(value)) != len(value): + raise cv.Invalid("Each axis may be listed only once") + return value + + def _axis_map(config: dict) -> dict: errors = [] for key, axis in config.items(): @@ -197,6 +219,52 @@ async def new_motion_component(config: dict) -> MockObj: return var +# --- Tap detection --- + +_TAP_ARGS = [(cg.uint8, "axis"), (bool, "negative")] +_TAP_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback", _TAP_ARGS), + automation.CallbackAutomation( + CONF_ON_DOUBLE_TAP, "add_on_double_tap_callback", _TAP_ARGS + ), +) + + +def tap_schema(default_threshold: str, extra: dict | None = None) -> cv.All: + """The `tap:` block for a platform; `extra` adds the chip's timing and pin options.""" + schema = cv.Schema( + { + cv.Optional(CONF_THRESHOLD, default=default_threshold): ACCELERATION_G, + cv.Optional(CONF_AXES, default=AXES): validate_axes, + cv.Optional(CONF_ON_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_DOUBLE_TAP): automation.validate_automation({}), + } + ) + if extra: + schema = schema.extend(extra) + return cv.All(schema, cv.has_at_least_one_key(CONF_ON_TAP, CONF_ON_DOUBLE_TAP)) + + +async def setup_tap(var: MockObj, tap: ConfigType) -> None: + """Generate the shared part of a validated `tap:` block.""" + cg.add_define("USE_MOTION_TAP") + cg.add(var.set_tap_threshold(tap[CONF_THRESHOLD])) + await automation.build_callback_automations(var, tap, _TAP_CALLBACK_AUTOMATIONS) + + +def _final_validate(config: list[ConfigType]) -> None: + ids = CORE.data.get(KEY_TAP_THRESHOLD_IDS, ()) + for index, conf in enumerate(config): + if conf[CONF_ID].id in ids and CONF_TAP not in conf: + raise cv.Invalid( + "'motion.set_tap_threshold' needs a 'tap' block on this sensor", + path=[index, CONF_ID], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + # --- Actions --- CONF_SAVE = "save" @@ -251,6 +319,26 @@ CLEAR_ACTION_SCHEMA = cv.Schema( ) +def _tap_threshold_id(value: object) -> ID: + # Checked against the target's tap block in final validation + value = cv.use_id(MotionComponent)(value) + CORE.data.setdefault(KEY_TAP_THRESHOLD_IDS, set()).add(value.id) + return value + + +# Clamped on the device to the steps the chip allows +automation.register_apply_action( + "motion.set_tap_threshold", + cv.Schema( + { + cv.Required(CONF_ID): _tap_threshold_id, + cv.Required(CONF_THRESHOLD): cv.templatable(ACCELERATION_G), + } + ), + automation.ApplyField(CONF_THRESHOLD, "set_tap_threshold", cg.float_), +) + + automation.register_apply_action( "motion.clear_calibration", CLEAR_ACTION_SCHEMA, diff --git a/esphome/components/motion/motion_component.cpp b/esphome/components/motion/motion_component.cpp index 3171bef4ec9..c2b07aec1f1 100644 --- a/esphome/components/motion/motion_component.cpp +++ b/esphome/components/motion/motion_component.cpp @@ -194,4 +194,55 @@ bool MotionComponent::calibrate_heading() { return true; } +#ifdef USE_MOTION_TAP +void MotionComponent::set_tap_threshold(float threshold_g) { + ESP_LOGW(TAG, "Tap threshold not supported by this platform"); +} + +void MotionComponent::resolve_tap_axis_(uint8_t chip_axis, bool chip_negative, uint8_t &axis, bool &negative) const { + // The base matrix, so level calibration does not move it + axis = 0; + float weight = 0.0f; + for (uint8_t i = 0; i < 3; i++) { + const float w = this->base_matrix_[i * 3 + chip_axis]; + if (std::fabs(w) > std::fabs(weight)) { + weight = w; + axis = i; + } + } + negative = chip_negative != (weight < 0.0f); +} + +void MotionComponent::report_tap_(uint8_t chip_axis, bool chip_negative) { + uint8_t axis; + bool negative; + this->resolve_tap_axis_(chip_axis, chip_negative, axis, negative); + this->tap_callback_.call(axis, negative); +} + +void MotionComponent::report_double_tap_(uint8_t chip_axis, bool chip_negative) { + this->tap_held_ = false; + uint8_t axis; + bool negative; + this->resolve_tap_axis_(chip_axis, chip_negative, axis, negative); + this->double_tap_callback_.call(axis, negative); +} + +void MotionComponent::hold_tap_(uint8_t chip_axis, bool chip_negative, uint32_t now) { + if (this->tap_held_) + return; + this->tap_held_axis_ = chip_axis; + this->tap_held_negative_ = chip_negative; + this->tap_held_since_ = now; + this->tap_held_ = true; +} + +void MotionComponent::release_held_tap_(uint32_t now) { + if (this->tap_held_ && now - this->tap_held_since_ >= this->tap_hold_ms_) { + this->tap_held_ = false; + this->report_tap_(this->tap_held_axis_, this->tap_held_negative_); + } +} +#endif + } // namespace esphome::motion diff --git a/esphome/components/motion/motion_component.h b/esphome/components/motion/motion_component.h index e2674dbdef6..cf8c09afa88 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -84,10 +84,29 @@ class MotionComponent : public PollingComponent { template void add_listener(F &&cb) { this->motion_data_callback_.add(std::forward(cb)); } +#ifdef USE_MOTION_TAP + /// Platforms with a tap block convert this to chip steps and clamp it. + virtual void set_tap_threshold(float threshold_g); + // Callback args: (axis after axis_map, negative side) + template void add_on_tap_callback(F &&cb) { this->tap_callback_.add(std::forward(cb)); } + template void add_on_double_tap_callback(F &&cb) { this->double_tap_callback_.add(std::forward(cb)); } +#endif + protected: // platforms must implement this method to update raw data. virtual bool update_data(MotionData &data) = 0; +#ifdef USE_MOTION_TAP + // chip_axis is the sensor's own axis; it is reported in the axis_map frame + void report_tap_(uint8_t chip_axis, bool chip_negative); + // Also drops a single tap held by hold_tap_() + void report_double_tap_(uint8_t chip_axis, bool chip_negative); + // For chips that report the first tap of a double as a single: hold it for tap_hold_ms_ + void hold_tap_(uint8_t chip_axis, bool chip_negative, uint32_t now); + // Reports a held single tap once tap_hold_ms_ has passed with no double tap + void release_held_tap_(uint32_t now); +#endif + // for mapping axes float matrix_[9]{ 1, 0, 0, 0, 1, 0, 0, 0, 1, @@ -108,6 +127,18 @@ class MotionComponent : public PollingComponent { uint32_t pref_key_{0}; uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup() ESPPreferenceObject pref_{}; + +#ifdef USE_MOTION_TAP + void resolve_tap_axis_(uint8_t chip_axis, bool chip_negative, uint8_t &axis, bool &negative) const; + + LazyCallbackManager tap_callback_{}; + LazyCallbackManager double_tap_callback_{}; + uint32_t tap_hold_ms_{0}; + uint32_t tap_held_since_{0}; + uint8_t tap_held_axis_{0}; + bool tap_held_negative_{false}; + bool tap_held_{false}; +#endif }; // --- Actions --- diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 83279dee76d..d43d44df860 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -186,6 +186,7 @@ #define LV_USE_LOG 1 #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define USE_MOTION_TAP #define ETHERNET_INFO_IP_SENSOR_COUNT 5 #define GPS_LISTENER_COUNT 1 #define HDMI_CEC_MESSAGE_CALLBACK_COUNT 2 diff --git a/tests/component_tests/motion/test_tap.py b/tests/component_tests/motion/test_tap.py new file mode 100644 index 00000000000..698aa4844ce --- /dev/null +++ b/tests/component_tests/motion/test_tap.py @@ -0,0 +1,83 @@ +"""Tests for the shared motion tap schema and set_tap_threshold validation.""" + +from __future__ import annotations + +import pytest + +from esphome.components.const import CONF_ON_DOUBLE_TAP, CONF_ON_TAP, CONF_TAP +from esphome.components.motion import ( + AXES, + CONF_AXES, + KEY_TAP_THRESHOLD_IDS, + _final_validate, + tap_schema, + validate_axes, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.core import CORE, ID + + +def test_tap_defaults() -> None: + """Threshold takes the platform default and all axes are watched.""" + config = tap_schema("0.6g")({CONF_ON_TAP: []}) + assert config[CONF_THRESHOLD] == pytest.approx(0.6) + assert config[CONF_AXES] == AXES + + +def test_tap_needs_a_trigger() -> None: + """A tap block with neither trigger does nothing, so it is rejected.""" + with pytest.raises(cv.Invalid, match="at least one of"): + tap_schema("0.6g")({}) + + +def test_tap_accepts_both_triggers() -> None: + """Single and double taps can be watched together.""" + config = tap_schema("0.6g")({CONF_ON_TAP: [], CONF_ON_DOUBLE_TAP: []}) + assert CONF_ON_TAP in config + assert CONF_ON_DOUBLE_TAP in config + + +def test_tap_threshold_takes_g() -> None: + """The threshold is in g, with or without the unit.""" + assert tap_schema("0.6g")({CONF_THRESHOLD: "1.5g", CONF_ON_TAP: []})[ + CONF_THRESHOLD + ] == pytest.approx(1.5) + with pytest.raises(cv.Invalid): + tap_schema("0.6g")({CONF_THRESHOLD: "-1g", CONF_ON_TAP: []}) + + +def test_tap_extra_keys_are_platform_only() -> None: + """A platform's own options are accepted only when it adds them.""" + extra = {cv.Optional("window", default="250ms"): cv.positive_time_period} + assert "window" in tap_schema("0.6g", extra)({CONF_ON_TAP: []}) + with pytest.raises(cv.Invalid): + tap_schema("0.6g")({"window": "250ms", CONF_ON_TAP: []}) + + +def test_axes_reject_duplicates() -> None: + """An axis listed twice is a mistake.""" + assert validate_axes(["X", "z"]) == ["x", "z"] + with pytest.raises(cv.Invalid, match="only once"): + validate_axes(["x", "x"]) + + +@pytest.mark.parametrize("has_tap", [True, False]) +def test_set_tap_threshold_needs_tap_block( + has_tap: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + """The threshold setter is compiled out without a tap block.""" + monkeypatch.setitem(CORE.data, KEY_TAP_THRESHOLD_IDS, {"accel"}) + conf = {CONF_ID: ID("accel")} + if has_tap: + conf[CONF_TAP] = {} + _final_validate([{CONF_ID: ID("other")}, conf]) + else: + with pytest.raises(cv.Invalid, match="needs a 'tap' block") as err: + _final_validate([{CONF_ID: ID("other")}, conf]) + assert err.value.path == [1, CONF_ID] + + +def test_unreferenced_sensor_needs_no_tap_block() -> None: + """A sensor no threshold action targets needs no tap block.""" + _final_validate([{CONF_ID: ID("other")}]) From 354660cafc8c3baf5b8656f5febd03623831322a Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:54:11 -1000 Subject: [PATCH 095/170] [ci] Refresh integration test durations (#22150) --- .../integration_test_durations.json | 432 +++++++++--------- 1 file changed, 226 insertions(+), 206 deletions(-) diff --git a/tests/integration/integration_test_durations.json b/tests/integration/integration_test_durations.json index a5e3c9a7308..71b07cf0e4a 100644 --- a/tests/integration/integration_test_durations.json +++ b/tests/integration/integration_test_durations.json @@ -1,208 +1,228 @@ { - "tests/integration/test_action_concurrent_reentry.py": 15.02, - "tests/integration/test_addressable_light_transition.py": 14.54, - "tests/integration/test_alarm_control_panel_state_transitions.py": 15.84, - "tests/integration/test_api_action_metadata.py": 14.04, - "tests/integration/test_api_action_responses.py": 17.62, - "tests/integration/test_api_action_timeout.py": 15.17, - "tests/integration/test_api_conditional_memory.py": 12.16, - "tests/integration/test_api_cover_toggle.py": 13.64, - "tests/integration/test_api_custom_services.py": 11.96, - "tests/integration/test_api_decode_wire_types.py": 12.88, - "tests/integration/test_api_empty_message_roundtrip.py": 11.72, - "tests/integration/test_api_encode_boundaries.py": 9.99, - "tests/integration/test_api_get_time_response_timezone.py": 12.23, - "tests/integration/test_api_homeassistant.py": 17.98, - "tests/integration/test_api_homeassistant_action_no_subscriber.py": 7.67, - "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 13.2, - "tests/integration/test_api_homeassistant_entities.py": 10.65, - "tests/integration/test_api_homeassistant_light.py": 33.13, - "tests/integration/test_api_list_entities_backpressure.py": 10.9, - "tests/integration/test_api_message_size_batching.py": 8.36, - "tests/integration/test_api_outgoing_connection.py": 34.71, - "tests/integration/test_api_reboot_timeout.py": 12.36, - "tests/integration/test_api_string_lambda.py": 9.26, - "tests/integration/test_api_vv_logging.py": 14.12, - "tests/integration/test_api_zero_psk_provisioning.py": 18.15, - "tests/integration/test_areas_and_devices.py": 14.82, - "tests/integration/test_automation_wait_actions.py": 13.62, - "tests/integration/test_automations.py": 22.32, - "tests/integration/test_batch_delay_zero_rapid_transitions.py": 10.22, - "tests/integration/test_binary_sensor_autorepeat_filter.py": 11.78, - "tests/integration/test_binary_sensor_invalidate_state.py": 10.5, - "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 8.28, - "tests/integration/test_branch_resume_async.py": 13.54, - "tests/integration/test_build_info.py": 8.43, - "tests/integration/test_camera_mock.py": 9.04, - "tests/integration/test_climate_control_action.py": 15.04, - "tests/integration/test_climate_custom_modes.py": 11.12, - "tests/integration/test_continuation_actions.py": 8.87, - "tests/integration/test_copy_entity_state.py": 9.64, - "tests/integration/test_copy_initially_off.py": 10.97, - "tests/integration/test_cover_control_action.py": 13.41, - "tests/integration/test_cover_stop_tilt_cancel.py": 16.62, - "tests/integration/test_crc8_helper.py": 7.35, - "tests/integration/test_device_id_in_state.py": 18.29, - "tests/integration/test_duplicate_entities.py": 12.49, - "tests/integration/test_entity_icon.py": 12.18, - "tests/integration/test_entity_missing_state.py": 9.01, - "tests/integration/test_fan_turn_on_action.py": 12.41, - "tests/integration/test_fnv1_hash_object_id.py": 6.14, - "tests/integration/test_fnv1a_hash.py": 8.0, - "tests/integration/test_gpio_expander_cache.py": 9.51, - "tests/integration/test_host_logger_thread_safety.py": 9.08, - "tests/integration/test_host_mode_basic.py": 10.07, - "tests/integration/test_host_mode_batch_delay.py": 10.04, - "tests/integration/test_host_mode_climate_basic_state.py": 10.36, - "tests/integration/test_host_mode_climate_control.py": 10.68, - "tests/integration/test_host_mode_empty_string_options.py": 9.22, - "tests/integration/test_host_mode_entity_fields.py": 12.65, - "tests/integration/test_host_mode_fan_preset.py": 9.37, - "tests/integration/test_host_mode_many_entities.py": 23.2, - "tests/integration/test_host_mode_many_entities_multiple_connections.py": 14.92, - "tests/integration/test_host_mode_noise_encryption.py": 27.22, - "tests/integration/test_host_mode_reconnect.py": 7.22, - "tests/integration/test_host_mode_sensor.py": 9.89, - "tests/integration/test_host_ota.py": 93.33, - "tests/integration/test_host_preferences.py": 9.34, - "tests/integration/test_host_preferences_suspend_resume.py": 10.46, - "tests/integration/test_host_write_after_peer_close.py": 12.93, - "tests/integration/test_improv_serial_uart.py": 10.68, - "tests/integration/test_in_range_conditions.py": 13.84, - "tests/integration/test_includes_lambda_order.py": 9.07, - "tests/integration/test_ir_rf_transmit_complete.py": 15.53, - "tests/integration/test_large_message_batching.py": 13.44, - "tests/integration/test_legacy_area.py": 8.07, - "tests/integration/test_light_automations.py": 14.26, - "tests/integration/test_light_binary_effect_off_phase.py": 20.03, - "tests/integration/test_light_calls.py": 16.45, - "tests/integration/test_light_constant_brightness.py": 13.09, - "tests/integration/test_light_control_action.py": 13.41, - "tests/integration/test_light_dim_relative_action.py": 10.41, - "tests/integration/test_light_effect_zero_brightness.py": 14.36, - "tests/integration/test_light_initial_state.py": 9.84, - "tests/integration/test_light_repeat_effect.py": 16.56, - "tests/integration/test_light_restore_state.py": 13.07, - "tests/integration/test_light_resume_effect.py": 10.81, - "tests/integration/test_light_toggle_action.py": 10.35, - "tests/integration/test_light_transition_state_publish_interval.py": 43.17, - "tests/integration/test_lock_automations.py": 8.08, - "tests/integration/test_logger_buffered_recursion_guard.py": 11.54, - "tests/integration/test_loop_disable_enable.py": 9.37, - "tests/integration/test_loop_failed_before_setup.py": 6.93, - "tests/integration/test_loop_interval_decoupling.py": 9.68, - "tests/integration/test_loop_interval_default_not_pulled_forward.py": 13.06, - "tests/integration/test_lvgl_color_picker_render.py": 39.23, - "tests/integration/test_lvgl_headless_render.py": 40.05, - "tests/integration/test_micros_to_millis.py": 6.53, - "tests/integration/test_multi_click_trigger.py": 11.46, - "tests/integration/test_multi_device_preferences.py": 11.03, - "tests/integration/test_noise_encryption_key_protection.py": 16.83, - "tests/integration/test_object_id_api_verification.py": 8.86, - "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 8.93, - "tests/integration/test_object_id_no_friendly_name.py": 11.88, - "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 24.18, - "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 20.36, - "tests/integration/test_online_image_bmp.py": 16.14, - "tests/integration/test_online_image_stalled_download.py": 19.52, - "tests/integration/test_oversized_payloads.py": 50.86, - "tests/integration/test_preference_key_stability.py": 14.0, - "tests/integration/test_runtime_stats.py": 13.09, - "tests/integration/test_safe_mode_loop_runs.py": 6.98, - "tests/integration/test_scheduler_background_wake.py": 19.01, - "tests/integration/test_scheduler_blocking_warning.py": 20.57, - "tests/integration/test_scheduler_bulk_cleanup.py": 7.57, - "tests/integration/test_scheduler_defer_cancel.py": 12.57, - "tests/integration/test_scheduler_defer_cancel_regular.py": 8.4, - "tests/integration/test_scheduler_defer_fifo_simple.py": 9.31, - "tests/integration/test_scheduler_defer_stress.py": 10.83, - "tests/integration/test_scheduler_heap_stress.py": 8.74, - "tests/integration/test_scheduler_internal_id_no_collision.py": 13.5, - "tests/integration/test_scheduler_interval_reschedule.py": 8.17, - "tests/integration/test_scheduler_interval_zero_coerced.py": 9.21, - "tests/integration/test_scheduler_loop_phase_timeout.py": 14.95, - "tests/integration/test_scheduler_null_name.py": 13.19, - "tests/integration/test_scheduler_numeric_id_test.py": 9.63, - "tests/integration/test_scheduler_pool.py": 10.74, - "tests/integration/test_scheduler_rapid_cancellation.py": 8.55, - "tests/integration/test_scheduler_recursive_timeout.py": 13.76, - "tests/integration/test_scheduler_removed_item_race.py": 14.2, - "tests/integration/test_scheduler_self_keyed.py": 9.51, - "tests/integration/test_scheduler_simultaneous_callbacks.py": 13.19, - "tests/integration/test_scheduler_string_test.py": 10.9, - "tests/integration/test_script_array_params.py": 12.44, - "tests/integration/test_script_delay_params.py": 12.44, - "tests/integration/test_script_on_stop_and_choose.py": 10.69, - "tests/integration/test_script_queued.py": 11.25, - "tests/integration/test_script_queued_idle_loop.py": 15.05, - "tests/integration/test_script_wait_on_boot.py": 11.97, - "tests/integration/test_sdl_headless_screenshot.py": 14.47, - "tests/integration/test_select_stringref_trigger.py": 13.37, - "tests/integration/test_sensor_filters_delta.py": 13.76, - "tests/integration/test_sensor_filters_rh_correction.py": 9.72, - "tests/integration/test_sensor_filters_ring_buffer.py": 13.07, - "tests/integration/test_sensor_filters_sliding_window.py": 28.7, - "tests/integration/test_sensor_filters_value_list.py": 9.61, - "tests/integration/test_sensor_raw_state.py": 24.98, - "tests/integration/test_sensor_timeout_filter.py": 10.01, - "tests/integration/test_sensor_timeout_throttle_filter.py": 9.86, - "tests/integration/test_set_internal_at_boot.py": 9.58, - "tests/integration/test_snapshot_display.py": 14.37, - "tests/integration/test_socket_ipv4_resolve.py": 11.12, - "tests/integration/test_socket_tcp_client_link.py": 13.42, - "tests/integration/test_socket_wake_gate_tcp.py": 12.61, - "tests/integration/test_socket_wake_gate_udp.py": 8.99, - "tests/integration/test_source_entity_follow.py": 14.73, - "tests/integration/test_status_flags.py": 16.3, - "tests/integration/test_status_momentary.py": 15.28, - "tests/integration/test_strftime_to.py": 10.03, - "tests/integration/test_syslog.py": 17.43, - "tests/integration/test_tcp_uart_bridge.py": 16.25, - "tests/integration/test_tcp_uart_server.py": 6.52, - "tests/integration/test_template_alarm_control_panel_many_sensors.py": 8.11, - "tests/integration/test_template_climate_basic.py": 13.7, - "tests/integration/test_template_climate_custom_modes.py": 6.88, - "tests/integration/test_template_climate_nonoptimistic.py": 12.68, - "tests/integration/test_template_climate_on_control_ordering.py": 12.72, - "tests/integration/test_template_climate_publish_all_fields.py": 10.76, - "tests/integration/test_template_climate_sensor_push.py": 13.52, - "tests/integration/test_template_climate_set_actions.py": 13.41, - "tests/integration/test_template_climate_two_point_temperature.py": 8.4, - "tests/integration/test_template_text_save.py": 12.87, - "tests/integration/test_template_time.py": 13.35, - "tests/integration/test_text_command.py": 11.78, - "tests/integration/test_text_sensor_raw_state.py": 8.44, - "tests/integration/test_uart_mock_bl0906_stray_rx.py": 13.96, - "tests/integration/test_uart_mock_dfrobot_sen0465.py": 18.9, - "tests/integration/test_uart_mock_dfrobot_sen0609.py": 12.01, - "tests/integration/test_uart_mock_duco.py": 37.61, - "tests/integration/test_uart_mock_ld2410.py": 33.42, - "tests/integration/test_uart_mock_ld2410_query_ack.py": 10.85, - "tests/integration/test_uart_mock_ld2410s.py": 49.7, - "tests/integration/test_uart_mock_ld2412.py": 72.78, - "tests/integration/test_uart_mock_ld2420.py": 21.52, - "tests/integration/test_uart_mock_ld2420_config_exit.py": 39.94, - "tests/integration/test_uart_mock_ld2450.py": 11.62, - "tests/integration/test_uart_mock_ld2450_has_target_only.py": 15.91, - "tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py": 9.66, - "tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py": 14.46, - "tests/integration/test_uart_mock_ld2450_polygon_zones.py": 16.34, - "tests/integration/test_uart_mock_ld2460.py": 13.22, - "tests/integration/test_uart_mock_modbus.py": 249.46, - "tests/integration/test_uart_mock_pylontech.py": 14.81, - "tests/integration/test_uart_mock_ufm01.py": 69.05, - "tests/integration/test_uart_tcp_bridge.py": 7.58, - "tests/integration/test_udp.py": 10.0, - "tests/integration/test_udp_ipv6.py": 11.71, - "tests/integration/test_udp_multicast.py": 12.0, - "tests/integration/test_use_address_runtime.py": 16.14, - "tests/integration/test_valve_control_action.py": 12.37, - "tests/integration/test_varint_five_byte_device_id.py": 9.46, - "tests/integration/test_vnc_client.py": 19.94, - "tests/integration/test_wait_until_mid_loop_timing.py": 8.72, - "tests/integration/test_wait_until_on_boot.py": 4.54, - "tests/integration/test_wait_until_ordering.py": 10.39, - "tests/integration/test_wait_until_reentrant_restart.py": 13.99, - "tests/integration/test_wake_loop_forces_phase_b.py": 10.26, - "tests/integration/test_water_heater_template.py": 20.13 + "tests/integration/test_action_concurrent_reentry.py": 9.0, + "tests/integration/test_addressable_light_transition.py": 7.14, + "tests/integration/test_alarm_control_panel_state_transitions.py": 7.0, + "tests/integration/test_api_action_metadata.py": 6.57, + "tests/integration/test_api_action_responses.py": 6.93, + "tests/integration/test_api_action_timeout.py": 6.64, + "tests/integration/test_api_conditional_memory.py": 3.51, + "tests/integration/test_api_cover_toggle.py": 9.39, + "tests/integration/test_api_custom_services.py": 6.78, + "tests/integration/test_api_decode_wire_types.py": 6.86, + "tests/integration/test_api_empty_message_roundtrip.py": 8.4, + "tests/integration/test_api_encode_boundaries.py": 3.07, + "tests/integration/test_api_event_order_backpressure.py": 8.65, + "tests/integration/test_api_get_time_response_timezone.py": 9.08, + "tests/integration/test_api_homeassistant.py": 5.8, + "tests/integration/test_api_homeassistant_action_no_subscriber.py": 3.36, + "tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 3.39, + "tests/integration/test_api_homeassistant_entities.py": 8.3, + "tests/integration/test_api_homeassistant_light.py": 27.31, + "tests/integration/test_api_list_entities_backpressure.py": 8.73, + "tests/integration/test_api_message_size_batching.py": 8.22, + "tests/integration/test_api_outgoing_connection.py": 27.44, + "tests/integration/test_api_reboot_timeout.py": 2.92, + "tests/integration/test_api_string_lambda.py": 3.18, + "tests/integration/test_api_vv_logging.py": 4.16, + "tests/integration/test_api_zero_psk_provisioning.py": 10.0, + "tests/integration/test_areas_and_devices.py": 5.54, + "tests/integration/test_automation_merged_actions.py": 4.75, + "tests/integration/test_automation_wait_actions.py": 4.95, + "tests/integration/test_automations.py": 7.57, + "tests/integration/test_batch_delay_zero_rapid_transitions.py": 5.9, + "tests/integration/test_binary_sensor_autorepeat_filter.py": 3.89, + "tests/integration/test_binary_sensor_delay_filters.py": 5.41, + "tests/integration/test_binary_sensor_invalidate_state.py": 4.55, + "tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 5.18, + "tests/integration/test_branch_resume_async.py": 7.0, + "tests/integration/test_build_info.py": 3.5, + "tests/integration/test_camera_mock.py": 3.88, + "tests/integration/test_canbus_on_frame.py": 4.24, + "tests/integration/test_climate_control_action.py": 5.72, + "tests/integration/test_climate_custom_modes.py": 5.04, + "tests/integration/test_continuation_actions.py": 8.47, + "tests/integration/test_copy_cover_toggle.py": 2.91, + "tests/integration/test_copy_entity_state.py": 4.61, + "tests/integration/test_copy_initially_off.py": 2.99, + "tests/integration/test_copy_lock_open.py": 6.42, + "tests/integration/test_core_automation_order.py": 4.1, + "tests/integration/test_cover_control_action.py": 4.86, + "tests/integration/test_cover_position_action_cancel.py": 11.88, + "tests/integration/test_cover_stop_tilt_cancel.py": 7.39, + "tests/integration/test_crc8_helper.py": 3.23, + "tests/integration/test_device_id_in_state.py": 4.9, + "tests/integration/test_duplicate_entities.py": 6.75, + "tests/integration/test_entity_icon.py": 4.96, + "tests/integration/test_entity_missing_state.py": 3.08, + "tests/integration/test_fan_turn_on_action.py": 3.88, + "tests/integration/test_fnv1_hash_object_id.py": 3.16, + "tests/integration/test_fnv1a_hash.py": 3.25, + "tests/integration/test_gpio_expander_cache.py": 6.37, + "tests/integration/test_host_logger_thread_safety.py": 7.43, + "tests/integration/test_host_mode_basic.py": 3.22, + "tests/integration/test_host_mode_batch_delay.py": 7.06, + "tests/integration/test_host_mode_climate_basic_state.py": 7.22, + "tests/integration/test_host_mode_climate_control.py": 3.84, + "tests/integration/test_host_mode_empty_string_options.py": 3.73, + "tests/integration/test_host_mode_entity_fields.py": 4.88, + "tests/integration/test_host_mode_fan_preset.py": 3.83, + "tests/integration/test_host_mode_many_entities.py": 5.0, + "tests/integration/test_host_mode_many_entities_multiple_connections.py": 3.73, + "tests/integration/test_host_mode_noise_encryption.py": 5.88, + "tests/integration/test_host_mode_reconnect.py": 4.41, + "tests/integration/test_host_mode_sensor.py": 3.37, + "tests/integration/test_host_ota.py": 70.07, + "tests/integration/test_host_preferences.py": 3.48, + "tests/integration/test_host_preferences_suspend_resume.py": 5.29, + "tests/integration/test_host_write_after_peer_close.py": 3.1, + "tests/integration/test_improv_serial_uart.py": 11.23, + "tests/integration/test_in_range_conditions.py": 4.13, + "tests/integration/test_includes_lambda_order.py": 3.79, + "tests/integration/test_interval_startup_delay.py": 4.82, + "tests/integration/test_ir_rf_transmit_complete.py": 4.29, + "tests/integration/test_large_message_batching.py": 3.41, + "tests/integration/test_legacy_area.py": 3.39, + "tests/integration/test_light_automations.py": 3.66, + "tests/integration/test_light_binary_effect_off_phase.py": 9.05, + "tests/integration/test_light_calls.py": 8.79, + "tests/integration/test_light_constant_brightness.py": 5.01, + "tests/integration/test_light_control_action.py": 3.8, + "tests/integration/test_light_dim_relative_action.py": 3.54, + "tests/integration/test_light_effect_cycle_action.py": 3.99, + "tests/integration/test_light_effect_zero_brightness.py": 5.18, + "tests/integration/test_light_initial_state.py": 3.54, + "tests/integration/test_light_repeat_effect.py": 12.55, + "tests/integration/test_light_restore_state.py": 3.19, + "tests/integration/test_light_resume_effect.py": 6.61, + "tests/integration/test_light_toggle_action.py": 6.22, + "tests/integration/test_light_transition_state_publish_interval.py": 25.95, + "tests/integration/test_lock_automations.py": 3.44, + "tests/integration/test_logger_buffered_recursion_guard.py": 3.3, + "tests/integration/test_loop_disable_enable.py": 3.93, + "tests/integration/test_loop_failed_before_setup.py": 3.63, + "tests/integration/test_loop_interval_decoupling.py": 5.74, + "tests/integration/test_loop_interval_default_not_pulled_forward.py": 6.23, + "tests/integration/test_lvgl_color_picker_render.py": 85.69, + "tests/integration/test_lvgl_headless_render.py": 88.47, + "tests/integration/test_micros_to_millis.py": 3.16, + "tests/integration/test_multi_click_trigger.py": 5.11, + "tests/integration/test_multi_device_preferences.py": 4.04, + "tests/integration/test_noise_encryption_key_protection.py": 10.18, + "tests/integration/test_object_id_api_verification.py": 4.02, + "tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 3.17, + "tests/integration/test_object_id_no_friendly_name.py": 7.38, + "tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 6.28, + "tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 6.02, + "tests/integration/test_online_image_bmp.py": 23.13, + "tests/integration/test_online_image_stalled_download.py": 16.61, + "tests/integration/test_oversized_payloads.py": 21.14, + "tests/integration/test_preference_key_stability.py": 6.24, + "tests/integration/test_runtime_stats.py": 5.82, + "tests/integration/test_safe_mode_loop_runs.py": 2.96, + "tests/integration/test_scheduler_background_wake.py": 8.07, + "tests/integration/test_scheduler_blocking_warning.py": 10.37, + "tests/integration/test_scheduler_bulk_cleanup.py": 3.46, + "tests/integration/test_scheduler_defer_cancel.py": 2.99, + "tests/integration/test_scheduler_defer_cancel_regular.py": 3.01, + "tests/integration/test_scheduler_defer_fifo_simple.py": 4.34, + "tests/integration/test_scheduler_defer_stress.py": 4.66, + "tests/integration/test_scheduler_heap_stress.py": 2.95, + "tests/integration/test_scheduler_internal_id_no_collision.py": 5.5, + "tests/integration/test_scheduler_interval_reschedule.py": 3.69, + "tests/integration/test_scheduler_interval_zero_coerced.py": 3.91, + "tests/integration/test_scheduler_loop_phase_timeout.py": 7.58, + "tests/integration/test_scheduler_null_name.py": 3.42, + "tests/integration/test_scheduler_numeric_id_test.py": 4.47, + "tests/integration/test_scheduler_pool.py": 4.5, + "tests/integration/test_scheduler_rapid_cancellation.py": 3.45, + "tests/integration/test_scheduler_recursive_timeout.py": 3.36, + "tests/integration/test_scheduler_removed_item_race.py": 3.41, + "tests/integration/test_scheduler_self_keyed.py": 4.78, + "tests/integration/test_scheduler_simultaneous_callbacks.py": 3.52, + "tests/integration/test_scheduler_string_test.py": 4.96, + "tests/integration/test_scheduler_verbose_log_on_message.py": 3.45, + "tests/integration/test_script_array_params.py": 4.47, + "tests/integration/test_script_delay_params.py": 5.27, + "tests/integration/test_script_on_stop_and_choose.py": 3.72, + "tests/integration/test_script_queued.py": 5.96, + "tests/integration/test_script_queued_idle_loop.py": 3.6, + "tests/integration/test_script_synchronous.py": 4.27, + "tests/integration/test_script_wait_on_boot.py": 3.65, + "tests/integration/test_sdl_headless_screenshot.py": 11.02, + "tests/integration/test_select_stringref_trigger.py": 4.15, + "tests/integration/test_sensor_filters_apply.py": 3.27, + "tests/integration/test_sensor_filters_delta.py": 5.1, + "tests/integration/test_sensor_filters_rh_correction.py": 3.85, + "tests/integration/test_sensor_filters_ring_buffer.py": 4.24, + "tests/integration/test_sensor_filters_sliding_window.py": 18.16, + "tests/integration/test_sensor_filters_value_list.py": 5.34, + "tests/integration/test_sensor_raw_state.py": 11.18, + "tests/integration/test_sensor_timeout_filter.py": 4.49, + "tests/integration/test_sensor_timeout_throttle_filter.py": 9.67, + "tests/integration/test_set_internal_at_boot.py": 3.59, + "tests/integration/test_snapshot_display.py": 13.28, + "tests/integration/test_socket_ipv4_resolve.py": 3.57, + "tests/integration/test_socket_tcp_client_link.py": 4.27, + "tests/integration/test_socket_wake_gate_tcp.py": 5.72, + "tests/integration/test_socket_wake_gate_udp.py": 3.87, + "tests/integration/test_source_entity_follow.py": 6.01, + "tests/integration/test_status_flags.py": 9.81, + "tests/integration/test_status_momentary.py": 6.71, + "tests/integration/test_strftime_to.py": 4.88, + "tests/integration/test_syslog.py": 9.27, + "tests/integration/test_tcp_uart_bridge.py": 5.96, + "tests/integration/test_tcp_uart_server.py": 3.56, + "tests/integration/test_template_alarm_control_panel_many_sensors.py": 3.65, + "tests/integration/test_template_climate_basic.py": 3.45, + "tests/integration/test_template_climate_custom_modes.py": 3.5, + "tests/integration/test_template_climate_nonoptimistic.py": 3.58, + "tests/integration/test_template_climate_on_control_ordering.py": 3.56, + "tests/integration/test_template_climate_publish_all_fields.py": 10.85, + "tests/integration/test_template_climate_sensor_push.py": 3.99, + "tests/integration/test_template_climate_set_actions.py": 3.94, + "tests/integration/test_template_climate_two_point_temperature.py": 3.59, + "tests/integration/test_template_text_save.py": 3.99, + "tests/integration/test_template_time.py": 4.64, + "tests/integration/test_text_command.py": 3.45, + "tests/integration/test_text_sensor_raw_state.py": 5.02, + "tests/integration/test_uart_mock_bl0906_stray_rx.py": 4.91, + "tests/integration/test_uart_mock_dfrobot_sen0395.py": 6.92, + "tests/integration/test_uart_mock_dfrobot_sen0465.py": 10.14, + "tests/integration/test_uart_mock_dfrobot_sen0609.py": 5.11, + "tests/integration/test_uart_mock_duco.py": 24.53, + "tests/integration/test_uart_mock_ld2410.py": 16.64, + "tests/integration/test_uart_mock_ld2410_query_ack.py": 3.83, + "tests/integration/test_uart_mock_ld2410_selects.py": 4.07, + "tests/integration/test_uart_mock_ld2410s.py": 31.9, + "tests/integration/test_uart_mock_ld2412.py": 54.11, + "tests/integration/test_uart_mock_ld2420.py": 12.1, + "tests/integration/test_uart_mock_ld2420_config_exit.py": 33.4, + "tests/integration/test_uart_mock_ld2420_select_mode.py": 11.06, + "tests/integration/test_uart_mock_ld2450.py": 3.93, + "tests/integration/test_uart_mock_ld2450_has_target_only.py": 5.74, + "tests/integration/test_uart_mock_ld2450_link_loss.py": 31.01, + "tests/integration/test_uart_mock_ld2450_polygon_zone_restore.py": 9.23, + "tests/integration/test_uart_mock_ld2450_polygon_zone_timeout.py": 5.16, + "tests/integration/test_uart_mock_ld2450_polygon_zones.py": 4.94, + "tests/integration/test_uart_mock_ld2450_target_xy.py": 5.59, + "tests/integration/test_uart_mock_ld2460.py": 4.51, + "tests/integration/test_uart_mock_modbus.py": 129.72, + "tests/integration/test_uart_mock_pipsolar_switches.py": 8.46, + "tests/integration/test_uart_mock_pylontech.py": 7.59, + "tests/integration/test_uart_mock_ufm01.py": 55.98, + "tests/integration/test_uart_tcp_bridge.py": 3.36, + "tests/integration/test_udp.py": 4.74, + "tests/integration/test_udp_ipv6.py": 3.05, + "tests/integration/test_udp_multicast.py": 5.42, + "tests/integration/test_use_address_runtime.py": 6.46, + "tests/integration/test_valve_control_action.py": 4.63, + "tests/integration/test_varint_five_byte_device_id.py": 3.73, + "tests/integration/test_vnc_client.py": 25.03, + "tests/integration/test_vnc_client_auth.py": 18.71, + "tests/integration/test_wait_until_mid_loop_timing.py": 3.65, + "tests/integration/test_wait_until_on_boot.py": 3.3, + "tests/integration/test_wait_until_ordering.py": 4.22, + "tests/integration/test_wait_until_reentrant_restart.py": 4.82, + "tests/integration/test_wake_loop_forces_phase_b.py": 4.12, + "tests/integration/test_water_heater_template.py": 8.66 } From 6d5305f1fe84d7c08894d75a0834574e13ec696c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 04:00:16 -1000 Subject: [PATCH 096/170] [ci] Show why a C++ unit test build raised (#22140) --- script/build_helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/script/build_helpers.py b/script/build_helpers.py index dd865ca9d4d..42d3b328d93 100644 --- a/script/build_helpers.py +++ b/script/build_helpers.py @@ -9,6 +9,7 @@ import os from pathlib import Path import subprocess import sys +import traceback from helpers import get_all_dependencies, has_cpp_unit_tests, root_path as _root_path import yaml @@ -423,7 +424,8 @@ def compile_and_get_binary( print(f"Error compiling {label} for {', '.join(components)}") return exit_code, None except Exception as e: # noqa: BLE001 - print(f"Error compiling {label} for {', '.join(components)}: {e}") + print(f"Error compiling {label} for {', '.join(components)}: {e!r}") + traceback.print_exc(file=sys.stdout) return EXIT_COMPILE_ERROR, None # After a successful compilation, locate the executable: From d2b635cd92df8532e35772d69ece6fc7fb4007d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 04:00:36 -1000 Subject: [PATCH 097/170] [uptime] Add unit tests for the uptime text sensor format (#22146) --- .../uptime/text_sensor/uptime_text_sensor.cpp | 7 +- .../uptime/text_sensor/uptime_text_sensor.h | 5 ++ tests/components/uptime/common.h | 25 +++++++ .../text_sensor/uptime_text_sensor_test.cpp | 70 +++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/components/uptime/common.h create mode 100644 tests/components/uptime/text_sensor/uptime_text_sensor_test.cpp diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index 33cde23b946..7b0a8f2515d 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -18,8 +18,8 @@ static void append_unit(char *buf, size_t buf_size, size_t &pos, const char *sep void UptimeTextSensor::setup() { this->update(); } -void UptimeTextSensor::update() { - uint32_t uptime = static_cast(millis_64() / 1000); +// Inlined into update(), so firmware has no extra function +ESPHOME_ALWAYS_INLINE inline void UptimeTextSensor::publish_uptime_(uint32_t uptime) { unsigned interval = this->get_update_interval() / 1000; // Calculate all time units @@ -73,6 +73,9 @@ void UptimeTextSensor::update() { this->publish_state(buf, pos); } +void UptimeTextSensor::update() { this->publish_uptime_(static_cast(millis_64() / 1000)); } +void UptimeTextSensor::publish_uptime(uint32_t uptime) { this->publish_uptime_(uptime); } + float UptimeTextSensor::get_setup_priority() const { return setup_priority::HARDWARE; } void UptimeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Uptime Text Sensor", this); } diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.h b/esphome/components/uptime/text_sensor/uptime_text_sensor.h index 0bdc7fe404a..2e3b2cf331a 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.h +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.h @@ -18,6 +18,9 @@ class UptimeTextSensor final : public text_sensor::TextSensor, public PollingCom separator_(separator), expand_(expand) {} void update() override; + /// Publish uptime (in seconds) as text, keeping only the units the update interval can resolve. + /// For tests; firmware only calls update(). + void publish_uptime(uint32_t uptime); void dump_config() override; void setup() override; @@ -28,6 +31,8 @@ class UptimeTextSensor final : public text_sensor::TextSensor, public PollingCom void set_seconds(const char *seconds_text) { this->seconds_text_ = seconds_text; } protected: + void publish_uptime_(uint32_t uptime); + const char *days_text_; const char *hours_text_; const char *minutes_text_; diff --git a/tests/components/uptime/common.h b/tests/components/uptime/common.h new file mode 100644 index 00000000000..22cf8650fa2 --- /dev/null +++ b/tests/components/uptime/common.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "esphome/components/uptime/text_sensor/uptime_text_sensor.h" + +namespace esphome::uptime::testing { + +static constexpr uint32_t MINUTE = 60; +static constexpr uint32_t HOUR = 60 * MINUTE; +static constexpr uint32_t DAY = 24 * HOUR; + +inline UptimeTextSensor make_default(bool expand = false) { return UptimeTextSensor("d", "h", "m", "s", "", expand); } + +inline UptimeTextSensor make_custom(bool expand = false) { + return UptimeTextSensor("Days", "H", "M", "S", "-", expand); +} + +inline std::string publish(UptimeTextSensor &sensor, uint32_t interval_s, uint32_t uptime_s) { + sensor.set_update_interval(interval_s * 1000); + sensor.publish_uptime(uptime_s); + return sensor.get_state(); +} + +} // namespace esphome::uptime::testing diff --git a/tests/components/uptime/text_sensor/uptime_text_sensor_test.cpp b/tests/components/uptime/text_sensor/uptime_text_sensor_test.cpp new file mode 100644 index 00000000000..023c00a710a --- /dev/null +++ b/tests/components/uptime/text_sensor/uptime_text_sensor_test.cpp @@ -0,0 +1,70 @@ +#include + +#include "../common.h" + +namespace esphome::uptime::testing { + +TEST(UptimeTextSensorTest, DefaultFormatStartsAtHighestNonZeroUnit) { + auto sensor = make_default(); + EXPECT_EQ(publish(sensor, 1, 0), "0s"); + EXPECT_EQ(publish(sensor, 1, 59), "59s"); + EXPECT_EQ(publish(sensor, 1, MINUTE + 1), "1m1s"); + EXPECT_EQ(publish(sensor, 1, HOUR + 5), "1h0m5s"); + EXPECT_EQ(publish(sensor, 1, DAY), "1d0h0m0s"); + EXPECT_EQ(publish(sensor, 1, DAY + HOUR + MINUTE + 1), "1d1h1m1s"); + EXPECT_EQ(publish(sensor, 1, 400 * DAY + 23 * HOUR + 59 * MINUTE + 59), "400d23h59m59s"); +} + +TEST(UptimeTextSensorTest, CustomLabelsAndSeparator) { + auto sensor = make_custom(); + EXPECT_EQ(publish(sensor, 1, 5), "5S"); + EXPECT_EQ(publish(sensor, 1, MINUTE + 1), "1M-1S"); + EXPECT_EQ(publish(sensor, 1, DAY + HOUR + MINUTE + 1), "1Days-1H-1M-1S"); +} + +TEST(UptimeTextSensorTest, ExpandShowsEveryEnabledUnit) { + auto sensor = make_default(true); + EXPECT_EQ(publish(sensor, 1, 5), "0d0h0m5s"); + EXPECT_EQ(publish(sensor, 1, HOUR), "0d1h0m0s"); + + auto custom = make_custom(true); + EXPECT_EQ(publish(custom, 1, 5), "0Days-0H-0M-5S"); + EXPECT_EQ(publish(custom, HOUR, 5), "0Days-0H"); +} + +TEST(UptimeTextSensorTest, SecondsDroppedFrom30sInterval) { + auto sensor = make_default(); + EXPECT_EQ(publish(sensor, 29, DAY + HOUR + MINUTE + 1), "1d1h1m1s"); + EXPECT_EQ(publish(sensor, 30, DAY + HOUR + MINUTE + 1), "1d1h1m"); +} + +TEST(UptimeTextSensorTest, MinutesDroppedFrom30minInterval) { + auto sensor = make_default(); + EXPECT_EQ(publish(sensor, 1799, DAY + HOUR + MINUTE + 1), "1d1h1m"); + EXPECT_EQ(publish(sensor, 1800, DAY + HOUR + MINUTE + 1), "1d1h"); +} + +TEST(UptimeTextSensorTest, HoursDroppedFrom12hInterval) { + auto sensor = make_default(); + EXPECT_EQ(publish(sensor, 12 * HOUR - 1, DAY + HOUR + MINUTE + 1), "1d1h"); + EXPECT_EQ(publish(sensor, 12 * HOUR, DAY + HOUR + MINUTE + 1), "1d"); +} + +TEST(UptimeTextSensorTest, NeverIntervalShowsDaysOnly) { + auto sensor = make_default(); + sensor.set_update_interval(SCHEDULER_DONT_RUN); + sensor.publish_uptime(5); + EXPECT_EQ(sensor.get_state(), "0d"); + sensor.publish_uptime(3 * DAY + HOUR + MINUTE + 1); + EXPECT_EQ(sensor.get_state(), "3d"); +} + +TEST(UptimeTextSensorTest, ShowsSmallestEnabledUnitWhenAllShownUnitsAreZero) { + auto sensor = make_default(); + EXPECT_EQ(publish(sensor, 30, 5), "0m"); + EXPECT_EQ(publish(sensor, 1800, 5), "0h"); + EXPECT_EQ(publish(sensor, 12 * HOUR, 5), "0d"); + EXPECT_EQ(publish(sensor, 30, HOUR + 5), "1h0m"); +} + +} // namespace esphome::uptime::testing From 52fedcbe97ff6fc01460a9fb812bcbd7a4406612 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 04:01:31 -1000 Subject: [PATCH 098/170] [components] Log byte dumps with format_hex_pretty_to (#22149) --- esphome/components/ballu/ballu.cpp | 9 ++---- esphome/components/cm1106/cm1106.cpp | 10 +++++-- .../components/dallas_temp/dallas_temp.cpp | 18 ++++++------ .../components/esphome/ota/ota_esphome.cpp | 7 +++-- esphome/components/gree/gree.cpp | 5 ++-- .../hitachi_ac344/hitachi_ac344.cpp | 7 +---- .../hitachi_ac424/hitachi_ac424.cpp | 7 +---- esphome/components/micronova/micronova.cpp | 4 +-- esphome/components/mics_4514/mics_4514.cpp | 2 +- esphome/components/mitsubishi/mitsubishi.cpp | 5 +--- esphome/components/noblex/noblex.cpp | 8 ++---- esphome/components/senseair/senseair.cpp | 28 ++++++++++++------- esphome/components/tcl112/tcl112.cpp | 11 ++------ esphome/components/tm1637/tm1637.cpp | 2 +- esphome/components/whirlpool/whirlpool.cpp | 23 ++++----------- 15 files changed, 63 insertions(+), 83 deletions(-) diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index b8e48805e18..3588143592b 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -1,4 +1,5 @@ #include "ballu.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::ballu { @@ -90,9 +91,7 @@ void BalluClimate::transmit_state() { for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) remote_state[12] += remote_state[i]; - ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", remote_state[0], - remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], - remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], remote_state[12]); + ESP_LOGV_HEX(TAG, remote_state, BALLU_STATE_LENGTH, BALLU_STATE_LENGTH, "Sending: %s"); // Send code auto transmit = this->transmitter_->transmit(); @@ -136,9 +135,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - ESP_LOGV(TAG, "Received: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", remote_state[0], - remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], - remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], remote_state[12]); + ESP_LOGV_HEX(TAG, remote_state, BALLU_STATE_LENGTH, BALLU_STATE_LENGTH, "Received: %s"); // verify header remote code if (remote_state[0] != 0xc3) diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index d7e8d457039..77e6c844136 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -42,7 +42,10 @@ void CM1106Component::update() { } if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) { - ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN + char hex_buf[format_hex_pretty_size(4)]; + ESP_LOGW(TAG, "Unexpected response: %s", format_hex_pretty_to(hex_buf, response, 4, ' ')); +#endif this->status_set_warning(); return; } @@ -101,7 +104,10 @@ bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t co return false; } if (!this->status_comm_result(memcmp(response, ack, sizeof(response)) == 0)) { - ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN + char hex_buf[format_hex_pretty_size(4)]; + ESP_LOGW(TAG, "Unexpected response: %s", format_hex_pretty_to(hex_buf, response, 4, ' ')); +#endif return false; } return true; diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index b7ac705de47..0a795a42060 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -118,20 +118,18 @@ void DallasTemperatureSensor::setup() { } bool DallasTemperatureSensor::check_scratch_pad_() { - bool chksum_validity = (crc8(this->scratch_pad_, 8) == this->scratch_pad_[8]); + const uint8_t crc = crc8(this->scratch_pad_, 8); + const bool chksum_validity = (crc == this->scratch_pad_[8]); -#ifdef ESPHOME_LOG_LEVEL_VERY_VERBOSE - ESP_LOGVV(TAG, "Scratch pad: %02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X (%02X)", this->scratch_pad_[0], - this->scratch_pad_[1], this->scratch_pad_[2], this->scratch_pad_[3], this->scratch_pad_[4], - this->scratch_pad_[5], this->scratch_pad_[6], this->scratch_pad_[7], this->scratch_pad_[8], - crc8(this->scratch_pad_, 8)); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + char hex_buf[format_hex_pretty_size(sizeof(this->scratch_pad_))]; + if (ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE || !chksum_validity) + format_hex_pretty_to(hex_buf, this->scratch_pad_, sizeof(this->scratch_pad_), '.'); #endif + ESP_LOGVV(TAG, "Scratch pad: %s (%02X)", hex_buf, crc); if (!chksum_validity) { this->status_set_warning(LOG_STR("scratch pad checksum invalid")); - ESP_LOGD(TAG, "Scratch pad: %02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X.%02X (%02X)", this->scratch_pad_[0], - this->scratch_pad_[1], this->scratch_pad_[2], this->scratch_pad_[3], this->scratch_pad_[4], - this->scratch_pad_[5], this->scratch_pad_[6], this->scratch_pad_[7], this->scratch_pad_[8], - crc8(this->scratch_pad_, 8)); + ESP_LOGD(TAG, "Scratch pad: %s (%02X)", hex_buf, crc); } return chksum_validity; } diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index df2aa4ee37a..b92263182fe 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -292,8 +292,11 @@ void ESPHomeOTAComponent::handle_handshake_() { // Validate magic bytes if (memcmp(this->handshake_buf_, MAGIC_BYTES, sizeof(MAGIC_BYTES)) != 0) { - ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0], - this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN + char hex_buf[format_hex_pretty_size(HANDSHAKE_BUF_SIZE)]; + ESP_LOGW(TAG, "Magic bytes mismatch! %s", + format_hex_pretty_to(hex_buf, this->handshake_buf_, HANDSHAKE_BUF_SIZE, '-')); +#endif this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC); return; } diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 47d0a1bf832..c27285dbd18 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -589,9 +589,8 @@ bool GreeClimate::on_receive(remote_base::RemoteReceiveData data) { auto decoded = GreeClimateCodec::decode(this->model_, *state); if (!decoded.has_value()) { - ESP_LOGD(TAG, "GREE frame rejected for model %u: %02X %02X %02X %02X %02X %02X %02X %02X", - static_cast(this->model_), (*state)[0], (*state)[1], (*state)[2], (*state)[3], (*state)[4], - (*state)[5], (*state)[6], (*state)[7]); + ESP_LOGD_HEX(TAG, state->data(), state->size(), GREE_STATE_FRAME_SIZE, "GREE frame rejected for model %u: %s", + static_cast(this->model_)); return false; } diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.cpp b/esphome/components/hitachi_ac344/hitachi_ac344.cpp index cf26c422df2..f4310ef2324 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.cpp +++ b/esphome/components/hitachi_ac344/hitachi_ac344.cpp @@ -377,12 +377,7 @@ bool HitachiClimate::on_receive(remote_base::RemoteReceiveData data) { } void HitachiClimate::dump_state_(const char action[], uint8_t state[]) { - for (uint16_t i = 0; i < HITACHI_AC344_STATE_LENGTH - 10; i += 10) { - ESP_LOGV(TAG, "%s: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", action, state[i + 0], state[i + 1], - state[i + 2], state[i + 3], state[i + 4], state[i + 5], state[i + 6], state[i + 7], state[i + 8], - state[i + 9]); - } - ESP_LOGV(TAG, "%s: %02X %02X %02X", action, state[40], state[41], state[42]); + ESP_LOGV_HEX(TAG, state, HITACHI_AC344_STATE_LENGTH, HITACHI_AC344_STATE_LENGTH, "%s: %s", action); } } // namespace esphome::hitachi_ac344 diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.cpp b/esphome/components/hitachi_ac424/hitachi_ac424.cpp index c40d824035a..bcf9b15a9c3 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.cpp +++ b/esphome/components/hitachi_ac424/hitachi_ac424.cpp @@ -340,12 +340,7 @@ bool HitachiClimate::on_receive(remote_base::RemoteReceiveData data) { } void HitachiClimate::dump_state_(const char action[], uint8_t state[]) { - for (uint16_t i = 0; i < HITACHI_AC424_STATE_LENGTH - 10; i += 10) { - ESP_LOGV(TAG, "%s: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", action, state[i + 0], state[i + 1], - state[i + 2], state[i + 3], state[i + 4], state[i + 5], state[i + 6], state[i + 7], state[i + 8], - state[i + 9]); - } - ESP_LOGV(TAG, "%s: %02X %02X %02X", action, state[40], state[41], state[42]); + ESP_LOGV_HEX(TAG, state, HITACHI_AC424_STATE_LENGTH, HITACHI_AC424_STATE_LENGTH, "%s: %s", action); } } // namespace esphome::hitachi_ac424 diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 86b340d5e47..74c74b18b1d 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -1,4 +1,5 @@ #include "micronova.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::micronova { @@ -145,8 +146,7 @@ void MicroNova::send_current_command_() { write_data[2] = this->current_command_.data; // calculate checksum write_data[3] = write_data[0] + write_data[1] + write_data[2]; - ESP_LOGV(TAG, "Sending write request [%02X,%02X,%02X,%02X]", write_data[0], write_data[1], write_data[2], - write_data[3]); + ESP_LOGV_HEX(TAG, write_data, sizeof(write_data), sizeof(write_data), "Sending write request [%s]"); } else { write_len = 2; ESP_LOGV(TAG, "Sending read request [%02X,%02X]", write_data[0], write_data[1]); diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index c42ca7ab465..d7312104188 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -45,7 +45,7 @@ void MICS4514Component::update() { uint8_t data[6]; if (!this->status_comm_result(this->read_register(SENSOR_REGISTER, data, 6) == i2c::ERROR_OK)) return; - ESP_LOGV(TAG, "Got data: %02X %02X %02X %02X %02X %02X", data[0], data[1], data[2], data[3], data[4], data[5]); + ESP_LOGV_HEX(TAG, data, sizeof(data), sizeof(data), "Got data: %s"); uint16_t ox = encode_uint16(data[0], data[1]); uint16_t red = encode_uint16(data[2], data[3]); uint16_t power = encode_uint16(data[4], data[5]); diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 08b63dd313a..41a79af1c39 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -244,10 +244,7 @@ void MitsubishiClimate::transmit_state() { // Checksum remote_state[17] = sum_bytes(remote_state, 17); - ESP_LOGD(TAG, "sending: %02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X", - remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], - remote_state[6], remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], - remote_state[12], remote_state[13], remote_state[14], remote_state[15], remote_state[16], remote_state[17]); + ESP_LOGD_HEX(TAG, remote_state, MITSUBISHI_FRAME_SIZE, MITSUBISHI_FRAME_SIZE, "sending: %s"); auto transmit = this->transmitter_->transmit(); auto *data = transmit.get_data(); diff --git a/esphome/components/noblex/noblex.cpp b/esphome/components/noblex/noblex.cpp index c5bdbdf6214..f5191525031 100644 --- a/esphome/components/noblex/noblex.cpp +++ b/esphome/components/noblex/noblex.cpp @@ -104,8 +104,7 @@ void NoblexClimate::transmit_state() { } crc = reverse_bits(uint8_t(crc & 0x0F)) >> 4; - ESP_LOGD(TAG, "Sending noblex code: %02X%02X %02X%02X %02X%02X %02X%02X", remote_state[0], remote_state[1], - remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], remote_state[7]); + ESP_LOGD_HEX(TAG, remote_state, sizeof(remote_state), sizeof(remote_state), "Sending noblex code: %s"); ESP_LOGV(TAG, "CRC: %01X", crc); @@ -218,9 +217,8 @@ bool NoblexClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - ESP_LOGD(TAG, "Received noblex code: %02X%02X %02X%02X %02X%02X %02X%02X", this->remote_state_[0], - this->remote_state_[1], this->remote_state_[2], this->remote_state_[3], this->remote_state_[4], - this->remote_state_[5], this->remote_state_[6], this->remote_state_[7]); + ESP_LOGD_HEX(TAG, this->remote_state_, sizeof(this->remote_state_), sizeof(this->remote_state_), + "Received noblex code: %s"); auto powered_on = false; if ((this->remote_state_[0] & NOBLEX_POWER) == NOBLEX_POWER) { diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index 1e5ce5c6c10..2c99ca0f950 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -26,9 +26,10 @@ void SenseAirComponent::update() { } if (response[0] != 0xFE || response[1] != 0x04) { - ESP_LOGW(TAG, "Invalid preamble from SenseAir! %02x%02x%02x%02x %02x%02x%02x%02x %02x%02x%02x%02x %02x", - response[0], response[1], response[2], response[3], response[4], response[5], response[6], response[7], - response[8], response[9], response[10], response[11], response[12]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN + char hex_buf[format_hex_pretty_size(sizeof(response))]; + ESP_LOGW(TAG, "Invalid preamble from SenseAir! %s", format_hex_pretty_to(hex_buf, response, sizeof(response), ' ')); +#endif this->status_set_warning(); this->discard_available(); @@ -73,15 +74,20 @@ void SenseAirComponent::background_calibration_result() { } if (response[0] != 0xFE || response[1] != 0x03) { - ESP_LOGE(TAG, "Invalid reply from SenseAir! %02x%02x%02x %02x%02x %02x%02x", response[0], response[1], response[2], - response[3], response[4], response[5], response[6]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR + char hex_buf[format_hex_pretty_size(sizeof(response))]; + ESP_LOGE(TAG, "Invalid reply from SenseAir! %s", format_hex_pretty_to(hex_buf, response, sizeof(response), ' ')); +#endif return; } // Check if 5th bit (register CI6) is set - ESP_LOGI(TAG, "SenseAir Result=%s (%02x%02x%02x %02x%02x %02x%02x)", - (response[4] & 0b100000) != 0 ? LOG_STR_LITERAL("OK") : LOG_STR_LITERAL("NOT_OK"), response[0], response[1], - response[2], response[3], response[4], response[5], response[6]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO + char hex_buf[format_hex_pretty_size(sizeof(response))]; + ESP_LOGI(TAG, "SenseAir Result=%s (%s)", + (response[4] & 0b100000) != 0 ? LOG_STR_LITERAL("OK") : LOG_STR_LITERAL("NOT_OK"), + format_hex_pretty_to(hex_buf, response, sizeof(response), ' ')); +#endif } void SenseAirComponent::abc_enable() { @@ -109,8 +115,10 @@ void SenseAirComponent::abc_get_period() { } if (response[0] != 0xFE || response[1] != 0x03) { - ESP_LOGE(TAG, "Invalid reply from SenseAir! %02x%02x%02x %02x%02x %02x%02x", response[0], response[1], response[2], - response[3], response[4], response[5], response[6]); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_ERROR + char hex_buf[format_hex_pretty_size(sizeof(response))]; + ESP_LOGE(TAG, "Invalid reply from SenseAir! %s", format_hex_pretty_to(hex_buf, response, sizeof(response), ' ')); +#endif return; } diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index e259470e46d..bf7c03dcde4 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -1,4 +1,5 @@ #include "tcl112.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::tcl112 { @@ -114,10 +115,7 @@ void Tcl112Climate::transmit_state() { for (uint8_t checksum_byte = 0; checksum_byte < TCL112_STATE_LENGTH - 1; checksum_byte++) remote_state[TCL112_STATE_LENGTH - 1] += remote_state[checksum_byte]; - ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", remote_state[0], - remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], - remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], remote_state[12], - remote_state[13]); + ESP_LOGV_HEX(TAG, remote_state, TCL112_STATE_LENGTH, TCL112_STATE_LENGTH, "Sending: %s"); auto transmit = this->transmitter_->transmit(); auto *data = transmit.get_data(); @@ -162,10 +160,7 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return false; } - ESP_LOGV(TAG, "Received: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X", - remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], - remote_state[6], remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], - remote_state[12], remote_state[13]); + ESP_LOGV_HEX(TAG, remote_state, TCL112_STATE_LENGTH, TCL112_STATE_LENGTH, "Received: %s"); // two first bytes are constant if (remote_state[0] != 0x23 || remote_state[1] != 0xCB) diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index 03eb427e76b..c49f5bad16d 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -198,7 +198,7 @@ void TM1637Display::stop_() { } void TM1637Display::display() { - ESP_LOGVV(TAG, "Display %02X%02X%02X%02X", buffer_[0], buffer_[1], buffer_[2], buffer_[3]); + ESP_LOGVV_HEX(TAG, this->buffer_, 4, 4, "Display %s"); // Write DATA CMND this->start_(); diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index 782f9a6b8dd..745da563a87 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -53,7 +53,8 @@ WhirlpoolClimate::WhirlpoolClimate() void WhirlpoolClimate::transmit_state() { this->last_transmit_time_ = millis(); // setting the time of the last transmission. - uint8_t remote_state[WHIRLPOOL_STATE_LENGTH] = {0}; + uint8_t remote_state[WHIRLPOOL_STATE_LENGTH]; + memset(remote_state, 0, sizeof(remote_state)); remote_state[0] = 0x83; remote_state[1] = 0x06; remote_state[6] = 0x80; @@ -142,13 +143,7 @@ void WhirlpoolClimate::transmit_state() { remote_state[13] = xor_bytes(&remote_state[2], 11, remote_state[13]); remote_state[20] = xor_bytes(&remote_state[14], 6, remote_state[20]); - ESP_LOGV(TAG, - "Sending: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X " - "%02X %02X %02X", - remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], - remote_state[6], remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], - remote_state[12], remote_state[13], remote_state[14], remote_state[15], remote_state[16], remote_state[17], - remote_state[18], remote_state[19], remote_state[20]); + ESP_LOGV_HEX(TAG, remote_state, WHIRLPOOL_STATE_LENGTH, WHIRLPOOL_STATE_LENGTH, "Sending: %s"); // Send code auto transmit = this->transmitter_->transmit(); @@ -230,7 +225,8 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t remote_state[WHIRLPOOL_STATE_LENGTH] = {0}; + uint8_t remote_state[WHIRLPOOL_STATE_LENGTH]; + memset(remote_state, 0, sizeof(remote_state)); bool skip_footer = false; // Read all bytes. for (int i = 0; i < WHIRLPOOL_STATE_LENGTH; i++) { @@ -262,14 +258,7 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - ESP_LOGV( - TAG, - "Received: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X " - "%02X %02X %02X", - remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], - remote_state[6], remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], - remote_state[12], remote_state[13], remote_state[14], remote_state[15], remote_state[16], remote_state[17], - remote_state[18], remote_state[19], remote_state[20]); + ESP_LOGV_HEX(TAG, remote_state, WHIRLPOOL_STATE_LENGTH, WHIRLPOOL_STATE_LENGTH, "Received: %s"); // verify header remote code if (remote_state[0] != 0x83 || remote_state[1] != 0x06) From 69d96a4707159400b46388e0a437ea57db64301e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 04:33:18 -1000 Subject: [PATCH 099/170] [ota] Work around the LibreTiny OTA issue that bricks RTL8710B on a compressed update (#22111) --- .../components/esphome/ota/ota_esphome.cpp | 5 +- esphome/components/ota/ota_backend.h | 4 +- .../ota/ota_backend_arduino_libretiny.cpp | 64 ++++++++++++++++--- .../ota/ota_backend_arduino_libretiny.h | 16 +++++ esphome/espota2.py | 36 ++++++++++- .../host_ota_deflate_rtl8710b_bit.yaml | 9 +++ tests/integration/test_host_ota.py | 26 ++++++++ tests/unit_tests/test_espota2.py | 59 +++++++++++++++++ 8 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index b92263182fe..5f0b2499c63 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -197,6 +197,9 @@ void ESPHomeOTAComponent::loop() { this->handle_handshake_(); } +// Deflate for an RTL8710B image: 2026.10 firmware does not know this bit, and +// there LibreTiny corrupts a deflate upload into OTA2 (esphome#22012) +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B = 0x40; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; @@ -372,7 +375,7 @@ void ESPHomeOTAComponent::handle_handshake_() { #endif #ifdef USE_OTA_DEFLATE // Offered only once the session memory is in hand; else uncompressed - if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) { + if ((this->ota_features_ & (CLIENT_FEATURE_SUPPORTS_DEFLATE | CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B)) != 0) { // Value initialized: a corrupt stream that back references the // window before it is filled then copies zeros, never stale memory. // Default placement, PSRAM first where present: the session lives for one diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 3e8b5954906..66fec7b8cac 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -126,7 +126,9 @@ class UpdateLock { // (web_server upload) and the backend allows the largest image it can take. // ota_type defaults to an app update, so both call forms must be accepted. // - set_update_md5: expected digest of the incoming image, hex string. -// - write: consume the next chunk; end: finalize and mark bootable. +// - write: consume the next chunk without modifying it; the caller may reuse it +// (the deflate OTA window is also back reference history). end: finalize and +// mark bootable. // - abort: safe to call in any state, including after end(). // - supports_compression: constexpr, whether a gzip image is stored as is and // inflated at reboot. diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 6a6d58c31fd..4275d27c9a0 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -3,8 +3,11 @@ #include "ota_backend.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + #include namespace esphome::ota { @@ -26,6 +29,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ot } bool ret = Update.begin(image_size, U_FLASH); if (ret) { + this->md5_.init(); return OTA_RESPONSE_OK; } @@ -39,23 +43,47 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ot } void ArduinoLibreTinyOTABackend::set_update_md5(const char *md5) { - Update.setMD5(md5); + // Checked in end(); a checksum that does not parse refuses the update this->md5_set_ = true; + this->md5_parsed_ = parse_hex(md5, this->expected_md5_, 16); +} + +bool ArduinoLibreTinyOTABackend::feed_(const uint8_t *data, size_t len) { + while (len != 0) { + const size_t chunk = std::min(len, MAX_FEED); + if (Update.write(data, chunk) != chunk) { + ESP_LOGE(TAG, "Write error: %d", Update.getError()); + return false; + } + this->wrote_ = true; + data += chunk; + len -= chunk; + } + return true; } OTAResponseTypes ArduinoLibreTinyOTABackend::write(uint8_t *data, size_t len) { - size_t written = Update.write(data, len); - if (written == len) { + if (len == 0) return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "Write error: %d", error); - - return OTA_RESPONSE_ERROR_WRITING_FLASH; + this->md5_.add(data, len); + // Keep the last byte back so the image stays incomplete until end() + if ((this->has_pending_ && !this->feed_(&this->pending_, 1)) || !this->feed_(data, len - 1)) + return OTA_RESPONSE_ERROR_WRITING_FLASH; + this->pending_ = data[len - 1]; + this->has_pending_ = true; + return OTA_RESPONSE_OK; } OTAResponseTypes ArduinoLibreTinyOTABackend::end() { + if (this->md5_set_) { + this->md5_.calculate(); + if (!this->md5_parsed_ || !this->md5_.equals_bytes(this->expected_md5_)) { + ESP_LOGE(TAG, "MD5 mismatch"); + return OTA_RESPONSE_ERROR_MD5_MISMATCH; + } + } + if (this->has_pending_ && !this->feed_(&this->pending_, 1)) + return OTA_RESPONSE_ERROR_WRITING_FLASH; // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { @@ -77,9 +105,25 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { } void ArduinoLibreTinyOTABackend::abort() { - if (!this->lock_.is_held()) // refused in begin() + if (!this->lock_.is_held()) // refused in begin(), or end() succeeded return; Update.abort(); + // Bootloaders take a written image LibreTiny never marked: RTL8720C boots the + // valid one with the higher serial number, BK72xx and LN882H install a + // complete download. On LN882H the device image ends before the flasher + // blocks of a full UF2, so the held-back byte does not keep it incomplete. + if (!this->wrote_) + return; +#ifdef USE_LN882X + // lt_ota_switch() ignores revert here; erasing the header voids the download + const bool reverted = lt_flash_erase_block(FLASH_OTA_OFFSET); +#else + // On BK72xx this erases the download header + const bool reverted = lt_ota_switch(/* revert= */ true); +#endif + if (!reverted) { + ESP_LOGE(TAG, "Revert failed"); + } } } // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index fc277bb5e41..069ec6a18f0 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -2,6 +2,7 @@ #ifdef USE_LIBRETINY #include "ota_backend.h" +#include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" namespace esphome::ota { @@ -16,8 +17,23 @@ class ArduinoLibreTinyOTABackend final { static constexpr bool supports_compression() { return false; } private: + // LibreTiny uses a full 512-byte UF2 block in place, and patches it there + // when writing the OTA2 slot, so it never gets more than one byte less + static constexpr size_t MAX_FEED = 511; + + bool feed_(const uint8_t *data, size_t len); + UpdateLock lock_; + md5::MD5Digest md5_{}; + uint8_t expected_md5_[16]; + // The last byte is held back until end() has checked the MD5: lt_ota_end(), + // which Update.abort() runs too, marks the image bootable once every block is + // written. + uint8_t pending_{0}; + bool has_pending_{false}; bool md5_set_{false}; + bool md5_parsed_{false}; + bool wrote_{false}; }; std::unique_ptr make_ota_backend(); diff --git a/esphome/espota2.py b/esphome/espota2.py index 92b70575ad7..385a6e3ba75 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -103,6 +103,33 @@ SERVER_FEATURE_SUPPORTS_UDP = 0x10 # Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE) DEFLATE_WINDOW_BITS = 12 +# Asks for deflate in place of CLIENT_FEATURE_SUPPORTS_DEFLATE for an RTL8710B +# image. 2026.10 firmware does not know it: there an upstream issue corrupts a +# deflate upload into OTA2 and makes it the boot image (esphome#22012) +CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B = 0x40 +_UF2_MAGIC_HEAD = (0x0A324655).to_bytes(4, "little") + (0x9E5D5157).to_bytes( + 4, "little" +) +_UF2_FAMILY_RTL8710B = 0x22E0D6FC +RTL8710B_DEFLATE_NOTICE = ( + "Uploading uncompressed, so this OTA takes longer: the device did not accept " + "a compressed upload. On RTL8710B this is expected while it runs ESPHome " + "2026.10, because of an upstream issue " + "(https://github.com/esphome/esphome/issues/22012); once this update is " + "installed, later OTAs are compressed again." +) + + +def _is_rtl8710b_image(image: bytes) -> bool: + """Whether the image is a LibreTiny UF2 for the RTL8710B (AmebaZ).""" + return ( + len(image) >= 32 + and image[:8] == _UF2_MAGIC_HEAD + and int.from_bytes(image[8:12], "little") & 0x2000 != 0 + and int.from_bytes(image[28:32], "little") == _UF2_FAMILY_RTL8710B + ) + + NOISE_FRAME_INDICATOR = 0x01 NOISE_HANDSHAKE_OK = 0x00 # The device decrypts frames in its transfer buffer (OTA_BUFFER_SIZE, sized @@ -1001,6 +1028,7 @@ def _negotiate_session( noise_psk: str | None, plaintext_fallback: bool = False, allow_plaintext_upload: bool = False, + rtl8710b: bool = False, ) -> tuple[socket.socket | NoiseSocketWrapper, int, int, bool, float]: """Negotiate through the Noise handshake; returns the socket to go on with (wrapped once encrypted), version, features, extended and the slowest @@ -1027,6 +1055,9 @@ def _negotiate_session( | CLIENT_FEATURE_SUPPORTS_DEFLATE | CLIENT_FEATURE_SUPPORTS_UDP ) + if rtl8710b: + features_to_send &= ~CLIENT_FEATURE_SUPPORTS_DEFLATE + features_to_send |= CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B if noise_psk: features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE exchange_start = time.monotonic() @@ -1129,11 +1160,12 @@ def perform_ota( file_contents = file_handle.read() file_size = len(file_contents) _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) + rtl8710b = _is_rtl8710b_image(file_contents) # sock may become a Noise wrapper or a UDP channel; UDP needs the peer tcp_sock = sock sock, version, features, extended_proto, slowest_exchange = _negotiate_session( - sock, noise_psk, plaintext_fallback, allow_plaintext_upload + sock, noise_psk, plaintext_fallback, allow_plaintext_upload, rtl8710b ) _LOGGER.info("Connection established; device supports OTA version %s", version) if isinstance(sock, NoiseSocketWrapper): @@ -1164,6 +1196,8 @@ def perform_ota( ) deflate = bool(extended_proto and features & SERVER_FEATURE_SUPPORTS_DEFLATE) + if rtl8710b and not deflate: + _LOGGER.warning(RTL8710B_DEFLATE_NOTICE) if deflate: import zlib diff --git a/tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml b/tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml new file mode 100644 index 00000000000..ebf7977123d --- /dev/null +++ b/tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index fd31e2643c8..e2102e1644f 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -327,6 +327,32 @@ async def test_host_ota_self_update( await dev.ota(None, None, "second OTA failed -- listener leaked across execv") +@pytest.mark.asyncio +async def test_host_ota_deflate_rtl8710b_bit( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The device inflates an upload asked for with the RTL8710B deflate bit.""" + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + monkeypatch.setattr(espota2, "_is_rtl8710b_image", lambda image: True) + + async with run_binary(dev.binary_path, line_callback=dev.on_log) as ( + proc, + _lines, + ): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await dev.ota(None, None, "RTL8710B deflate upload failed") + assert dev.inflates == 1, "device ignored the RTL8710B deflate offer" + + @pytest.mark.asyncio async def test_host_ota_deflate( yaml_config: str, diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index 05526e98136..bf8929940df 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1582,6 +1582,65 @@ _UPLOAD_TAIL = [ ] +def _uf2_image(family: int, flags: int = 0x2000) -> bytes: + header = struct.pack("<8I", 0x0A324655, 0x9E5D5157, flags, 0, 256, 0, 1, family) + return header + b"firmware" * 100 + + +@pytest.mark.parametrize( + ("image", "expected"), + [ + (_uf2_image(0x22E0D6FC), True), + (_uf2_image(0xE08F7564), False), + (_uf2_image(0x22E0D6FC, flags=0), False), + (b"\xe9" + bytes(31), False), + ], + ids=["rtl8710b", "rtl8720c", "no-family", "esp"], +) +def test_is_rtl8710b_image(image: bytes, expected: bool) -> None: + assert espota2._is_rtl8710b_image(image) is expected + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_rtl8710b_deflate_with_fixed_firmware( + mock_socket: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """An RTL8710B image asks for deflate with the bit only fixed firmware knows.""" + original_content = _uf2_image(0x22E0D6FC) + mock_socket.recv.side_effect = ( + _no_auth_handshake( + espota2.OTA_VERSION_2_0, espota2.SERVER_FEATURE_SUPPORTS_DEFLATE + ) + + _UPLOAD_TAIL + ) + + espota2.perform_ota(mock_socket, None, io.BytesIO(original_content), "test.uf2") + + sent = [c[0][0] for c in mock_socket.sendall.call_args_list] + assert sent[1][0] & espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B + assert not sent[1][0] & espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + assert zlib.decompress(sent[6], -espota2.DEFLATE_WINDOW_BITS) == original_content + assert espota2.RTL8710B_DEFLATE_NOTICE not in caplog.text + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_rtl8710b_uncompressed_with_old_firmware( + mock_socket: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """2026.10 firmware does not know the RTL8710B bit, so the image goes as it is.""" + original_content = _uf2_image(0x22E0D6FC) + mock_socket.recv.side_effect = ( + _no_auth_handshake(espota2.OTA_VERSION_2_0, 0) + _UPLOAD_TAIL + ) + + espota2.perform_ota(mock_socket, None, io.BytesIO(original_content), "test.uf2") + + sent = [c[0][0] for c in mock_socket.sendall.call_args_list] + assert not sent[1][0] & espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + assert original_content in sent + assert espota2.RTL8710B_DEFLATE_NOTICE in caplog.text + + @pytest.mark.usefixtures("mock_time") @pytest.mark.parametrize( "server_features", From 57a5e541180d57093523de4bd6b85693e056bf06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 04:34:00 -1000 Subject: [PATCH 100/170] [uptime] Keep the text sensor format in flash (#22147) --- .../components/uptime/text_sensor/__init__.py | 22 +++++++------ .../uptime/text_sensor/uptime_text_sensor.cpp | 24 +++++++++----- .../uptime/text_sensor/uptime_text_sensor.h | 31 ++++++++----------- tests/components/uptime/common.h | 11 +++++-- 4 files changed, 52 insertions(+), 36 deletions(-) diff --git a/esphome/components/uptime/text_sensor/__init__.py b/esphome/components/uptime/text_sensor/__init__.py index ed6a99c4058..cc2982eeeee 100644 --- a/esphome/components/uptime/text_sensor/__init__.py +++ b/esphome/components/uptime/text_sensor/__init__.py @@ -16,6 +16,7 @@ uptime_ns = cg.esphome_ns.namespace("uptime") UptimeTextSensor = uptime_ns.class_( "UptimeTextSensor", text_sensor.TextSensor, cg.PollingComponent ) +UptimeFormat = uptime_ns.struct("UptimeFormat") CONF_SEPARATOR = "separator" CONF_DAYS = "days" @@ -46,15 +47,18 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: - format = config[CONF_FORMAT] - var = cg.new_Pvariable( - config[CONF_ID], - format[CONF_DAYS], - format[CONF_HOURS], - format[CONF_MINUTES], - format[CONF_SECONDS], - format[CONF_SEPARATOR], - format[CONF_EXPAND], + fmt = config[CONF_FORMAT] + labels = [ + (key, cg.shared_progmem_string(fmt[key])) + for key in (CONF_DAYS, CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_SEPARATOR) + ] + format_table = cg.shared_progmem_array( + "uptime_format", + UptimeFormat, + cg.ArrayInitializer( + cg.StructInitializer(UptimeFormat, *labels, (CONF_EXPAND, fmt[CONF_EXPAND])) + ), ) + var = cg.new_Pvariable(config[CONF_ID], format_table) await text_sensor.register_text_sensor(var, config) await cg.register_component(var, config) diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index 7b0a8f2515d..9871e133389 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -8,18 +8,28 @@ namespace esphome::uptime { ESPHOME_LOG_TAG(TAG, "uptime.sensor"); +static size_t append_flash_str(char *buf, size_t buf_size, size_t pos, const char *str) { +#ifdef USE_ESP8266 + return buf_append_str_p(buf, buf_size, pos, str); +#else + return buf_append_str(buf, buf_size, pos, str); +#endif +} + static void append_unit(char *buf, size_t buf_size, size_t &pos, const char *separator, unsigned value, const char *label) { if (pos > 0) { - pos = buf_append_printf(buf, buf_size, pos, "%s", separator); + pos = append_flash_str(buf, buf_size, pos, separator); } - pos = buf_append_printf(buf, buf_size, pos, "%u%s", value, label); + pos = buf_append_printf(buf, buf_size, pos, "%u", value); + pos = append_flash_str(buf, buf_size, pos, label); } void UptimeTextSensor::setup() { this->update(); } // Inlined into update(), so firmware has no extra function ESPHOME_ALWAYS_INLINE inline void UptimeTextSensor::publish_uptime_(uint32_t uptime) { + const UptimeFormat *format = this->format_; unsigned interval = this->get_update_interval() / 1000; // Calculate all time units @@ -37,7 +47,7 @@ ESPHOME_ALWAYS_INLINE inline void UptimeTextSensor::publish_uptime_(uint32_t upt bool hours_enabled = interval < 12 * 3600; // Show from highest non-zero unit (or all in expand mode) down to smallest enabled - bool show_days = this->expand_ || days > 0; + bool show_days = progmem_read_byte(&format->expand) || days > 0; bool show_hours = hours_enabled && (show_days || hours > 0); bool show_minutes = minutes_enabled && (show_hours || minutes > 0); bool show_seconds = seconds_enabled && (show_minutes || seconds > 0); @@ -62,13 +72,13 @@ ESPHOME_ALWAYS_INLINE inline void UptimeTextSensor::publish_uptime_(uint32_t upt buf[0] = '\0'; // Initialize for empty case if (show_days) - append_unit(buf, sizeof(buf), pos, this->separator_, days, this->days_text_); + append_unit(buf, sizeof(buf), pos, format->separator, days, format->days); if (show_hours) - append_unit(buf, sizeof(buf), pos, this->separator_, hours, this->hours_text_); + append_unit(buf, sizeof(buf), pos, format->separator, hours, format->hours); if (show_minutes) - append_unit(buf, sizeof(buf), pos, this->separator_, minutes, this->minutes_text_); + append_unit(buf, sizeof(buf), pos, format->separator, minutes, format->minutes); if (show_seconds) - append_unit(buf, sizeof(buf), pos, this->separator_, seconds, this->seconds_text_); + append_unit(buf, sizeof(buf), pos, format->separator, seconds, format->seconds); this->publish_state(buf, pos); } diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.h b/esphome/components/uptime/text_sensor/uptime_text_sensor.h index 2e3b2cf331a..7a21ef53800 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.h +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.h @@ -7,16 +7,20 @@ namespace esphome::uptime { +/// Emitted by codegen in PROGMEM once per distinct format. On ESP8266 the labels are PROGMEM strings +/// and expand is read with progmem_read_byte(). +struct UptimeFormat { + const char *days; + const char *hours; + const char *minutes; + const char *seconds; + const char *separator; + uint8_t expand; +}; + class UptimeTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: - UptimeTextSensor(const char *days_text, const char *hours_text, const char *minutes_text, const char *seconds_text, - const char *separator, bool expand) - : days_text_(days_text), - hours_text_(hours_text), - minutes_text_(minutes_text), - seconds_text_(seconds_text), - separator_(separator), - expand_(expand) {} + explicit UptimeTextSensor(const UptimeFormat *format) : format_(format) {} void update() override; /// Publish uptime (in seconds) as text, keeping only the units the update interval can resolve. /// For tests; firmware only calls update(). @@ -25,20 +29,11 @@ class UptimeTextSensor final : public text_sensor::TextSensor, public PollingCom void setup() override; float get_setup_priority() const override; - void set_days(const char *days_text) { this->days_text_ = days_text; } - void set_hours(const char *hours_text) { this->hours_text_ = hours_text; } - void set_minutes(const char *minutes_text) { this->minutes_text_ = minutes_text; } - void set_seconds(const char *seconds_text) { this->seconds_text_ = seconds_text; } protected: void publish_uptime_(uint32_t uptime); - const char *days_text_; - const char *hours_text_; - const char *minutes_text_; - const char *seconds_text_; - const char *separator_; - bool expand_{}; + const UptimeFormat *format_; }; } // namespace esphome::uptime diff --git a/tests/components/uptime/common.h b/tests/components/uptime/common.h index 22cf8650fa2..091c1dd0f25 100644 --- a/tests/components/uptime/common.h +++ b/tests/components/uptime/common.h @@ -10,10 +10,17 @@ static constexpr uint32_t MINUTE = 60; static constexpr uint32_t HOUR = 60 * MINUTE; static constexpr uint32_t DAY = 24 * HOUR; -inline UptimeTextSensor make_default(bool expand = false) { return UptimeTextSensor("d", "h", "m", "s", "", expand); } +static constexpr UptimeFormat DEFAULT_FORMAT{"d", "h", "m", "s", "", false}; +static constexpr UptimeFormat DEFAULT_EXPAND_FORMAT{"d", "h", "m", "s", "", true}; +static constexpr UptimeFormat CUSTOM_FORMAT{"Days", "H", "M", "S", "-", false}; +static constexpr UptimeFormat CUSTOM_EXPAND_FORMAT{"Days", "H", "M", "S", "-", true}; + +inline UptimeTextSensor make_default(bool expand = false) { + return UptimeTextSensor(expand ? &DEFAULT_EXPAND_FORMAT : &DEFAULT_FORMAT); +} inline UptimeTextSensor make_custom(bool expand = false) { - return UptimeTextSensor("Days", "H", "M", "S", "-", expand); + return UptimeTextSensor(expand ? &CUSTOM_EXPAND_FORMAT : &CUSTOM_FORMAT); } inline std::string publish(UptimeTextSensor &sensor, uint32_t interval_s, uint32_t uptime_s) { From d68674597ed87d71229aade8ee1adf66f65be269 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 04:34:38 -1000 Subject: [PATCH 101/170] [const] Move shared charger config keys to components/const (#22161) --- esphome/components/bq25186/__init__.py | 3 +-- esphome/components/const/__init__.py | 4 ++++ esphome/components/sy6970/__init__.py | 10 ++++++---- esphome/components/sy6970/sensor/__init__.py | 3 +-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py index 71f8772bebf..78df18fb042 100644 --- a/esphome/components/bq25186/__init__.py +++ b/esphome/components/bq25186/__init__.py @@ -4,6 +4,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import CONF_CHARGE_ENABLED, CONF_INPUT_CURRENT_LIMIT import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_UPDATE_INTERVAL, CONF_WATCHDOG from esphome.core import CORE, ID, EsphomeError, Lambda, TimePeriod @@ -19,7 +20,6 @@ CONF_BQ25186_ID = "bq25186_id" CONF_CONFIGURE_ON_BOOT = "configure_on_boot" CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" CONF_FAST_CHARGE_CURRENT = "fast_charge_current" -CONF_CHARGE_ENABLED = "charge_enabled" CONF_FLASH_CHARGING_MODE = "flash_charging_mode" CONF_PRECHARGE_IS_ITERM = "precharge_is_iterm" CONF_TERMINATION_PERCENT = "termination_percent" @@ -38,7 +38,6 @@ CONF_SAFETY_TIMER = "safety_timer" CONF_LONG_PRESS_DURATION = "long_press_duration" CONF_HW_RESET_REQUIRES_VIN = "hw_reset_requires_vin" CONF_AUTOWAKE = "autowake" -CONF_INPUT_CURRENT_LIMIT = "input_current_limit" CONF_PUSH_BUTTON_LONG_PRESS_ACTION = "push_button_long_press_action" CONF_WAKE1_TIMER = "wake1_timer" CONF_WAKE2_TIMER = "wake2_timer" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index db9122e7ef2..81abe8c91ce 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -24,6 +24,8 @@ CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BUTTONS = "buttons" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" +CONF_CHARGE_CURRENT = "charge_current" +CONF_CHARGE_ENABLED = "charge_enabled" CONF_CLEAR_CHANNEL = "clear_channel" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" @@ -50,6 +52,7 @@ CONF_HOST = "host" CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_INPUT_CURRENT_LIMIT = "input_current_limit" CONF_IS_WRGB = "is_wrgb" CONF_KEYS = "keys" CONF_LABEL = "label" @@ -74,6 +77,7 @@ CONF_OUT_PIN = "out_pin" CONF_OUT_PIN_LEVEL = "out_pin_level" CONF_PARITY = "parity" CONF_PASSKEY = "passkey" +CONF_PRECHARGE_CURRENT = "precharge_current" CONF_PROVIDER = "provider" CONF_PWM_FREQUENCY = "pwm_frequency" CONF_QUERY_PARAMS = "query_params" diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 23aaceb6a3c..4179f7cab12 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -1,5 +1,11 @@ import esphome.codegen as cg from esphome.components import i2c +from esphome.components.const import ( + CONF_CHARGE_CURRENT, + CONF_CHARGE_ENABLED, + CONF_INPUT_CURRENT_LIMIT, + CONF_PRECHARGE_CURRENT, +) import esphome.config_validation as cv from esphome.types import ConfigType @@ -10,11 +16,7 @@ MULTI_CONF = True CONF_SY6970_ID = "sy6970_id" CONF_ENABLE_STATUS_LED = "enable_status_led" -CONF_INPUT_CURRENT_LIMIT = "input_current_limit" CONF_CHARGE_VOLTAGE = "charge_voltage" -CONF_CHARGE_CURRENT = "charge_current" -CONF_PRECHARGE_CURRENT = "precharge_current" -CONF_CHARGE_ENABLED = "charge_enabled" CONF_ENABLE_ADC = "enable_adc" CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout" diff --git a/esphome/components/sy6970/sensor/__init__.py b/esphome/components/sy6970/sensor/__init__.py index 16db2aa96b3..e2f12501ce1 100644 --- a/esphome/components/sy6970/sensor/__init__.py +++ b/esphome/components/sy6970/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_CHARGE_CURRENT, CONF_PRECHARGE_CURRENT import esphome.config_validation as cv from esphome.const import ( CONF_BATTERY_VOLTAGE, @@ -17,8 +18,6 @@ DEPENDENCIES = ["sy6970"] CONF_VBUS_VOLTAGE = "vbus_voltage" CONF_SYSTEM_VOLTAGE = "system_voltage" -CONF_CHARGE_CURRENT = "charge_current" -CONF_PRECHARGE_CURRENT = "precharge_current" SY6970VbusVoltageSensor = sy6970_ns.class_("SY6970VbusVoltageSensor", sensor.Sensor) SY6970BatteryVoltageSensor = sy6970_ns.class_( From b252c90a5b0d38bb6a2a96c95c95c1244d7c9588 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 04:39:21 -1000 Subject: [PATCH 102/170] [api] Add tests for Home Assistant state subscriptions (#22155) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- tests/components/api/__init__.py | 2 + .../api/test_home_assistant_states.cpp | 402 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 tests/components/api/test_home_assistant_states.cpp diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index 79feb398bda..5d8c4236bb3 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -16,6 +16,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: cg.add_define("USE_API_PLAINTEXT") # Linked wizard inputs only exist next to homeassistant entities, which need this cg.add_define("USE_API_HOMEASSISTANT_STATES") + # Fewer than test_home_assistant_states.cpp registers, so some go past it + cg.add_define("HOMEASSISTANT_STATE_SUBSCRIPTION_COUNT", 4) # test_wizard.cpp supplies the tables that codegen emits for a real build; # it is only compiled when api's own tests are, not when api is a dependency if any(Path(p) == TESTS_DIR for p in CORE.config[CONF_ESPHOME][CONF_INCLUDES]): diff --git a/tests/components/api/test_home_assistant_states.cpp b/tests/components/api/test_home_assistant_states.cpp new file mode 100644 index 00000000000..b56e2f74958 --- /dev/null +++ b/tests/components/api/test_home_assistant_states.cpp @@ -0,0 +1,402 @@ +#ifdef USE_HOST +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/components/api/api_wizard.h" +#include "esphome/components/api/custom_api_device.h" +#include "esphome/components/socket/socket.h" + +namespace esphome::api::testing { + +// What the device sent to subscribe to one state +struct SentSubscription { + std::string entity_id; + std::string attribute; + bool once{false}; + bool operator==(const SentSubscription &other) const = default; +}; + +std::ostream &operator<<(std::ostream &os, const SentSubscription &sub) { + return os << "{" << sub.entity_id << ", '" << sub.attribute << "', once=" << sub.once << "}"; +} + +static bool read_varint(const uint8_t *&pos, const uint8_t *end, uint32_t &value) { + value = 0; + for (uint8_t shift = 0; pos < end && shift < 35; shift += 7) { + uint8_t byte = *pos++; + value |= static_cast(byte & 0x7F) << shift; + if ((byte & 0x80) == 0) + return true; + } + return false; +} + +static SentSubscription decode_subscription(const uint8_t *pos, const uint8_t *end) { + SentSubscription sub; + while (pos < end) { + uint32_t tag, value; + if (!read_varint(pos, end, tag) || !read_varint(pos, end, value)) { + ADD_FAILURE() << "Truncated field"; + break; + } + if ((tag & 0x7) == 2) { + if (value > static_cast(end - pos)) { + ADD_FAILURE() << "Field length " << value << " runs past the payload"; + break; + } + std::string text(reinterpret_cast(pos), value); + pos += value; + if (tag >> 3 == 1) { + sub.entity_id = text; + } else if (tag >> 3 == 2) { + sub.attribute = text; + } + } else if (tag >> 3 == 3) { + sub.once = value != 0; + } + } + return sub; +} + +class HomeAssistantStatesTest : public ::testing::Test { + protected: + void SetUp() override { + // APIServer() makes itself the global_api_server that CustomAPIDevice uses + this->server_ = std::make_unique(); + this->connect_(); + } + + void TearDown() override { + this->connection_.reset(); + if (this->peer_fd_ >= 0) + ::close(this->peer_fd_); + this->server_.reset(); + global_api_server = nullptr; + } + + // A plaintext connection over loopback TCP; the test reads what it sends from peer_fd_ + void connect_() { + int listen_fd = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(listen_fd, 0); + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t len = sizeof(addr); + ASSERT_EQ(::bind(listen_fd, reinterpret_cast(&addr), len), 0); + ASSERT_EQ(::listen(listen_fd, 1), 0); + ASSERT_EQ(::getsockname(listen_fd, reinterpret_cast(&addr), &len), 0); + this->peer_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(this->peer_fd_, 0); + ASSERT_EQ(::connect(this->peer_fd_, reinterpret_cast(&addr), len), 0); + int device_fd = ::accept(listen_fd, nullptr, nullptr); + ::close(listen_fd); + ASSERT_GE(device_fd, 0); + this->connection_ = + std::make_unique(std::make_unique(device_fd), this->server_.get()); + this->connection_->start(); + } + + // Ask for the subscriptions as Home Assistant does, then let the connection send them, one per loop + std::vector request_subscriptions_() { + this->connection_->on_subscribe_home_assistant_states_request(); + for (int i = 0; i < 64; i++) + this->connection_->loop(); + return this->read_sent_(); + } + + std::vector read_sent_() { + std::vector data; + uint8_t chunk[512]; + // Loopback delivery is not instant on every OS; stop once nothing more arrives for a moment + pollfd pfd{this->peer_fd_, POLLIN, 0}; + while (::poll(&pfd, 1, 50) > 0) { + ssize_t got = ::recv(this->peer_fd_, chunk, sizeof(chunk), MSG_DONTWAIT); + if (got <= 0) + break; + data.insert(data.end(), chunk, chunk + got); + } + std::vector sent; + const uint8_t *pos = data.data(); + const uint8_t *end = pos + data.size(); + while (pos < end) { + // Plaintext frame: a zero byte, the payload size and the message type as varints, then the payload + uint32_t size, type; + if (*pos++ != 0 || !read_varint(pos, end, size) || !read_varint(pos, end, type) || + size > static_cast(end - pos)) { + ADD_FAILURE() << "Malformed frame"; + break; + } + if (type == SubscribeHomeAssistantStateResponse::MESSAGE_TYPE) + sent.push_back(decode_subscription(pos, pos + size)); + pos += size; + } + return sent; + } + + // Deliver a state from Home Assistant; the connection writes a terminator after a non-empty state, as in rx_buf_ + void deliver_(const char *entity_id, const char *attribute, const char *state) { + size_t len = strlen(state); + std::string buffer = std::string(state) + "#"; + HomeAssistantStateResponse msg; + msg.entity_id = StringRef(entity_id); + msg.attribute = StringRef(attribute); + msg.state = StringRef(buffer.data(), len); + this->connection_->on_home_assistant_state_response(msg); + if (len > 0) + EXPECT_EQ(buffer[len], '\0'); + } + + std::unique_ptr server_; + std::unique_ptr connection_; + int peer_fd_{-1}; +}; + +TEST_F(HomeAssistantStatesTest, NothingSubscribedSendsNothing) { EXPECT_TRUE(this->request_subscriptions_().empty()); } + +TEST_F(HomeAssistantStatesTest, SubscriptionsAreSentInRegistrationOrder) { + auto noop = [](StringRef) {}; + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, noop); + this->server_->get_home_assistant_state("number.b", "min", noop); + this->server_->subscribe_home_assistant_state("light.c", "brightness", noop); + this->server_->get_home_assistant_state("number.b", "max", noop); + this->server_->subscribe_home_assistant_state("switch.d", nullptr, noop); + + EXPECT_EQ(this->request_subscriptions_(), (std::vector{ + {"sensor.a", "", false}, + {"number.b", "min", true}, + {"light.c", "brightness", false}, + {"number.b", "max", true}, + {"switch.d", "", false}, + })); +} + +TEST_F(HomeAssistantStatesTest, ANewRequestSendsEverySubscriptionAgain) { + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, [](StringRef) {}); + this->server_->subscribe_home_assistant_state("sensor.b", "unit", [](StringRef) {}); + ASSERT_EQ(this->request_subscriptions_().size(), 2u); + EXPECT_TRUE(this->read_sent_().empty()); + EXPECT_EQ(this->request_subscriptions_().size(), 2u); +} + +TEST_F(HomeAssistantStatesTest, StateWithoutAttributeReachesOnlyThePlainSubscription) { + std::vector plain, attribute; + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, + [&](StringRef state) { plain.push_back(state.str()); }); + this->server_->subscribe_home_assistant_state("sensor.a", "unit", + [&](StringRef state) { attribute.push_back(state.str()); }); + + this->deliver_("sensor.a", "", "21.5"); + EXPECT_EQ(plain, (std::vector{"21.5"})); + EXPECT_TRUE(attribute.empty()); +} + +TEST_F(HomeAssistantStatesTest, AttributeReachesOnlyTheMatchingSubscription) { + std::vector plain, unit, other; + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, + [&](StringRef state) { plain.push_back(state.str()); }); + this->server_->subscribe_home_assistant_state("sensor.a", "unit", + [&](StringRef state) { unit.push_back(state.str()); }); + this->server_->subscribe_home_assistant_state("sensor.a", "other", + [&](StringRef state) { other.push_back(state.str()); }); + + this->deliver_("sensor.a", "unit", "C"); + EXPECT_TRUE(plain.empty()); + EXPECT_EQ(unit, (std::vector{"C"})); + EXPECT_TRUE(other.empty()); +} + +TEST_F(HomeAssistantStatesTest, OtherEntitiesAreIgnored) { + int calls = 0; + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, [&](StringRef) { calls++; }); + this->deliver_("sensor.ab", "", "1"); + this->deliver_("sensor.", "", "1"); + this->deliver_("binary_sensor.a", "", "1"); + EXPECT_EQ(calls, 0); +} + +TEST_F(HomeAssistantStatesTest, EverySubscriberOfAStateIsCalledInOrder) { + std::vector order; + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, [&](StringRef) { order.push_back(1); }); + this->server_->subscribe_home_assistant_state("sensor.b", nullptr, [&](StringRef) { order.push_back(99); }); + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, [&](StringRef) { order.push_back(2); }); + this->deliver_("sensor.a", "", "on"); + EXPECT_EQ(order, (std::vector{1, 2})); +} + +TEST_F(HomeAssistantStatesTest, StateIsTerminatedForTheCallback) { + std::string seen; + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, [&](StringRef state) { seen = state.c_str(); }); + this->deliver_("sensor.a", "", "12.5"); + EXPECT_EQ(seen, "12.5"); +} + +TEST_F(HomeAssistantStatesTest, EmptyStateIsDelivered) { + std::vector seen; + this->server_->subscribe_home_assistant_state("sensor.a", nullptr, + [&](StringRef state) { seen.push_back(state.str()); }); + this->deliver_("sensor.a", "", ""); + EXPECT_EQ(seen, (std::vector{""})); +} + +TEST_F(HomeAssistantStatesTest, GetIsCalledForEveryMatchingState) { + // once only asks Home Assistant to send the state a single time; the device keeps the subscription + std::vector seen; + this->server_->get_home_assistant_state("number.a", "min", [&](StringRef state) { seen.push_back(state.str()); }); + this->deliver_("number.a", "min", "1"); + this->deliver_("number.a", "min", "2"); + EXPECT_EQ(seen, (std::vector{"1", "2"})); + EXPECT_EQ(this->request_subscriptions_(), (std::vector{{"number.a", "min", true}})); +} + +TEST_F(HomeAssistantStatesTest, StdStringOverloadsCopyTheirStrings) { + std::vector seen; + { + std::string entity_id = "sensor.temporary"; + std::string attribute = "unit"; + this->server_->subscribe_home_assistant_state( + entity_id, optional(attribute), + std::function([&](StringRef state) { seen.push_back("sub:" + state.str()); })); + this->server_->get_home_assistant_state(entity_id, nullopt, std::function([&](StringRef state) { + seen.push_back("get:" + state.str()); + })); + entity_id.assign(40, 'x'); + attribute.assign(40, 'y'); + } + this->deliver_("sensor.temporary", "unit", "C"); + this->deliver_("sensor.temporary", "", "5"); + EXPECT_EQ(seen, (std::vector{"sub:C", "get:5"})); + EXPECT_EQ(this->request_subscriptions_(), (std::vector{ + {"sensor.temporary", "unit", false}, + {"sensor.temporary", "", true}, + })); +} + +TEST_F(HomeAssistantStatesTest, LegacyStdStringCallbackGetsACopy) { + std::string seen; + this->server_->subscribe_home_assistant_state( + "sensor.a", nullopt, std::function([&](const std::string &state) { seen = state; })); + this->deliver_("sensor.a", "", "on"); + EXPECT_EQ(seen, "on"); +} + +class TestDevice : public CustomAPIDevice { + public: + void on_state(StringRef state) { this->seen.push_back("state:" + state.str()); } + void on_entity_state(const std::string &entity_id, StringRef state) { + this->seen.push_back(entity_id + ":" + state.str()); + } + void on_legacy_state(std::string state) { this->seen.push_back("legacy:" + state); } + void on_legacy_entity_state(std::string entity_id, std::string state) { + this->seen.push_back("legacy " + entity_id + ":" + state); + } + std::vector seen; +}; + +TEST_F(HomeAssistantStatesTest, CustomAPIDeviceSubscriptions) { + TestDevice device; + device.subscribe_homeassistant_state(&TestDevice::on_state, "sensor.a"); + device.subscribe_homeassistant_state(&TestDevice::on_entity_state, std::string("sensor.b"), "unit"); +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + device.subscribe_homeassistant_state(&TestDevice::on_legacy_state, "sensor.c", "unit"); + device.subscribe_homeassistant_state(&TestDevice::on_legacy_entity_state, "sensor.d"); +#pragma GCC diagnostic pop + + this->deliver_("sensor.a", "", "1"); + this->deliver_("sensor.b", "unit", "2"); + this->deliver_("sensor.c", "unit", "3"); + this->deliver_("sensor.d", "", "4"); + // An empty attribute is no attribute + this->deliver_("sensor.b", "", "ignored"); + EXPECT_EQ(device.seen, (std::vector{"state:1", "sensor.b:2", "legacy:3", "legacy sensor.d:4"})); + + EXPECT_EQ(this->request_subscriptions_(), (std::vector{ + {"sensor.a", "", false}, + {"sensor.b", "unit", false}, + {"sensor.c", "unit", false}, + {"sensor.d", "", false}, + })); +} + +TEST_F(HomeAssistantStatesTest, InternalAndCustomAPIDeviceSubscriptionsAreAllSent) { + TestDevice device; + for (const char *entity_id : {"sensor.1", "sensor.2", "sensor.3", "sensor.4", "sensor.5", "sensor.6"}) + this->server_->subscribe_home_assistant_state(entity_id, nullptr, [](StringRef) {}); + device.subscribe_homeassistant_state(&TestDevice::on_state, "sensor.custom"); + + auto sent = this->request_subscriptions_(); + ASSERT_EQ(sent.size(), 7u); + EXPECT_EQ(sent.front().entity_id, "sensor.1"); + EXPECT_EQ(sent[5].entity_id, "sensor.6"); + EXPECT_EQ(sent.back().entity_id, "sensor.custom"); + + this->deliver_("sensor.custom", "", "x"); + EXPECT_EQ(device.seen, (std::vector{"state:x"})); +} + +TEST_F(HomeAssistantStatesTest, CustomAPIDeviceSubscribedFirst) { + TestDevice device; + device.subscribe_homeassistant_state(&TestDevice::on_state, "sensor.custom"); + this->server_->subscribe_home_assistant_state("sensor.1", nullptr, [](StringRef) {}); + this->server_->subscribe_home_assistant_state("sensor.2", nullptr, [](StringRef) {}); + + // Sent in registration order + EXPECT_EQ(this->request_subscriptions_(), (std::vector{ + {"sensor.custom", "", false}, + {"sensor.1", "", false}, + {"sensor.2", "", false}, + })); + this->deliver_("sensor.custom", "", "x"); + EXPECT_EQ(device.seen, (std::vector{"state:x"})); +} + +TEST_F(HomeAssistantStatesTest, MemberCallbacksSeeTheirObject) { + struct Holder { + std::string last; + void subscribe() { + global_api_server->subscribe_home_assistant_state("sensor.a", nullptr, + [this](StringRef state) { this->last = state.str(); }); + } + } holder; + holder.subscribe(); + this->deliver_("sensor.a", "", "on"); + EXPECT_EQ(holder.last, "on"); +} + +#ifdef USE_API_WIZARD_LINKED_INPUTS +TEST_F(HomeAssistantStatesTest, WizardEntityIdIsSentOnceItIsSet) { + // A wizard input's entity id lives in a RAM buffer that is empty until the wizard sets it + static char buffer[WIZARD_ENTITY_ID_BUFFER_SIZE] = ""; + std::string seen; + this->server_->subscribe_home_assistant_state(buffer, nullptr, [&](StringRef state) { seen = state.str(); }); + this->server_->subscribe_home_assistant_state("sensor.fixed", nullptr, [](StringRef) {}); + + EXPECT_EQ(this->request_subscriptions_(), (std::vector{{"sensor.fixed", "", false}})); + + strcpy(buffer, "sensor.chosen"); + this->connection_->resend_state_subscriptions(buffer); + EXPECT_EQ(this->read_sent_(), (std::vector{{"sensor.chosen", "", false}})); + + this->deliver_("sensor.chosen", "", "on"); + EXPECT_EQ(seen, "on"); + buffer[0] = '\0'; +} +#endif + +} // namespace esphome::api::testing +#endif // USE_HOST From ae54e8b696f98a1ae9ce51d1212ae4fe3a1c7456 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 05:23:36 -1000 Subject: [PATCH 103/170] [esp32_ble] Parse Bluedroid hex strings without sscanf (#22081) --- esphome/components/esp32/__init__.py | 5 + esphome/components/esp32_ble/__init__.py | 35 ++++- esphome/components/esp32_ble/bluedroid_hex.h | 61 ++++++++ .../esp32_ble/bluedroid_hex_wraps.cpp | 53 +++++++ esphome/core/defines.h | 1 + script/merge_component_configs.py | 8 +- tests/component_tests/esp32_ble/__init__.py | 0 .../esp32_ble/test_bluedroid_hex_wraps.py | 54 +++++++ .../components/esp32_ble/test.esp32-idf.yaml | 6 + .../esp32_ble/test_bluedroid_hex.cpp | 134 ++++++++++++++++++ tests/script/test_merge_component_configs.py | 25 ++++ 11 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 esphome/components/esp32_ble/bluedroid_hex.h create mode 100644 esphome/components/esp32_ble/bluedroid_hex_wraps.cpp create mode 100644 tests/component_tests/esp32_ble/__init__.py create mode 100644 tests/component_tests/esp32_ble/test_bluedroid_hex_wraps.py create mode 100644 tests/components/esp32_ble/test_bluedroid_hex.cpp diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f934b0cc954..e7353cbeb6b 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -205,6 +205,11 @@ PSRAM_XIP_VARIANTS = { # neither, so the engine is already in the image and the wrap saves nothing. ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} +# Variants whose ROM exports sscanf for the newlib format in use +# (esp32c6.rom.newlib-normal.ld). The ESP32-C2 ROM only has it in the nano +# format, which ESPHome turns off. +ROM_SSCANF_VARIANTS = {VARIANT_ESP32C6} + # NVS encryption (HMAC peripheral scheme) is only available on variants that # expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original # ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index b3478e45449..a088ed619f4 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -16,9 +16,12 @@ from esphome.components.ble_device_base import ( # noqa: F401 # pylint: disabl ) from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import ( + ROM_SSCANF_VARIANTS, add_idf_sdkconfig_option, + add_src_cmake, const, get_esp32_variant, + idf_version, request_bluetooth, ) from esphome.components.esp32.const import VARIANT_ESP32C2 @@ -293,6 +296,19 @@ __all__ = [ NO_BLUETOOTH_VARIANTS = [const.VARIANT_ESP32S2] +# Give bluedroid_hex_wraps.cpp Bluedroid's private headers so its ESP-IDF drift +# checks compile; the bt target's include dirs are read at generate time. Only +# the native ESP-IDF toolchain generates src/CMakeLists.txt. +BLUEDROID_HEADERS_CMAKE = """\ +idf_component_get_property(esphome_bt_lib bt COMPONENT_LIB) +set_source_files_properties( + "${CMAKE_CURRENT_SOURCE_DIR}/esphome/components/esp32_ble/bluedroid_hex_wraps.cpp" + PROPERTIES + INCLUDE_DIRECTORIES "$" + COMPILE_DEFINITIONS USE_ESP32_BLE_BLUEDROID_HEADERS +) +""" + esp32_ble_ns = cg.esphome_ns.namespace("esp32_ble") ESP32BLE = esp32_ble_ns.class_("ESP32BLE", cg.Component) @@ -543,6 +559,20 @@ async def to_code(config: ConfigType) -> None: request_bluetooth() + # Replace Bluedroid's only two sscanf callers so newlib's scanf engine is not + # linked; see bluedroid_hex_wraps.cpp. IDF 6.0+ uses picolibc, not newlib. + if ( + not CORE.using_arduino + and idf_version() < cv.Version(6, 0, 0) + and get_esp32_variant() not in ROM_SSCANF_VARIANTS + ): + cg.add_define("USE_ESP32_BLE_BLUEDROID_HEX_WRAPS") + for symbol in ("btc_config_get_bin", "string_to_bdaddr"): + cg.add_build_flag(f"-Wl,--wrap={symbol}") + # libsrc.a is scanned before libbt.a, so pull the wrap in explicitly + cg.add_build_flag(f"-Wl,--undefined=__wrap_{symbol}") + add_src_cmake(BLUEDROID_HEADERS_CMAKE) + # When PSRAM and BT are used together, Bluedroid should prefer SPIRAM for # heap allocations and use dynamic (heap-based) environment memory tables # instead of large static DRAM arrays. This frees ~40 kB of internal RAM. @@ -612,5 +642,8 @@ for _name, _call in (("ble.enable", "enable()"), ("ble.disable", "disable()")): # ble_advertising.cpp is fully #ifdef'd on USE_ESP32_BLE_ADVERTISING, set # when advertising is enabled here or by esp32_ble_server / esp32_ble_beacon. FILTER_SOURCE_FILES = filter_source_files_from_defines( - {"ble_advertising.cpp": "USE_ESP32_BLE_ADVERTISING"} + { + "ble_advertising.cpp": "USE_ESP32_BLE_ADVERTISING", + "bluedroid_hex_wraps.cpp": "USE_ESP32_BLE_BLUEDROID_HEX_WRAPS", + } ) diff --git a/esphome/components/esp32_ble/bluedroid_hex.h b/esphome/components/esp32_ble/bluedroid_hex.h new file mode 100644 index 00000000000..654801e5be6 --- /dev/null +++ b/esphome/components/esp32_ble/bluedroid_hex.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::esp32_ble { + +/// Parse "xx:xx:xx:xx:xx:xx" as Bluedroid's string_to_bdaddr does: one or two hex digits per byte, ':' between +/// bytes, anything after the sixth byte ignored. addr (6 bytes) is written only on success. +inline bool parse_bdaddr(const char *str, uint8_t *addr) { + uint8_t out[6]; + for (size_t i = 0; i < sizeof(out); i++) { + uint8_t value = parse_hex_char(*str++); + if (value == INVALID_HEX_CHAR) + return false; + uint8_t low = parse_hex_char(*str); + if (low != INVALID_HEX_CHAR) { + value = (value << 4) | low; + str++; + } + if (i < sizeof(out) - 1 && *str++ != ':') + return false; + out[i] = value; + } + memcpy(addr, out, sizeof(out)); + return true; +} + +/// Decode a stored hex string as Bluedroid's btc_config_get_bin does: even length, at most *length bytes, hex +/// digits only. On success *length is the decoded size; on failure value and *length are untouched. +inline bool parse_config_bin(const char *str, size_t len, uint8_t *value, size_t *length) { + if (len % 2 != 0 || len / 2 > *length) + return false; + for (size_t i = 0; i < len; i++) { + if (parse_hex_char(str[i]) == INVALID_HEX_CHAR) + return false; + } + parse_hex(str, len, value, len / 2); + *length = len / 2; + return true; +} + +// Covers every btc_config_get_bin caller in Bluetooth LE builds (bonding keys are at most 28 bytes) +static constexpr size_t CONFIG_BIN_STACK_BYTES = 32; + +/// btc_config_get_bin on top of btc_config_get_str, passed as get_str, which strlcpy's the stored string. +template +bool config_get_bin(GetStr get_str, const char *section, const char *key, uint8_t *value, size_t *length) { + // A stored string too long for *length is truncated to an odd length, which parse_config_bin rejects + size_t size = 2 * *length + 2; + SmallBufferWithHeapFallback<2 * CONFIG_BIN_STACK_BYTES + 2, char> buf(size); + int size_bytes = static_cast(size); + if (!get_str(section, key, buf.get(), &size_bytes)) + return false; + return parse_config_bin(buf.get(), static_cast(size_bytes) - 1, value, length); +} + +} // namespace esphome::esp32_ble diff --git a/esphome/components/esp32_ble/bluedroid_hex_wraps.cpp b/esphome/components/esp32_ble/bluedroid_hex_wraps.cpp new file mode 100644 index 00000000000..ebbb92d0fca --- /dev/null +++ b/esphome/components/esp32_ble/bluedroid_hex_wraps.cpp @@ -0,0 +1,53 @@ +// Linker wraps for the only two sscanf callers in Bluedroid, so newlib's scanf engine (~16 KB) is not linked. +// Same parsing as espressif/esp-idf#19235; drop once ESPHome uses an ESP-IDF release that contains it. + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_BLE_BLUEDROID_HEX_WRAPS) + +#include "bluedroid_hex.h" + +// Set with Bluedroid's private include dirs for this file only (native ESP-IDF toolchain) +#ifdef USE_ESP32_BLE_BLUEDROID_HEADERS +#include +extern "C" { +#include "btc/btc_config.h" +#include "device/bdaddr.h" +#include "stack/btm_api.h" +} +#endif + +namespace esphome::esp32_ble {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +bool btc_config_get_str(const char *section, const char *key, char *value, int *size_bytes); + +bool __wrap_btc_config_get_bin(const char *section, const char *key, uint8_t *value, size_t *length) { + return esphome::esp32_ble::config_get_bin(btc_config_get_str, section, key, value, length); +} + +// bt_bdaddr_t is a struct holding uint8_t address[6] +bool __wrap_string_to_bdaddr(const char *string, uint8_t *addr) { + return esphome::esp32_ble::parse_bdaddr(string, addr); +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#ifdef USE_ESP32_BLE_BLUEDROID_HEADERS +// ESP-IDF drift checks: if one fails, re-check the wraps against espressif/esp-idf#19235 and Bluedroid's sources. +static_assert(std::is_same_v, + "btc_config_get_bin signature changed"); +static_assert(std::is_same_v, + "btc_config_get_str signature changed"); +static_assert(std::is_same_v, + "string_to_bdaddr signature changed"); +static_assert(sizeof(bt_bdaddr_t) == 6 && sizeof(bt_bdaddr_t::address) == 6, + "bt_bdaddr_t is no longer uint8_t address[6]"); +static_assert(sizeof(tBTM_LE_KEY_VALUE) <= esphome::esp32_ble::CONFIG_BIN_STACK_BYTES, + "Bluetooth LE keys outgrew the stack buffer of config_get_bin"); +#endif + +#endif // USE_ESP_IDF && USE_ESP32_BLE_BLUEDROID_HEX_WRAPS diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d43d44df860..c966f117dff 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -506,6 +506,7 @@ #define USE_ESP32_LOG_V2 #define USE_WIFI_SCAN_RESULTS_LOCK #define USE_ESP32_BLE +#define USE_ESP32_BLE_BLUEDROID_HEX_WRAPS #define USE_ESP32_BLE_MAX_CONNECTIONS 3 #define USE_ESP32_BLE_CLIENT #define USE_ESP32_BLE_DEVICE diff --git a/script/merge_component_configs.py b/script/merge_component_configs.py index c2be7be7fd1..8f6c0a1a570 100755 --- a/script/merge_component_configs.py +++ b/script/merge_component_configs.py @@ -428,9 +428,11 @@ def merge_component_configs( # Remove esphome section since it will be provided by the wrapper file # The wrapper file includes this merged config via packages and provides - # the proper esphome: section with name, platform, etc. - if "esphome" in merged_config_data: - del merged_config_data["esphome"] + # the proper esphome: section with name, platform, etc. Build flags are + # kept because a test may rely on them. + esphome_section = merged_config_data.pop("esphome", None) or {} + if build_flags := esphome_section.get("build_flags"): + merged_config_data["esphome"] = {"build_flags": build_flags} # Write merged config output_file.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/component_tests/esp32_ble/__init__.py b/tests/component_tests/esp32_ble/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp32_ble/test_bluedroid_hex_wraps.py b/tests/component_tests/esp32_ble/test_bluedroid_hex_wraps.py new file mode 100644 index 00000000000..1aef13c4217 --- /dev/null +++ b/tests/component_tests/esp32_ble/test_bluedroid_hex_wraps.py @@ -0,0 +1,54 @@ +"""Tests for the Bluedroid sscanf caller wraps.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.esp32.const import KEY_ESP32, KEY_SRC_CMAKE +from esphome.components.esp32_ble import BLUEDROID_HEADERS_CMAKE +from esphome.core import CORE + +_WRAP_FLAGS = { + "-Wl,--wrap=btc_config_get_bin", + "-Wl,--undefined=__wrap_btc_config_get_bin", + "-Wl,--wrap=string_to_bdaddr", + "-Wl,--undefined=__wrap_string_to_bdaddr", +} + + +@pytest.mark.parametrize( + ("variant", "framework", "expected"), + [ + pytest.param("esp32", "{type: esp-idf}", True, id="esp32"), + pytest.param("esp32c3", "{type: esp-idf}", True, id="c3"), + pytest.param("esp32s3", "{type: esp-idf}", True, id="s3"), + pytest.param("esp32c2", "{type: esp-idf}", True, id="c2"), + pytest.param("esp32c6", "{type: esp-idf}", False, id="c6_rom_sscanf"), + pytest.param( + "esp32", "{type: esp-idf, version: 6.0.1}", False, id="idf6_picolibc" + ), + pytest.param("esp32", "{type: arduino}", False, id="arduino"), + ], +) +def test_wraps_only_on_newlib_idf_without_rom_sscanf( + generate_main: Callable[[str | Path], str], + tmp_path: Path, + variant: str, + framework: str, + expected: bool, +) -> None: + """The wraps are emitted only where Bluedroid's sscanf calls link newlib's engine.""" + config = tmp_path / "test.yaml" + config.write_text( + f"esphome:\n name: test\n\nesp32:\n variant: {variant}\n" + f" framework: {framework}\n\nesp32_ble: {{}}\n" + ) + generate_main(config) + assert CORE.build_flags & _WRAP_FLAGS == (_WRAP_FLAGS if expected else set()) + defines = {define.name for define in CORE.defines} + assert ("USE_ESP32_BLE_BLUEDROID_HEX_WRAPS" in defines) is expected + # The drift checks need Bluedroid's private headers + assert (BLUEDROID_HEADERS_CMAKE in CORE.data[KEY_ESP32][KEY_SRC_CMAKE]) is expected diff --git a/tests/components/esp32_ble/test.esp32-idf.yaml b/tests/components/esp32_ble/test.esp32-idf.yaml index 0b2a920c60b..37add6f9e71 100644 --- a/tests/components/esp32_ble/test.esp32-idf.yaml +++ b/tests/components/esp32_ble/test.esp32-idf.yaml @@ -1,6 +1,12 @@ <<: !include common.yaml <<: !include common_use_psram.yaml +esphome: + # Fails the link if anything still calls sscanf; bluedroid_hex_wraps.cpp + # replaces Bluedroid's two callers so newlib's scanf engine stays out. + build_flags: + - "-Wl,--wrap=sscanf" + esp32_ble: io_capability: keyboard_only disable_bt_logs: false diff --git a/tests/components/esp32_ble/test_bluedroid_hex.cpp b/tests/components/esp32_ble/test_bluedroid_hex.cpp new file mode 100644 index 00000000000..92a0604506a --- /dev/null +++ b/tests/components/esp32_ble/test_bluedroid_hex.cpp @@ -0,0 +1,134 @@ +#include + +#include +#include +#include +#include +#include + +#include "esphome/components/esp32_ble/bluedroid_hex.h" + +namespace esphome::esp32_ble::testing { + +namespace { +constexpr std::array UNTOUCHED = {0xde, 0xad, 0xbe, 0xef, 0x55, 0xaa}; + +std::array bdaddr_of(const char *str, bool expect_ok = true) { + std::array addr = UNTOUCHED; + EXPECT_EQ(parse_bdaddr(str, addr.data()), expect_ok) << str; + return addr; +} + +// btc_config_get_str: strlcpy the stored string, then report strlen + 1 +bool config_get_bin_of(const char *stored, uint8_t *value, size_t *length) { + auto get_str = [stored](const char *, const char *, char *out, int *size_bytes) { + if (stored == nullptr) + return false; + size_t n = std::min(strlen(stored), static_cast(*size_bytes) - 1); + memcpy(out, stored, n); + out[n] = '\0'; + *size_bytes = static_cast(n) + 1; + return true; + }; + return config_get_bin(get_str, "section", "key", value, length); +} +} // namespace + +TEST(BluedroidHex, BdaddrTwoDigits) { + EXPECT_EQ(bdaddr_of("aa:BB:0c:d1:Ee:ff"), (std::array{0xaa, 0xbb, 0x0c, 0xd1, 0xee, 0xff})); +} + +TEST(BluedroidHex, BdaddrOneDigit) { + EXPECT_EQ(bdaddr_of("1:2:a:B:0:f"), (std::array{0x01, 0x02, 0x0a, 0x0b, 0x00, 0x0f})); + EXPECT_EQ(bdaddr_of("12:3:45:6:78:9"), (std::array{0x12, 0x03, 0x45, 0x06, 0x78, 0x09})); +} + +TEST(BluedroidHex, BdaddrIgnoresTrailingText) { + EXPECT_EQ(bdaddr_of("01:02:03:04:05:06:07"), (std::array{1, 2, 3, 4, 5, 6})); + // %02x stops after two digits, so a third digit in the last byte is ignored too + EXPECT_EQ(bdaddr_of("01:02:03:04:05:067"), (std::array{1, 2, 3, 4, 5, 0x06})); + EXPECT_EQ(bdaddr_of("01:02:03:04:05:06 extra"), (std::array{1, 2, 3, 4, 5, 6})); +} + +TEST(BluedroidHex, BdaddrRejectsAndLeavesOutputUntouched) { + for (const char *bad : + {"", ":01:02:03:04:05", "01:02:03:04:05", "01:02:03:04:05:", "01:02:03:04:05:g6", "01-02-03-04-05-06", + "012:02:03:04:05:06", "01:02::04:05:06", "0x:02:03:04:05:06", " 1:02:03:04:05:06"}) { + EXPECT_EQ(bdaddr_of(bad, false), UNTOUCHED) << bad; + } +} + +TEST(BluedroidHex, ConfigBinDecodes) { + std::array value{}; + size_t length = value.size(); + ASSERT_TRUE(parse_config_bin("00ff7Ea5", 8, value.data(), &length)); + EXPECT_EQ(length, 4u); + EXPECT_EQ(value, (std::array{0x00, 0xff, 0x7e, 0xa5, 0, 0})); +} + +TEST(BluedroidHex, ConfigBinExactCapacityAndEmpty) { + std::array value{}; + size_t length = value.size(); + ASSERT_TRUE(parse_config_bin("1234", 4, value.data(), &length)); + EXPECT_EQ(length, 2u); + EXPECT_EQ(value, (std::array{0x12, 0x34})); + + length = value.size(); + ASSERT_TRUE(parse_config_bin("", 0, value.data(), &length)); + EXPECT_EQ(length, 0u); +} + +TEST(BluedroidHex, ConfigBinRejectsAndLeavesOutputUntouched) { + struct Case { + const char *str; + size_t capacity; + }; + // odd length, more bytes than the capacity, a non-hex digit + for (const Case &c : {Case{"123", 4}, Case{"123456", 2}, Case{"12g4", 4}, Case{"12 4", 4}, Case{"0x12", 4}}) { + uint8_t value[4] = {0xaa, 0xaa, 0xaa, 0xaa}; + size_t length = c.capacity; + EXPECT_FALSE(parse_config_bin(c.str, strlen(c.str), value, &length)) << c.str; + EXPECT_EQ(length, c.capacity) << c.str; + for (uint8_t b : value) + EXPECT_EQ(b, 0xaa) << c.str; + } +} + +TEST(BluedroidHex, ConfigGetBinThroughStore) { + uint8_t value[CONFIG_BIN_STACK_BYTES + 8] = {}; + size_t length = 3; + ASSERT_TRUE(config_get_bin_of("0a0b0c", value, &length)); + EXPECT_EQ(length, 3u); + EXPECT_EQ(value[2], 0x0c); + + length = 3; + EXPECT_FALSE(config_get_bin_of(nullptr, value, &length)); + EXPECT_EQ(length, 3u); +} + +TEST(BluedroidHex, ConfigGetBinRejectsTooLongAfterTruncation) { + uint8_t value[4] = {0xaa, 0xaa, 0xaa, 0xaa}; + // One and several bytes more than the capacity: both truncate to an odd length + for (const char *stored : {"0102030405", "010203040506070809"}) { + size_t length = 4; + EXPECT_FALSE(config_get_bin_of(stored, value, &length)) << stored; + EXPECT_EQ(length, 4u); + EXPECT_EQ(value[0], 0xaa); + } +} + +TEST(BluedroidHex, ConfigGetBinHeapFallback) { + // Larger than the stack buffer, like a BT Classic HID descriptor + constexpr size_t big = CONFIG_BIN_STACK_BYTES * 4; + char stored[2 * big + 1]; + for (size_t i = 0; i < big; i++) + snprintf(stored + 2 * i, 3, "%02x", static_cast(i)); + uint8_t value[big]; + size_t length = big; + ASSERT_TRUE(config_get_bin_of(stored, value, &length)); + EXPECT_EQ(length, big); + for (size_t i = 0; i < big; i++) + EXPECT_EQ(value[i], static_cast(i)); +} + +} // namespace esphome::esp32_ble::testing diff --git a/tests/script/test_merge_component_configs.py b/tests/script/test_merge_component_configs.py index 27be3b5628c..d45422d1330 100644 --- a/tests/script/test_merge_component_configs.py +++ b/tests/script/test_merge_component_configs.py @@ -155,3 +155,28 @@ def test_list_style_packages_are_expanded(tmp_path: Path) -> None: assert "packages" not in result assert result["wifi"] == {"ssid": "MySSID"} + + +def test_esphome_section_dropped_but_build_flags_kept(tmp_path: Path) -> None: + """The wrapper provides esphome:, but a test's build flags survive the merge.""" + (tmp_path / "flagged").mkdir() + (tmp_path / "flagged" / "test.esp32-idf.yaml").write_text( + 'esphome:\n name_add_mac_suffix: true\n build_flags:\n - "-Wl,--wrap=sscanf"\n' + ) + (tmp_path / "plain").mkdir() + (tmp_path / "plain" / "test.esp32-idf.yaml").write_text( + "esphome:\n name_add_mac_suffix: true\nlogger:\n" + ) + output = tmp_path / "merged.yaml" + + merge_component_configs.merge_component_configs( + ["flagged", "plain"], "esp32-idf", tmp_path, output + ) + assert yaml_util.load_yaml(output)["esphome"] == { + "build_flags": ["-Wl,--wrap=sscanf"] + } + + merge_component_configs.merge_component_configs( + ["plain"], "esp32-idf", tmp_path, output + ) + assert "esphome" not in yaml_util.load_yaml(output) From 4206de5ed04df9012010b417ef964d452fafc0e8 Mon Sep 17 00:00:00 2001 From: Jakub Sztandera Date: Mon, 2 Nov 2026 18:04:48 +0100 Subject: [PATCH 104/170] [ledc] Correct PWM duty scaling (#22160) Co-authored-by: J. Nick Koston --- esphome/components/ledc/ledc_duty.h | 29 +++++++++++ esphome/components/ledc/ledc_output.cpp | 6 +-- tests/components/ledc/__init__.py | 5 ++ tests/components/ledc/duty_to_ticks_test.cpp | 52 ++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 esphome/components/ledc/ledc_duty.h create mode 100644 tests/components/ledc/__init__.py create mode 100644 tests/components/ledc/duty_to_ticks_test.cpp diff --git a/esphome/components/ledc/ledc_duty.h b/esphome/components/ledc/ledc_duty.h new file mode 100644 index 00000000000..d578af5c95f --- /dev/null +++ b/esphome/components/ledc/ledc_duty.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace esphome::ledc { + +/// Ticks for a constant high output, one past the highest PWM level. +inline uint32_t full_scale_ticks(uint8_t bit_depth, uint8_t fractional_bits) { + return uint32_t(1) << (bit_depth + fractional_bits); +} + +/// Convert a 0-1 duty to timer ticks, with fractional_bits extra bits for dithering. +inline uint32_t duty_to_ticks(float state, uint8_t bit_depth, uint8_t fractional_bits) { + const uint32_t max_duty = full_scale_ticks(bit_depth, fractional_bits); + const float scaled_duty = state * max_duty; + auto duty = static_cast(roundf(scaled_duty)); + if (fractional_bits != 0) { + // Fractional values above the last whole tick can wrap the PWM output instead of approaching 100%. + const uint32_t highest_pwm = max_duty - (1U << fractional_bits); + if (duty > highest_pwm) { + const uint32_t midpoint = highest_pwm + (1U << (fractional_bits - 1)); + duty = scaled_duty < midpoint ? highest_pwm : max_duty; + } + } + return duty; +} + +} // namespace esphome::ledc diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index fbd06100dd8..5d2ed137671 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -1,4 +1,5 @@ #include "ledc_output.h" +#include "ledc_duty.h" #include "esphome/core/log.h" #ifdef USE_ESP32 @@ -161,9 +162,8 @@ void LEDCOutput::write_duty_() { #ifdef USE_LEDC_DITHERING fractional_bits = this->dithering_ ? 4 : 0; #endif - const uint32_t max_duty = ((uint32_t(1) << this->bit_depth_) - 1) << fractional_bits; - const float duty_rounded = roundf(this->duty_ * max_duty); - auto duty = static_cast(duty_rounded); + const uint32_t max_duty = full_scale_ticks(this->bit_depth_, fractional_bits); + const uint32_t duty = duty_to_ticks(this->duty_, this->bit_depth_, fractional_bits); if (duty == this->last_duty_) { this->status_clear_warning(); this->disable_loop(); diff --git a/tests/components/ledc/__init__.py b/tests/components/ledc/__init__.py new file mode 100644 index 00000000000..0b6d6fb22f4 --- /dev/null +++ b/tests/components/ledc/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["output"] diff --git a/tests/components/ledc/duty_to_ticks_test.cpp b/tests/components/ledc/duty_to_ticks_test.cpp new file mode 100644 index 00000000000..6e794bc00e5 --- /dev/null +++ b/tests/components/ledc/duty_to_ticks_test.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/ledc/ledc_duty.h" + +namespace esphome::ledc::testing { + +TEST(LedcDutyToTicksTest, FullScaleIsOnePastTheHighestLevel) { + EXPECT_EQ(full_scale_ticks(8, 0), 256U); + EXPECT_EQ(full_scale_ticks(8, 4), 4096U); + EXPECT_EQ(duty_to_ticks(1.0f, 8, 0), full_scale_ticks(8, 0)); + EXPECT_EQ(duty_to_ticks(1.0f, 8, 4), full_scale_ticks(8, 4)); +} + +TEST(LedcDutyToTicksTest, ScalesAgainstThePwmPeriod) { + EXPECT_EQ(duty_to_ticks(0.0f, 8, 0), 0U); + EXPECT_EQ(duty_to_ticks(0.5f, 8, 0), 128U); + EXPECT_EQ(duty_to_ticks(0.25f, 10, 0), 256U); + EXPECT_EQ(duty_to_ticks(1.0f, 8, 0), 256U); +} + +TEST(LedcDutyToTicksTest, HighestWholeTickIsReachable) { + EXPECT_EQ(duty_to_ticks(255.0f / 256.0f, 8, 0), 255U); + // Rounds to the nearest tick on either side of the last half step + EXPECT_EQ(duty_to_ticks(255.4f / 256.0f, 8, 0), 255U); + EXPECT_EQ(duty_to_ticks(255.6f / 256.0f, 8, 0), 256U); +} + +TEST(LedcDutyToTicksTest, DitheringKeepsFractionalTicks) { + // 8 bits plus 4 fractional bits: 4096 sixteenths of a tick per period + EXPECT_EQ(duty_to_ticks(0.0f, 8, 4), 0U); + EXPECT_EQ(duty_to_ticks(0.5f, 8, 4), 2048U); + EXPECT_EQ(duty_to_ticks(100.25f / 256.0f, 8, 4), 1604U); + EXPECT_EQ(duty_to_ticks(1.0f, 8, 4), 4096U); +} + +TEST(LedcDutyToTicksTest, DitheringSnapsAboveTheLastWholeTick) { + // The last whole tick is 255 * 16 = 4080; the switch point to full scale is 4088 + EXPECT_EQ(duty_to_ticks(4080.0f / 4096.0f, 8, 4), 4080U); + EXPECT_EQ(duty_to_ticks(4081.0f / 4096.0f, 8, 4), 4080U); + EXPECT_EQ(duty_to_ticks(4087.6f / 4096.0f, 8, 4), 4080U); + EXPECT_EQ(duty_to_ticks(4088.0f / 4096.0f, 8, 4), 4096U); + EXPECT_EQ(duty_to_ticks(4095.0f / 4096.0f, 8, 4), 4096U); +} + +TEST(LedcDutyToTicksTest, HighestBitDepthFits) { + // 20 bits plus 4 fractional bits still fit the float mantissa + EXPECT_EQ(duty_to_ticks(1.0f, 20, 4), 1U << 24); + EXPECT_EQ(duty_to_ticks(0.5f, 20, 4), 1U << 23); + EXPECT_EQ(duty_to_ticks(0.5f, 20, 0), 1U << 19); +} + +} // namespace esphome::ledc::testing From a78a80c0d1ef7176e66a33fb5000f8ab69e7c2e6 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:01:17 +1300 Subject: [PATCH 105/170] [epaper_spi] Fix SSD1681 partial updates alternating between two images (#22145) --- esphome/components/epaper_spi/epaper_spi.cpp | 3 +- esphome/components/epaper_spi/epaper_spi.h | 1 + .../components/epaper_spi/epaper_spi_mono.cpp | 8 +- .../components/epaper_spi/epaper_spi_mono.h | 3 + .../epaper_spi/epaper_spi_ssd1677.h | 2 - .../epaper_spi/epaper_spi_ssd1681.cpp | 80 ++++----- .../epaper_spi/epaper_spi_ssd1681.h | 9 +- .../display/test_ssd1677_gray4_transfer.cpp | 28 ++- .../display/test_ssd1677_transfer.cpp | 27 ++- .../display/test_ssd1681_transfer.cpp | 170 +++++++++++++----- 10 files changed, 234 insertions(+), 97 deletions(-) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index 0781e1979e0..5f00fa59e90 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -36,10 +36,11 @@ bool EPaperBase::init_buffer_(size_t buffer_length) { } bool EPaperBase::init_sent_frame_(size_t length) { - if (!this->is_using_partial_update_()) + if (!this->is_using_partial_update_() || this->sent_init_failed_) return false; if (this->sent_.init(length)) return true; + this->sent_init_failed_ = true; ESP_LOGW(TAG, "No memory for the previous frame; partial updates will send the whole drawn area"); return false; } diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 3b593c9c8e2..52feefb5d52 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -218,6 +218,7 @@ class EPaperBase : public Display, split_buffer::SplitBuffer buffer_{}; split_buffer::SplitBuffer sent_{}; // the previous frame, kept only by drivers that diff against it bool sent_valid_{}; // sent_ holds a whole frame + bool sent_init_failed_{}; // sent_ could not be allocated, so it is not tried again size_t sent_count_{}; // bytes recorded into sent_ by the update in progress uint32_t shown_hash_{}; // hash of the frame on the panel, to skip an unchanged update bool shown_hash_valid_{}; // shown_hash_ holds the hash of a frame whose refresh completed diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index 7e5ec6f9d74..83f40629097 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -9,7 +9,12 @@ ESPHOME_LOG_TAG(TAG, "epaper_spi.mono"); void EPaperMono::setup() { EPaperBase::setup(); - if (!this->is_failed() && this->needs_previous_frame()) + if (!this->is_failed()) + this->init_previous_frame_(); +} + +void EPaperMono::init_previous_frame_() { + if (this->needs_previous_frame() && !this->sent_.is_valid()) this->init_sent_frame_(this->sent_frame_length()); } @@ -30,6 +35,7 @@ void EPaperMono::deep_sleep() { bool EPaperMono::reset() { if (!EPaperBase::reset()) return false; + this->init_previous_frame_(); if (this->software_reset_) this->command(0x12); return true; diff --git a/esphome/components/epaper_spi/epaper_spi_mono.h b/esphome/components/epaper_spi/epaper_spi_mono.h index 5efa49c6c4e..f855deb947d 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.h +++ b/esphome/components/epaper_spi/epaper_spi_mono.h @@ -23,6 +23,9 @@ class EPaperMono : public EPaperBase { virtual void set_window(); // Bytes in the comparison frame; the buffer's size unless a subclass sends the frame in another layout virtual size_t sent_frame_length() const { return this->buffer_length_; } + // Allocates the comparison frame if the driver needs one and partial updates are on; also run on every reset, + // since full_update_every can be raised after setup + void init_previous_frame_(); bool transfer_data() override; bool send_red_{true}; bool software_reset_{true}; // send 0x12 after the hardware reset diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h index 2cc12ba5792..f7ae1f9679f 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1677.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -29,8 +29,6 @@ class EPaperSSD1677 : public EPaperMono { protected: // Reads the previous frame to copy it into the other RAM bank on a full refresh bool needs_previous_frame() const override { return true; } - // Allocates the comparison frame as setup() would, for tests that run without a bus. - void init_comparison_frame_() { this->init_sent_frame_(this->sent_frame_length()); } size_t sent_frame_length() const override { return this->plane_row_length_() * this->height_; } // Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds. size_t plane_row_length_() const { return (this->width_ + 7) / 8; } diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1681.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1681.cpp index 912ba21806f..c0d273f3d0f 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1681.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1681.cpp @@ -1,65 +1,53 @@ #include "epaper_spi_ssd1681.h" #include -#include +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::epaper_spi { ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1681"); -void EPaperSSD1681::bounds_from_changes_() { - if (!this->sent_valid_ || this->sent_.size() != this->buffer_.size()) - return; - bool changed = false; - uint16_t row_low = this->height_, row_high = 0, col_low = this->row_width_, col_high = 0; - for (uint16_t row = 0; row != this->height_; row++) { - const size_t base = row * this->row_width_; - const size_t end = base + this->row_width_; - size_t index = base; - while (index != end) { - size_t new_length, old_length; - const uint8_t *new_bytes = this->buffer_.get_span(index, new_length); - const uint8_t *old_bytes = this->sent_.get_span(index, old_length); - const size_t length = std::min({new_length, old_length, end - index}); - if (std::memcmp(new_bytes, old_bytes, length) != 0) { - for (size_t i = 0; i != length; i++) { - if (new_bytes[i] == old_bytes[i]) - continue; - const auto col = static_cast(index + i - base); - changed = true; - row_low = std::min(row_low, row); - row_high = std::max(row_high, row + 1); - col_low = std::min(col_low, col); - col_high = std::max(col_high, col + 1); - } - } - index += length; +bool HOT EPaperSSD1681::transfer_previous_frame_() { + const uint32_t start_time = millis(); + if (this->current_data_index_ == 0) { + this->set_window(); + this->command(0x26); + } + this->start_data_(); + const size_t end = this->sent_.size(); + while (this->current_data_index_ != end) { + size_t length; + const uint8_t *bytes = this->sent_.get_span(this->current_data_index_, length); + length = std::min({length, end - this->current_data_index_, this->row_width_}); + this->write_array(bytes, length); + this->current_data_index_ += length; + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->disable(); + return false; } } - if (!changed) - return; - this->x_low_ = col_low * 8; - this->x_high_ = std::min(col_high * 8, this->width_); - this->y_low_ = row_low; - this->y_high_ = row_high; + this->disable(); + this->current_data_index_ = 0; + this->send_red_ = false; + return false; } bool HOT EPaperSSD1681::transfer_data() { if (this->current_data_index_ == 0 && this->send_red_) { - if (!this->partial_) { - // A refresh drives the whole panel from RAM, so a full one makes both RAM banks whole again - this->x_low_ = 0; - this->x_high_ = this->width_; - this->y_low_ = 0; - this->y_high_ = this->height_; - } else { - // 0x26 already holds the image on the panel: the controller copies 0x24 into it after each refresh - this->bounds_from_changes_(); - this->set_window(); - this->send_red_ = false; - } + // Both banks always get the whole frame: the RAM ping-pong the panel's OTP enables swaps the bank each + // command writes after every refresh, so a bank that is only partly written keeps an image from two frames ago + this->x_low_ = 0; + this->x_high_ = this->width_; + this->y_low_ = 0; + this->y_high_ = this->height_; + // Without the previous frame there is nothing to compare against, so refresh the whole panel + if (this->partial_ && !this->sent_valid_) + this->partial_ = false; } + if (this->partial_ && this->send_red_) + return this->transfer_previous_frame_(); return EPaperSSD1683::transfer_data(); } diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1681.h b/esphome/components/epaper_spi/epaper_spi_ssd1681.h index 77e2f506916..0da320466a7 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1681.h +++ b/esphome/components/epaper_spi/epaper_spi_ssd1681.h @@ -10,16 +10,15 @@ class EPaperSSD1681 : public EPaperSSD1683 { EPaperSSD1681(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, size_t init_sequence_length) : EPaperSSD1683(name, width, height, init_sequence, init_sequence_length) { - // 0x12 puts the display option register back to its default, which turns off the RAM ping-pong the panel's - // OTP enables; without it the controller no longer carries 0x24 over to 0x26 after a partial refresh + // 0x12 puts the display option register back to its default, dropping the settings the panel's OTP loads into it this->software_reset_ = false; } protected: - // Reads the previous frame to compute the changed window for a partial refresh + // Writes the previous frame to 0x26 before a partial refresh bool needs_previous_frame() const override { return true; } - // Shrinks the update bounds to the bytes that differ from the frame last sent - void bounds_from_changes_(); + // Sends the frame last sent to 0x26, in chunks; false until it is all sent + bool transfer_previous_frame_(); bool transfer_data() override; void refresh_screen(bool partial) override; }; diff --git a/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp index 1a66efe9839..2a18608af97 100644 --- a/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp +++ b/tests/components/epaper_spi/display/test_ssd1677_gray4_transfer.cpp @@ -22,7 +22,7 @@ class TestableSSD1677Gray4 : public EPaperSSD1677Gray4 { void install_with_partials(spi::SPIDelegate *delegate) { this->install(delegate); this->set_full_update_every(5); - this->init_comparison_frame_(); + this->init_previous_frame_(); ASSERT_TRUE(this->sent_.is_valid()); } @@ -45,7 +45,10 @@ class TestableSSD1677Gray4 : public EPaperSSD1677Gray4 { return calls; } + bool has_comparison_frame() const { return this->sent_.is_valid(); } + using EPaperSSD1677Gray4::refresh_screen; + using EPaperSSD1677Gray4::reset; using EPaperSSD1677Gray4::transfer_data; RecordingPin dc; @@ -173,6 +176,29 @@ TEST(EPaperSSD1677Gray4, FullPushRecordsTheHighBitsForTheNextPartial) { EXPECT_EQ(bus.data[0x24], (Bytes{0x33})); } +/// Partial updates turned on after setup, as a switch calling set_full_update_every() does, get a +/// comparison frame at the next reset, so the partial updates after the next full one are +/// black-and-white partials rather than four-level refreshes. +TEST(EPaperSSD1677Gray4, PartialUpdatesTurnedOnAfterSetupGetAComparisonFrame) { + TestableSSD1677Gray4 display(8, 1); + RecordingDelegate bus(&display.dc); + display.install(&bus); + ASSERT_FALSE(display.has_comparison_frame()); + + display.set_full_update_every(5); + display.set_update_count(0); + EXPECT_TRUE(display.reset()); + EXPECT_TRUE(display.has_comparison_frame()); + + draw_row(display, 0, {0, 1, 2, 3, 0, 1, 2, 3}); + display.run_push(); + bus.clear(); + display.set_update_count(1); + display.run_push(); + EXPECT_EQ(bus.data[0x26], (Bytes{0x33})) << "partial update is not black-and-white"; + EXPECT_EQ(bus.data[0x24], (Bytes{0x33})); +} + /// A partial update sends the comparison frame to 0x26 and the new frame's high bits to 0x24, /// not inverted (it runs the black-and-white waveform), over the whole panel. TEST(EPaperSSD1677Gray4, PartialPushSendsTheHighBitsInBlackAndWhite) { diff --git a/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp index 01eea3c83aa..e5219acc651 100644 --- a/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp +++ b/tests/components/epaper_spi/display/test_ssd1677_transfer.cpp @@ -18,7 +18,7 @@ class TestableSSD1677 : public EPaperSSD1677 { this->set_reset_pin(&this->reset_pin); ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); this->set_full_update_every(full_update_every); - this->init_comparison_frame_(); + this->init_previous_frame_(); } bool has_comparison_frame() const { return this->sent_.is_valid(); } @@ -245,4 +245,29 @@ TEST(EPaperSSD1677, NoComparisonFrameWhenEveryUpdateIsFull) { EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); } +/// Partial updates turned on after setup, as a switch calling set_full_update_every() does, get a +/// comparison frame at the next reset, so the partial pushes after the next full one compare +/// against the frame on the panel instead of falling back to EPaperMono's transfer. +TEST(EPaperSSD1677, PartialUpdatesTurnedOnAfterSetupGetAComparisonFrame) { + TestableSSD1677 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 1); + ASSERT_FALSE(display.has_comparison_frame()); + + display.set_full_update_every(5); + display.set_update_count(0); + EXPECT_FALSE(display.reset_in(EPaperState::RESET)); + EXPECT_TRUE(display.reset_in(EPaperState::RESET_END)); + EXPECT_TRUE(display.has_comparison_frame()); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_push(); + bus.clear(); + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.set_update_count(1); + display.run_push(); + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})); +} + } // namespace esphome::epaper_spi::testing diff --git a/tests/components/epaper_spi/display/test_ssd1681_transfer.cpp b/tests/components/epaper_spi/display/test_ssd1681_transfer.cpp index 3eb50d54023..9c22173b1d8 100644 --- a/tests/components/epaper_spi/display/test_ssd1681_transfer.cpp +++ b/tests/components/epaper_spi/display/test_ssd1681_transfer.cpp @@ -13,16 +13,21 @@ class TestableSSD1681 : public EPaperSSD1681 { public: TestableSSD1681(uint16_t width, uint16_t height) : EPaperSSD1681("test", width, height, nullptr, 0) {} - void install(spi::SPIDelegate *delegate, uint8_t full_update_every) { + void install(spi::SPIDelegate *delegate, uint8_t full_update_every, bool keep_previous_frame = true) { this->delegate_ = delegate; this->set_dc_pin(&this->dc); this->set_reset_pin(&this->reset_pin); ASSERT_TRUE(this->init_buffer_(this->buffer_length_)); this->set_full_update_every(full_update_every); - this->init_sent_frame_(this->buffer_length_); + if (keep_previous_frame) + this->init_sent_frame_(this->buffer_length_); } bool has_comparison_frame() const { return this->sent_.is_valid(); } + size_t sent_frame_length() const override { + return this->comparison_frame_fails ? 0 : EPaperSSD1681::sent_frame_length(); + } + bool comparison_frame_fails{false}; EPaperState state() const { return this->state_; } void step() { this->process_state_(); } uint8_t update_count() const { return this->update_count_; } @@ -79,13 +84,10 @@ TEST(EPaperSSD1681, FullRefreshSendsTheWholeFrameToBothBanks) { EXPECT_EQ(bus.data[0x22], (Bytes{0xF7})); } -/// Regression test for speckled white areas after a partial refresh. -/// -/// With the new frame in both banks and 0x26 inverted, the controller treats every pixel as -/// changed and drives it with the short partial waveform, which does not fully restore white on -/// this panel. Only the changed window of 0x24 is written before a partial refresh, with 0x26 -/// compared as it is; the controller copies 0x24 into 0x26 after each refresh by itself. -TEST(EPaperSSD1681, PartialRefreshWritesOnlyTheChangedWindowOfTheNewImageBank) { +/// Before a partial refresh 0x26 gets the frame on the panel and 0x24 the new one, both in full, +/// with 0x26 compared as it is. An inverted 0x26 makes the controller drive every pixel with the +/// short partial waveform, which leaves speckles in white areas on this panel. +TEST(EPaperSSD1681, PartialRefreshWritesThePreviousAndTheNewFrameInFull) { TestableSSD1681 display(16, 2); RecordingDelegate bus(&display.dc); display.install(&bus, 5); @@ -98,18 +100,126 @@ TEST(EPaperSSD1681, PartialRefreshWritesOnlyTheChangedWindowOfTheNewImageBank) { display.set_dirty(8, 1, 16, 2); // only the last byte changed display.run_update(); - EXPECT_EQ(bus.data[0x24], (Bytes{0x00})); - EXPECT_EQ(bus.data.count(0x26), 0u) << "0x26 was written"; - EXPECT_EQ(bus.data[0x44], (Bytes{1, 1})); - EXPECT_EQ(bus.data[0x45], (Bytes{1, 0, 1, 0})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x44], (Bytes{0, 1})); + EXPECT_EQ(bus.data[0x45], (Bytes{0, 0, 1, 0})); EXPECT_EQ(bus.data[0x21], (Bytes{0x00, 0x00})) << "0x26 is not compared as it is"; EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})); } -/// The software reset 0x12 turns off the RAM ping-pong the panel's OTP enables, after which the -/// controller no longer keeps 0x26 in step and the next partial refresh compares against a stale -/// image. So every update starts with the hardware reset only, and ends in deep sleep mode 1, which -/// keeps the RAM and is left by that reset. +/// Regression test for successive partial refreshes alternating between two images. +/// +/// The RAM ping-pong the panel's OTP may enable swaps the bank that 0x24 and 0x26 reach after each +/// refresh, so writing only the changed window left the other bank holding the image from two +/// frames ago. Each partial refresh must send the frame last shown and the new frame in full. +TEST(EPaperSSD1681, SuccessivePartialRefreshesEachSendTheFrameLastShown) { + TestableSSD1681 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 5); + + display.set_frame({0xFF, 0xFF, 0xFF, 0xFF}); + display.run_update(); + + const std::vector frames{ + {0x7F, 0xFF, 0xFF, 0xFF}, {0x7F, 0xFE, 0xFF, 0xFF}, {0x7F, 0xFE, 0x7F, 0xFF}, {0x7F, 0xFE, 0x7F, 0xFE}}; + Bytes shown{0xFF, 0xFF, 0xFF, 0xFF}; + for (const Bytes &frame : frames) { + bus.clear(); + display.set_frame({frame[0], frame[1], frame[2], frame[3]}); + display.set_dirty(0, 0, 16, 2); + display.run_update(); + EXPECT_EQ(bus.data[0x26], shown); + EXPECT_EQ(bus.data[0x24], frame); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})); + shown = frame; + } +} + +/// The previous frame goes out in chunks so the main loop keeps running, and arrives whole. +TEST(EPaperSSD1681, PreviousFrameIsSentInChunks) { + TestableSSD1681 display(16, 2); + RecordingDelegate bus(&display.dc, MAX_TRANSFER_TIME + 1); + display.install(&bus, 5); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.set_dirty(0, 0, 16, 2); + display.run_update_state(); + int transfer_steps = 0; + while (display.state() != EPaperState::IDLE) { + if (display.state() == EPaperState::TRANSFER_DATA) + transfer_steps++; + display.step(); + } + + EXPECT_GT(transfer_steps, 3) << "previous frame was not sent in chunks"; + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0xC3})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})); +} + +/// Partial updates turned on after setup, as a switch calling set_full_update_every() does, still +/// get the previous frame: it is allocated by the next update, a full one, and used by the partial +/// ones after it. +TEST(EPaperSSD1681, PartialUpdatesTurnedOnAfterSetupAllocateThePreviousFrame) { + TestableSSD1681 display(16, 2); + RecordingDelegate bus(&display.dc); + display.install(&bus, 1, false); + ASSERT_FALSE(display.has_comparison_frame()); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + EXPECT_FALSE(display.has_comparison_frame()) << "previous frame allocated without partial updates"; + bus.clear(); + + display.set_full_update_every(5); + display.set_frame({0x0F, 0xF0, 0x3C, 0x3C}); + display.set_dirty(0, 0, 16, 2); + display.run_update(); + EXPECT_TRUE(display.has_comparison_frame()); + EXPECT_EQ(bus.data[0x22], (Bytes{0xF7})); + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.set_dirty(0, 0, 16, 2); + display.run_update(); + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0x3C})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xFF})); +} + +/// With no memory for the previous frame there is nothing to put in 0x26, so a partial refresh +/// becomes a full one. The allocation is not retried on every update. +TEST(EPaperSSD1681, PartialRefreshWithoutThePreviousFrameIsAFullOne) { + TestableSSD1681 display(16, 2); + RecordingDelegate bus(&display.dc); + display.comparison_frame_fails = true; + display.install(&bus, 5, false); + + display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); + display.run_update(); + ASSERT_FALSE(display.has_comparison_frame()); + display.comparison_frame_fails = false; // a failed allocation is not tried again + bus.clear(); + + display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); + display.set_dirty(8, 1, 16, 2); + display.run_update(); + + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x21], (Bytes{0x40, 0x00})); + EXPECT_EQ(bus.data[0x22], (Bytes{0xF7})); + EXPECT_FALSE(display.has_comparison_frame()) << "failed allocation was tried again"; +} + +/// The software reset 0x12 puts the display option register back to its default, which drops the +/// OTP's settings for the partial waveform and makes a partial refresh as slow as a full one. So +/// every update starts with the hardware reset only, and ends in deep sleep mode 1, which keeps the +/// RAM and is left by that reset. TEST(EPaperSSD1681, UpdatesResetInHardwareOnlyAndSleepInBetween) { TestableSSD1681 display(16, 2); RecordingDelegate bus(&display.dc); @@ -117,7 +227,7 @@ TEST(EPaperSSD1681, UpdatesResetInHardwareOnlyAndSleepInBetween) { display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); display.run_update(); - EXPECT_EQ(bus.commands.front(), 0x44) << "full refresh sent a software reset"; + EXPECT_EQ(std::count(bus.commands.begin(), bus.commands.end(), 0x12), 0) << "full refresh sent a software reset"; EXPECT_EQ(bus.data[0x10], (Bytes{0x01})) << "panel was not put into deep sleep mode 1"; bus.clear(); @@ -206,27 +316,6 @@ TEST(EPaperSSD1681, RequestedFullUpdateIsNotSkipped) { EXPECT_EQ(display.update_count(), 1); } -/// A partial refresh sends only the bytes that differ from the frame on the panel, whatever area -/// was drawn: with auto clear on, the drawn area is always the whole panel. -TEST(EPaperSSD1681, PartialRefreshSendsOnlyTheBytesThatChanged) { - TestableSSD1681 display(16, 2); - RecordingDelegate bus(&display.dc); - display.install(&bus, 5); - - display.set_frame({0x0F, 0xF0, 0x3C, 0xC3}); - display.set_dirty(0, 0, 16, 2); - display.run_update(); - bus.clear(); - - display.set_frame({0x0F, 0xF0, 0x3C, 0x00}); - display.set_dirty(0, 0, 16, 2); // whole panel redrawn, only the last byte differs - display.run_update(); - - EXPECT_EQ(bus.data[0x24], (Bytes{0x00})); - EXPECT_EQ(bus.data[0x44], (Bytes{1, 1})); - EXPECT_EQ(bus.data[0x45], (Bytes{1, 0, 1, 0})); -} - /// The comparison frame holds what was sent, so a change that was drawn but not pushed yet /// (because it was drawn during a push) is still sent by the next update. TEST(EPaperSSD1681, ComparisonFrameIsWhatWasSent) { @@ -250,7 +339,8 @@ TEST(EPaperSSD1681, ComparisonFrameIsWhatWasSent) { display.set_dirty(0, 0, 16, 2); EXPECT_TRUE(display.run_update()) << "pixel drawn during the refresh was never sent"; - EXPECT_EQ(bus.data[0x24], (Bytes{0x07})); + EXPECT_EQ(bus.data[0x26], (Bytes{0x0F, 0xF0, 0x3C, 0x00})); + EXPECT_EQ(bus.data[0x24], (Bytes{0x07, 0xF0, 0x3C, 0x00})); } } // namespace esphome::epaper_spi::testing From 6ea4ed08688fab028c6a3813b95a873a0f1ad9c1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:50:25 +1300 Subject: [PATCH 106/170] [CI] Adopt gh actions-lock for workflow action pinning (#22138) Co-authored-by: J. Nick Koston --- .github/actions/apt-packages/action.yml | 2 +- .github/actions/build-image/action.yaml | 4 +- .github/actions/cache-arduino8266/action.yml | 4 +- .../cache-clang-tidy-idedata/action.yml | 4 +- .github/actions/cache-esp-idf/action.yml | 6 +- .github/actions/cache-sdk-nrf/action.yml | 4 +- .github/actions/restore-python/action.yml | 6 +- .github/workflows/actions.lock | 232 ++++++++++++++++++ .github/workflows/auto-label-pr.yml | 6 +- .github/workflows/ci-api-proto.yml | 12 +- .github/workflows/ci-docker.yml | 22 +- .github/workflows/ci-github-scripts.yml | 2 +- .../workflows/ci-memory-impact-comment.yml | 4 +- .github/workflows/ci.yml | 208 ++++++++-------- .../close-pr-from-fork-default-branch.yml | 2 +- .../codeowner-approved-label-update.yml | 4 +- .../workflows/codeowner-review-request.yml | 6 +- .github/workflows/codeql.yml | 6 +- .github/workflows/external-component-bot.yml | 4 +- .github/workflows/issue-codeowner-notify.yml | 2 +- .github/workflows/pr-title-check.yml | 4 +- .github/workflows/release-nightly.yml | 2 +- .github/workflows/release.yml | 38 +-- .github/workflows/status-check-labels.yml | 2 +- .../workflows/sync-dependency-versions.yml | 8 +- .github/workflows/sync-device-classes.yml | 12 +- .../workflows/sync-integration-durations.yml | 8 +- .github/workflows/verify-lockfile.yml | 26 ++ 28 files changed, 449 insertions(+), 191 deletions(-) create mode 100644 .github/workflows/actions.lock create mode 100644 .github/workflows/verify-lockfile.yml diff --git a/.github/actions/apt-packages/action.yml b/.github/actions/apt-packages/action.yml index 1397da4bdc4..91ac9c1b9a2 100644 --- a/.github/actions/apt-packages/action.yml +++ b/.github/actions/apt-packages/action.yml @@ -45,7 +45,7 @@ runs: id: apt # Pinned on purpose and not watched by Dependabot: the step above relies on how this # release calls apt-fast and where it writes install.log. - uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3 + uses: awalsh128/cache-apt-pkgs-action@v1.6.3 with: packages: libsdl2-dev libmbedtls-dev ccache # 1.3 drops the empty caches saved before the step above existed diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index f6565a3916a..c6d6984943a 100644 --- a/.github/actions/build-image/action.yaml +++ b/.github/actions/build-image/action.yaml @@ -42,7 +42,7 @@ runs: - name: Build and push to ghcr by digest id: build-ghcr - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + uses: docker/build-push-action@v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false @@ -67,7 +67,7 @@ runs: - name: Build and push to dockerhub by digest id: build-dockerhub - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + uses: docker/build-push-action@v7.4.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false diff --git a/.github/actions/cache-arduino8266/action.yml b/.github/actions/cache-arduino8266/action.yml index c9e194d667e..e8c6eedb8c8 100644 --- a/.github/actions/cache-arduino8266/action.yml +++ b/.github/actions/cache-arduino8266/action.yml @@ -27,13 +27,13 @@ runs: echo "key=$key" >> "$GITHUB_OUTPUT" - name: Cache the native toolchain (write on dev) if: github.ref == 'refs/heads/dev' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache@v6.1.0 with: path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }} key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }} - name: Restore the native toolchain (off dev) if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }} key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }} diff --git a/.github/actions/cache-clang-tidy-idedata/action.yml b/.github/actions/cache-clang-tidy-idedata/action.yml index 18f3c2b31ae..8d6b2a1c6b7 100644 --- a/.github/actions/cache-clang-tidy-idedata/action.yml +++ b/.github/actions/cache-clang-tidy-idedata/action.yml @@ -38,13 +38,13 @@ runs: # Extend the extension list if a component ships extensionless headers. - name: Cache clang-tidy idedata (write on dev) if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && steps.key.outputs.skip != 'true' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache@v6.1.0 with: path: ${{ steps.key.outputs.path }} key: ${{ steps.key.outputs.key }} - name: Cache clang-tidy idedata (restore-only off dev) if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') && steps.key.outputs.skip != 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ steps.key.outputs.path }} key: ${{ steps.key.outputs.key }} diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml index 38bcc80eb67..251feb46f75 100644 --- a/.github/actions/cache-esp-idf/action.yml +++ b/.github/actions/cache-esp-idf/action.yml @@ -49,13 +49,13 @@ runs: # -slim: bump when prune-esp-idf changes what it removes; a key is never overwritten. - name: Cache ESP-IDF install (write on dev) if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && inputs.restore-only != 'true' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim - name: Cache ESP-IDF install (restore-only off dev) if: github.ref != 'refs/heads/dev' && !contains(github.event.pull_request.labels.*.name, 'ci-cache-write') || inputs.restore-only == 'true' - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@v5.0.5 with: path: ~/.esphome-idf key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}-py${{ steps.version.outputs.python-version }}-slim @@ -67,4 +67,4 @@ runs: . venv/bin/activate python -c 'from esphome.espidf.framework import check_esp_idf_install; check_esp_idf_install("${{ steps.version.outputs.version }}")' - name: Prune ESP-IDF install - uses: ./.github/actions/prune-esp-idf + uses: $/.github/actions/prune-esp-idf diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml index 6cbb87cc66f..48778a004b8 100644 --- a/.github/actions/cache-sdk-nrf/action.yml +++ b/.github/actions/cache-sdk-nrf/action.yml @@ -35,14 +35,14 @@ runs: # restore-only and never push multi-GB artifacts into their own scope. - name: Cache nRF Connect SDK install (write on dev) if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache@v6.1.0 with: path: ~/.esphome-sdk-nrf # yamllint disable-line rule:line-length key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }} - name: Cache nRF Connect SDK install (restore-only off dev) if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ~/.esphome-sdk-nrf # yamllint disable-line rule:line-length diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index 65287d276a6..c8f56012b24 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,12 +17,12 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: venv # yamllint disable-line rule:line-length @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock new file mode 100644 index 00000000000..e7a0a18de32 --- /dev/null +++ b/.github/workflows/actions.lock @@ -0,0 +1,232 @@ +# This file is machine-generated by `gh actions-lock`. +# Do not edit by hand; run `gh actions-lock` to update. +# Docs: https://gh.io/actions-lockfile +version: 'v0.0.3' +workflows: + '.github/workflows/auto-label-pr.yml': + - 'actions/checkout@v7.0.1' + - 'actions/create-github-app-token@v3.3.0' + - 'actions/github-script@v9.0.0' + '.github/workflows/ci-api-proto.yml': + - 'actions/checkout@v7.0.1' + - 'actions/github-script@v9.0.0' + - 'actions/setup-python@v7.1.0' + - 'actions/upload-artifact@v7.0.2' + - 'astral-sh/setup-uv@v10.3.2' + '.github/workflows/ci-docker.yml': + - 'actions/checkout@v7.0.1' + - 'actions/download-artifact@v8.0.2' + - 'actions/setup-python@v7.1.0' + - 'actions/upload-artifact@v7.0.2' + - 'docker/login-action@v4.7.0' + - 'docker/setup-buildx-action@v4.4.0' + '.github/workflows/ci-github-scripts.yml': + - 'actions/checkout@v7.0.1' + '.github/workflows/ci-memory-impact-comment.yml': + - 'actions/cache@v6.1.0' + - 'actions/checkout@v7.0.1' + - 'actions/setup-python@v7.1.0' + - 'astral-sh/setup-uv@v10.3.2' + '.github/workflows/ci.yml': + - 'actions/cache@v5.0.5' + - 'actions/cache@v6.1.0' + - 'actions/checkout@v7.0.1' + - 'actions/download-artifact@v8.0.2' + - 'actions/setup-python@v7.1.0' + - 'actions/upload-artifact@v7.0.2' + - 'astral-sh/setup-uv@v10.3.2' + - 'awalsh128/cache-apt-pkgs-action@v1.6.3' + - 'codecov/codecov-action@v7.1.1' + - 'codspeedhq/action@v5.6.0' + - 'j178/prek-action@v3.1.0' + - 'pre-commit-ci/lite-action@v1.2.0' + '.github/workflows/close-pr-from-fork-default-branch.yml': + - 'actions/github-script@v9.0.0' + '.github/workflows/codeowner-approved-label-update.yml': + - 'actions/checkout@v7.0.1' + - 'actions/github-script@v9.0.0' + '.github/workflows/codeowner-review-request.yml': + - 'actions/checkout@v7.0.1' + - 'actions/create-github-app-token@v3.3.0' + - 'actions/github-script@v9.0.0' + '.github/workflows/codeql.yml': + - 'actions/checkout@v7.0.1' + - 'github/codeql-action@v4.39.1' + '.github/workflows/external-component-bot.yml': + - 'actions/create-github-app-token@v3.3.0' + - 'actions/github-script@v9.0.0' + '.github/workflows/issue-codeowner-notify.yml': + - 'actions/github-script@v9.0.0' + '.github/workflows/lock.yml': [] + '.github/workflows/pr-title-check.yml': + - 'actions/checkout@v7.0.1' + - 'actions/github-script@v9.0.0' + '.github/workflows/release-nightly.yml': + - 'actions/checkout@v7.0.1' + '.github/workflows/release.yml': + - 'actions/checkout@v7.0.1' + - 'actions/create-github-app-token@v3.3.0' + - 'actions/download-artifact@v8.0.2' + - 'actions/github-script@v9.0.0' + - 'actions/setup-python@v7.1.0' + - 'actions/upload-artifact@v7.0.2' + - 'docker/build-push-action@v7.4.0' + - 'docker/login-action@v4.7.0' + - 'docker/setup-buildx-action@v4.4.0' + - 'pypa/gh-action-pypi-publish@v1.14.3' + '.github/workflows/stale.yml': [] + '.github/workflows/status-check-labels.yml': + - 'actions/github-script@v9.0.0' + '.github/workflows/sync-dependency-versions.yml': + - 'actions/checkout@v7.0.1' + - 'actions/create-github-app-token@v3.3.0' + - 'actions/setup-python@v7.1.0' + '.github/workflows/sync-device-classes.yml': + - 'actions/checkout@v7.0.1' + - 'actions/create-github-app-token@v3.3.0' + - 'actions/setup-python@v7.1.0' + - 'astral-sh/setup-uv@v10.3.2' + - 'peter-evans/create-pull-request@v8.1.1' + '.github/workflows/sync-integration-durations.yml': + - 'actions/checkout@v7.0.1' + - 'actions/create-github-app-token@v3.3.0' + - 'actions/setup-python@v7.1.0' + - 'peter-evans/create-pull-request@v8.1.1' + '.github/workflows/verify-lockfile.yml': + - 'actions/checkout@v7.0.1' +dependencies: + 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': + ref: 'v6.1.0' + commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + owner_id: 44036562 + repo_id: 215566462 + 'actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7': + ref: 'v5.0.4' + commit: 'sha1-668228422ae6a00e4ad889ee87cd7109ec5666a7' + owner_id: 44036562 + repo_id: 215566462 + 'actions/cache@v5.0.5': + ref: 'v5.0.5' + commit: 'sha1-27d5ce7f107fe9357f9df03efb73ab90386fccae' + owner_id: 44036562 + repo_id: 215566462 + 'actions/cache@v6.1.0': + ref: 'v6.1.0' + commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + owner_id: 44036562 + repo_id: 215566462 + 'actions/checkout@v7.0.1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 + 'actions/create-github-app-token@v3.3.0': + ref: 'v3.3.0' + commit: 'sha1-64ab729a6294000f5aa4a4bc0b73d1f32bc45510' + owner_id: 44036562 + repo_id: 642580244 + 'actions/download-artifact@v8.0.2': + ref: 'v8.0.2' + commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333' + owner_id: 44036562 + repo_id: 192626254 + 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd': + ref: 'v8.0.0' + commit: 'sha1-ed597411d8f924073f98dfc5c65a23a2325f34cd' + owner_id: 44036562 + repo_id: 205262760 + 'actions/github-script@v9.0.0': + ref: 'v9.0.0' + commit: 'sha1-3a2844b7e9c422d3c10d287c895573f7108da1b3' + owner_id: 44036562 + repo_id: 205262760 + 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405': + ref: 'v6.2.0' + commit: 'sha1-a309ff8b426b58ec0e2a45f0f869d46889d02405' + owner_id: 44036562 + repo_id: 192625525 + 'actions/setup-python@v7.1.0': + ref: 'v7.1.0' + commit: 'sha1-6d4fd22b20465ccc0f7354476588e8df7c9ca0e0' + owner_id: 44036562 + repo_id: 192625525 + 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': + ref: 'v7.0.0' + commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + owner_id: 44036562 + repo_id: 192625955 + 'actions/upload-artifact@v7.0.2': + ref: 'v7.0.2' + commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' + owner_id: 44036562 + repo_id: 192625955 + 'astral-sh/setup-uv@v10.3.2': + ref: 'v10.3.2' + commit: 'sha1-07a047e608db84b3d8c06cd9a7c7dcc9c355c18e' + owner_id: 115962839 + repo_id: 846733598 + 'awalsh128/cache-apt-pkgs-action@v1.6.3': + ref: 'v1.6.3' + commit: 'sha1-553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9' + owner_id: 2087466 + repo_id: 408212054 + uses: + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + 'codecov/codecov-action@v7.1.1': + ref: 'v7.1.1' + commit: 'sha1-303a32d7a59b442fa8d48b6a1cc6825c09c847a5' + owner_id: 8226205 + repo_id: 200299178 + uses: + - 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' + 'codspeedhq/action@v5.6.0': + ref: 'v5.6.0' + commit: 'sha1-78102a468f96d8e0ccb92d0ec49425753fc42062' + owner_id: 116658140 + repo_id: 561699778 + uses: + - 'actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7' + - 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' + 'docker/build-push-action@v7.4.0': + ref: 'v7.4.0' + commit: 'sha1-c3c9e263c25d99ce0380d002d59b67737d91b0dc' + owner_id: 5429470 + repo_id: 241092383 + 'docker/login-action@v4.7.0': + ref: 'v4.7.0' + commit: 'sha1-81c6f93d4bc991e510b342a00b419b3bb4efc5c6' + owner_id: 5429470 + repo_id: 287743349 + 'docker/setup-buildx-action@v4.4.0': + ref: 'v4.4.0' + commit: 'sha1-594f3bf4285d9ea8dc53c9a0c9c4092420091003' + owner_id: 5429470 + repo_id: 288485773 + 'github/codeql-action@v4.39.1': + ref: 'v4.39.1' + commit: 'sha1-9b3254085494b55a0f7c4c2b3d2d52cc712ce370' + owner_id: 9919 + repo_id: 259445878 + 'j178/prek-action@v3.1.0': + ref: 'v3.1.0' + commit: 'sha1-44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d' + owner_id: 10510431 + repo_id: 1052387486 + 'peter-evans/create-pull-request@v8.1.1': + ref: 'v8.1.1' + commit: 'sha1-5f6978faf089d4d20b00c7766989d076bb2fc7f1' + owner_id: 18365890 + repo_id: 197176365 + 'pre-commit-ci/lite-action@v1.2.0': + ref: 'v1.2.0' + commit: 'sha1-062bca0919bc9d6e66755cc05074b70c77e111fc' + owner_id: 64617429 + repo_id: 560608915 + 'pypa/gh-action-pypi-publish@v1.14.3': + ref: 'v1.14.3' + commit: 'sha1-7245906fb61fa33a13554f9d5fb856fe61758086' + owner_id: 647025 + repo_id: 178055147 + uses: + - 'actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405' diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 82cf9f28288..6dc64d2fe3b 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -24,11 +24,11 @@ jobs: if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Generate a token id: generate-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + uses: actions/create-github-app-token@v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -38,7 +38,7 @@ jobs: permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview - name: Auto Label PR - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 126269e4a80..1faa10012a3 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,15 +21,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and @@ -82,7 +82,7 @@ jobs: fi - if: failure() name: Review PR - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: script: | await github.rest.pulls.createReview({ @@ -97,7 +97,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: name: generated-proto-files path: | @@ -105,7 +105,7 @@ jobs: esphome/components/api/api_pb2_service.* - if: success() name: Dismiss review - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 0168b029086..c8ad5dcd01a 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -60,13 +60,13 @@ jobs: tag: ${{ steps.tag.outputs.tag }} push: ${{ steps.tag.outputs.push }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@v4.4.0 - name: Determine tag and whether to push id: tag @@ -97,7 +97,7 @@ jobs: - name: Log in to the GitHub container registry if: steps.tag.outputs.push == 'true' - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -128,7 +128,7 @@ jobs: - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: # The tar is already compressed, so upload it as-is. archive: false # skips the redundant zip and makes the file name the artifact name @@ -152,16 +152,16 @@ jobs: - "ha-addon" - "docker" steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@v4.4.0 - name: Log in to the GitHub container registry - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -208,9 +208,9 @@ jobs: - nrf52 - host steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Download image artifact - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + uses: actions/download-artifact@v8.0.2 with: name: compile-test-image.tar.zst - name: Load image diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml index ea039de9b9d..4ccb1f13e64 100644 --- a/.github/workflows/ci-github-scripts.yml +++ b/.github/workflows/ci-github-scripts.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Run tests working-directory: .github/scripts/auto-label-pr diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index 0dc653bd393..5dc8869b6a8 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: # Always check out from the base repository (esphome/esphome), never from forks # Use the PR's target branch to ensure we run trusted code from the main repo @@ -58,7 +58,7 @@ jobs: - name: Restore Python if: steps.pr.outputs.skip != 'true' - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: "3.12" cache-key: ${{ hashFiles('.cache-key') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47d853b5295..becc3b971dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,18 +28,18 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache@v6.1.0 with: path: venv # yamllint disable-line rule:line-length @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -79,11 +79,11 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: sparse-checkout: .github - name: Install apt packages (cached) - uses: ./.github/actions/apt-packages + uses: $/.github/actions/apt-packages determine-jobs: name: Determine which jobs to run @@ -125,17 +125,17 @@ jobs: release-pr: ${{ github.base_ref == 'beta' || github.base_ref == 'release' }} steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: # Fetch enough history to find the merge base fetch-depth: 2 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -185,7 +185,7 @@ jobs: echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/save@v6.1.0 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -204,14 +204,14 @@ jobs: timeout-minutes: 15 steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache the native toolchain - uses: ./.github/actions/cache-arduino8266 + uses: $/.github/actions/cache-arduino8266 - name: Install the native toolchain run: | . venv/bin/activate @@ -226,9 +226,9 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -255,9 +255,9 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -277,7 +277,7 @@ jobs: if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.release-pr == 'false' && needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Read prek version from requirements_test.txt id: prek # requirements_test.txt is the only place the version is pinned, so a @@ -289,7 +289,7 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run prek - uses: j178/prek-action@44924fe9ef4a6f88253c4b6b1bf11c689d3ce53d # v3.1.0 + uses: j178/prek-action@v3.1.0 with: prek-version: ${{ steps.prek.outputs.version }} # This job only runs on pull requests, so nothing ever populates @@ -302,7 +302,7 @@ jobs: # Pushes any fixes the hooks made back to the pull request. This step # must keep its default name: the GitHub App that performs the push # locates the workflow run by that name. - - uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0 + - uses: pre-commit-ci/lite-action@v1.2.0 if: always() with: msg: apply automatic formatting fixes @@ -336,10 +336,10 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python id: restore-python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ matrix.python-version }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -356,10 +356,10 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + uses: codecov/codecov-action@v7.1.1 - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/save@v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -383,9 +383,9 @@ jobs: if: needs.determine-jobs.outputs.core-ci == 'false' steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Report empty upload to Codecov - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + uses: codecov/codecov-action@v7.1.1 with: run_command: empty-upload force: true @@ -416,23 +416,23 @@ jobs: INTEGRATION_CCACHE_SAVE: ${{ needs.determine-jobs.outputs.integration-run-all == 'true' && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) }} steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Install apt packages (cached) # ccache speeds up the host compiles. A cache hit never touches apt # (mirror outages cannot hang the job); the timeout bounds the cold # path. libsdl2-dev is needed by the headless display tests, which # capture screenshots. timeout-minutes: 10 - uses: ./.github/actions/apt-packages + uses: $/.github/actions/apt-packages - name: Set up Python 3.13 id: python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.13" - name: Restore integration library cache # A miss or a changed pin self-heals with a download, so no restore-keys id: library-cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }} @@ -441,35 +441,35 @@ jobs: # per slice of INTEGRATION_TESTS_SPLIT_BUCKETS in script/determine-jobs.py. - name: Restore integration ccache (slice 1/5) if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 0 - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ env.INTEGRATION_CCACHE_PATH }} key: integration-ccache-v1-0-${{ github.run_id }} restore-keys: integration-ccache-v1-0- - name: Restore integration ccache (slice 2/5) if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 1 - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ env.INTEGRATION_CCACHE_PATH }} key: integration-ccache-v1-1-${{ github.run_id }} restore-keys: integration-ccache-v1-1- - name: Restore integration ccache (slice 3/5) if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 2 - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ env.INTEGRATION_CCACHE_PATH }} key: integration-ccache-v1-2-${{ github.run_id }} restore-keys: integration-ccache-v1-2- - name: Restore integration ccache (slice 4/5) if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 3 - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ env.INTEGRATION_CCACHE_PATH }} key: integration-ccache-v1-3-${{ github.run_id }} restore-keys: integration-ccache-v1-3- - name: Restore integration ccache (slice 5/5) if: needs.determine-jobs.outputs.integration-run-all != 'true' || strategy.job-index == 4 - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ${{ env.INTEGRATION_CCACHE_PATH }} key: integration-ccache-v1-4-${{ github.run_id }} @@ -486,14 +486,14 @@ jobs: CCACHE_DIR="$dir" ccache -z - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache@v6.1.0 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -560,7 +560,7 @@ jobs: # Tests that compare rendered output write the image they actually got here, so a # failure can be looked at without reproducing the whole build locally. if: failure() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: name: integration-test-artifacts-${{ matrix.bucket.name }} path: test_artifacts/ @@ -571,7 +571,7 @@ jobs: # script/update_integration_test_durations.py; only full matrix dev # runs produce usable data. if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: name: junit-integration-${{ strategy.job-index }} path: junit-integration.xml @@ -587,14 +587,14 @@ jobs: run: ccache -s - name: Save integration ccache slice if: success() && env.INTEGRATION_CCACHE_SAVE == 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/save@v6.1.0 with: path: ${{ env.INTEGRATION_CCACHE_PATH }} key: integration-ccache-v1-${{ strategy.job-index }}-${{ github.run_id }} - name: Save integration library cache # Bucket 0 only; the others would race the same immutable key. if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/save@v6.1.0 with: path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} key: ${{ steps.library-cache.outputs.cache-primary-key }} @@ -643,9 +643,9 @@ jobs: if: needs.determine-jobs.outputs.import-time == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -655,7 +655,7 @@ jobs: script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: name: import-time-waterfall path: importtime.har @@ -689,10 +689,10 @@ jobs: # have already been benchmarked on their original dev pull requests. steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -753,7 +753,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@78102a468f96d8e0ccb92d0ec49425753fc42062 # v5.6.0 + uses: CodSpeedHQ/action@v5.6.0 with: run: | . venv/bin/activate @@ -770,10 +770,10 @@ jobs: if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]') steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -834,7 +834,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -843,11 +843,11 @@ jobs: # libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev # by dsmr and dlms_meter. timeout-minutes: 10 - uses: ./.github/actions/apt-packages + uses: $/.github/actions/apt-packages - name: Restore Python id: restore-python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -858,33 +858,33 @@ jobs: # a new image ships a newer patch release and drops the old interpreter. - name: Cache platformio if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache@v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }} - name: Cache ESP-IDF install if: matrix.cache_idf - uses: ./.github/actions/cache-esp-idf + uses: $/.github/actions/cache-esp-idf with: framework: arduino - name: Cache clang-tidy idedata if: matrix.cache_idf - uses: ./.github/actions/cache-clang-tidy-idedata + uses: $/.github/actions/cache-clang-tidy-idedata with: environment: esp32-arduino-tidy - name: Cache nRF Connect SDK install if: matrix.cache_sdk_nrf - uses: ./.github/actions/cache-sdk-nrf + uses: $/.github/actions/cache-sdk-nrf - name: Register problem matchers run: | @@ -933,7 +933,7 @@ jobs: - name: Prune ESP-IDF install before cache save if: matrix.cache_idf && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) - uses: ./.github/actions/prune-esp-idf + uses: $/.github/actions/prune-esp-idf - name: Suggested changes run: script/ci-suggest-changes ${{ matrix.ignore_errors && '|| true' || '' }} @@ -953,22 +953,22 @@ jobs: ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - uses: ./.github/actions/cache-esp-idf + uses: $/.github/actions/cache-esp-idf - name: Cache clang-tidy idedata - uses: ./.github/actions/cache-clang-tidy-idedata + uses: $/.github/actions/cache-clang-tidy-idedata with: environment: esp32-idf-tidy @@ -1008,7 +1008,7 @@ jobs: - name: Prune ESP-IDF install before cache save if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) - uses: ./.github/actions/prune-esp-idf + uses: $/.github/actions/prune-esp-idf - name: Suggested changes run: script/ci-suggest-changes @@ -1047,22 +1047,22 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - uses: ./.github/actions/cache-esp-idf + uses: $/.github/actions/cache-esp-idf - name: Cache clang-tidy idedata - uses: ./.github/actions/cache-clang-tidy-idedata + uses: $/.github/actions/cache-clang-tidy-idedata with: environment: esp32-idf-tidy @@ -1102,7 +1102,7 @@ jobs: - name: Prune ESP-IDF install before cache save if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) - uses: ./.github/actions/prune-esp-idf + uses: $/.github/actions/prune-esp-idf - name: Suggested changes run: script/ci-suggest-changes @@ -1143,22 +1143,22 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache ESP-IDF install - uses: ./.github/actions/cache-esp-idf + uses: $/.github/actions/cache-esp-idf - name: Cache clang-tidy idedata - uses: ./.github/actions/cache-clang-tidy-idedata + uses: $/.github/actions/cache-clang-tidy-idedata with: environment: ${{ matrix.tidy_environment }} @@ -1197,7 +1197,7 @@ jobs: - name: Prune ESP-IDF install before cache save if: (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) - uses: ./.github/actions/prune-esp-idf + uses: $/.github/actions/prune-esp-idf - name: Suggested changes run: script/ci-suggest-changes @@ -1231,7 +1231,7 @@ jobs: run: echo ${{ matrix.batch.components }} - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Install apt packages (cached) # A cache hit (seeded on dev by seed-apt-cache) never touches apt, @@ -1239,9 +1239,9 @@ jobs: # the cold path. The action has no --no-install-recommends; same # package set this job used before #17463. timeout-minutes: 10 - uses: ./.github/actions/apt-packages + uses: $/.github/actions/apt-packages - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -1250,7 +1250,7 @@ jobs: # ESP-IDF install; never save -- just reuse the shared install the # dev tidy jobs already cached when present. if: matrix.batch.needs_idf - uses: ./.github/actions/cache-esp-idf + uses: $/.github/actions/cache-esp-idf with: restore-only: true - name: Cache nRF Connect SDK install (restore-only) @@ -1258,14 +1258,14 @@ jobs: # sdk-nrf install; never save -- just reuse the shared install the # dev nrf52 tidy job cached when present. if: matrix.batch.needs_nrf - uses: ./.github/actions/cache-sdk-nrf + uses: $/.github/actions/cache-sdk-nrf with: restore-only: true - name: Cache the native ESP8266 toolchain # Only batches whose test platforms include esp8266; never saves # here, it reuses the install the dev seed job cached. if: matrix.batch.needs_arduino8266 - uses: ./.github/actions/cache-arduino8266 + uses: $/.github/actions/cache-arduino8266 - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -1408,10 +1408,10 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -1446,16 +1446,16 @@ jobs: TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }} steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache the native toolchain - uses: ./.github/actions/cache-arduino8266 + uses: $/.github/actions/cache-arduino8266 - name: Run native toolchain compile test run: | @@ -1495,10 +1495,10 @@ jobs: CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f steps: - name: Check out code from GitHub - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} @@ -1622,17 +1622,17 @@ jobs: if: needs.determine-jobs.outputs.device-builder == 'true' steps: - name: Check out esphome (this PR) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: path: esphome - name: Check out esphome/device-builder - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: repository: esphome/device-builder ref: main path: device-builder - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.13" - name: Set up uv @@ -1641,7 +1641,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1684,7 +1684,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.base_ref }} @@ -1756,7 +1756,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/restore@v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1773,14 +1773,14 @@ jobs: - name: Restore Python if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache the native ESP8266 toolchain if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 - uses: ./.github/actions/cache-arduino8266 + uses: $/.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' @@ -1819,7 +1819,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + uses: actions/cache/save@v6.1.0 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -1840,7 +1840,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: name: memory-analysis-target path: memory-analysis-target.json @@ -1859,15 +1859,15 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache the native ESP8266 toolchain if: fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 - uses: ./.github/actions/cache-arduino8266 + uses: $/.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory id: extract run: | @@ -1903,7 +1903,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: name: memory-analysis-pr path: memory-analysis-pr.json @@ -1926,20 +1926,20 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Restore Python - uses: ./.github/actions/restore-python + uses: $/.github/actions/restore-python with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + uses: actions/download-artifact@v8.0.2 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + uses: actions/download-artifact@v8.0.2 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml index 5180a071806..d71a982c436 100644 --- a/.github/workflows/close-pr-from-fork-default-branch.yml +++ b/.github/workflows/close-pr-from-fork-default-branch.yml @@ -17,7 +17,7 @@ jobs: github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name && github.event.pull_request.head.ref == github.event.repository.default_branch steps: - - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + - uses: actions/github-script@v9.0.0 with: script: | const { owner, repo } = context.repo; diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index bb1d1e2d7ad..305ccedc6d2 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} sparse-checkout: | @@ -34,7 +34,7 @@ jobs: CODEOWNERS - name: Check codeowner approval and update label - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 env: PR_NUMBER: ${{ github.event.pull_request.number }} with: diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 728406e82ff..d399ce970d5 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -29,13 +29,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} - name: Generate a token id: generate-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + uses: actions/create-github-app-token@v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -44,7 +44,7 @@ jobs: permission-issues: write # issues.listComments and issues.createComment (PR comments use the issues API) - name: Request reviews from component codeowners - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dcdd2e71ba4..a5fef1b1441 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -52,11 +52,11 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/init@v4.39.1 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 + uses: github/codeql-action/analyze@v4.39.1 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index d3c4c5bf029..1a8632fbd7c 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + uses: actions/create-github-app-token@v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -24,7 +24,7 @@ jobs: permission-pull-requests: write - name: Add external component comment - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index bc892b64e0b..ba91f1ced45 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify codeowners for component issues - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 3a89c26cd31..273d6660dff 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -16,9 +16,9 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + - uses: actions/github-script@v9.0.0 with: script: | const { diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index cd3b7207b72..196ecb127b1 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -22,7 +22,7 @@ jobs: actions: write # gh workflow run starts release.yml steps: - name: Check out the repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Start the release workflow env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26259334013..f7c0371daa6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Get tag id: tag env: @@ -84,9 +84,9 @@ jobs: contents: read # actions/checkout to build the sdist/wheel id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.x" - name: Build @@ -94,7 +94,7 @@ jobs: pip3 install build python3 -m build - name: Publish - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + uses: pypa/gh-action-pypi-publish@v1.14.3 with: skip-existing: true @@ -116,29 +116,29 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@v4.4.0 - name: Log in to docker hub - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@v4.7.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build docker - uses: ./.github/actions/build-image + uses: $/.github/actions/build-image with: target: final build_type: docker @@ -146,7 +146,7 @@ jobs: version: ${{ needs.init.outputs.tag }} - name: Build ha-addon - uses: ./.github/actions/build-image + uses: $/.github/actions/build-image with: target: final build_type: ha-addon @@ -162,7 +162,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + uses: actions/upload-artifact@v7.0.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests @@ -192,27 +192,27 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@v7.0.1 - name: Download digests - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + uses: actions/download-artifact@v8.0.2 with: pattern: digests-* path: /tmp/digests merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + uses: docker/setup-buildx-action@v4.4.0 - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@v4.7.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -245,7 +245,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + uses: actions/create-github-app-token@v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -254,7 +254,7 @@ jobs: permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token) - name: Trigger Workflow - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 5f0966c1515..922996df9cc 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check for blocking labels - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + uses: actions/github-script@v9.0.0 with: script: | const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr']; diff --git a/.github/workflows/sync-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml index bdde76465d5..05bfbd3586b 100644 --- a/.github/workflows/sync-dependency-versions.yml +++ b/.github/workflows/sync-dependency-versions.yml @@ -43,7 +43,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + uses: actions/create-github-app-token@v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -54,7 +54,7 @@ jobs: - name: Check out base branch # Provides the script that runs below. Deliberately the base branch # so the pull request cannot change what executes here. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false @@ -64,14 +64,14 @@ jobs: # refuses heads that live in a different repository, and the job # condition above already limits runs to same-repository branches. # Leaving it off keeps that refusal as a backstop for fork heads. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.event.pull_request.head.ref }} path: pull-request token: ${{ steps.generate-token.outputs.token }} - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.12" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index b813ba364fa..a6338370bb7 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + uses: actions/create-github-app-token@v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -28,16 +28,16 @@ jobs: permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Checkout Home Assistant - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 with: repository: home-assistant/core path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.14" @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + uses: astral-sh/setup-uv@v10.3.2 with: enable-cache: true # Pin uv version so the action does not have to fetch the @@ -93,7 +93,7 @@ jobs: run: python script/run-in-env.py prek run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + uses: peter-evans/create-pull-request@v8.1.1 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> diff --git a/.github/workflows/sync-integration-durations.yml b/.github/workflows/sync-integration-durations.yml index 49382409bd0..bf2cfd1b36c 100644 --- a/.github/workflows/sync-integration-durations.yml +++ b/.github/workflows/sync-integration-durations.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + uses: actions/create-github-app-token@v3.3.0 with: client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -28,10 +28,10 @@ jobs: permission-pull-requests: write # open or refresh the sync PR - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + uses: actions/checkout@v7.0.1 - name: Setup Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + uses: actions/setup-python@v7.1.0 with: python-version: "3.13" @@ -86,7 +86,7 @@ jobs: exit 1 - name: Commit changes - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + uses: peter-evans/create-pull-request@v8.1.1 with: commit-message: "[ci] Refresh integration test durations" committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> diff --git a/.github/workflows/verify-lockfile.yml b/.github/workflows/verify-lockfile.yml new file mode 100644 index 00000000000..c39e2589ea9 --- /dev/null +++ b/.github/workflows/verify-lockfile.yml @@ -0,0 +1,26 @@ +name: verify-lockfile + +on: + pull_request: + push: + branches: + - dev + +permissions: + contents: read + +jobs: + verify: + name: Verify actions lockfile + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Install gh-actions-lock + run: gh extension install github/gh-actions-lock --pin v0.4.0 + - name: Verify lockfile + run: gh actions-lock --verify From b18b7fe7a15418def1e82405f08bebafcfad26d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:40:34 -1000 Subject: [PATCH 107/170] Bump astral-sh/setup-uv from 10.3.2 to 10.3.3 (#22170) --- .github/workflows/actions.lock | 10 ++++++++-- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index e7a0a18de32..1883f8f7f28 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -12,7 +12,7 @@ workflows: - 'actions/github-script@v9.0.0' - 'actions/setup-python@v7.1.0' - 'actions/upload-artifact@v7.0.2' - - 'astral-sh/setup-uv@v10.3.2' + - 'astral-sh/setup-uv@v10.3.3' '.github/workflows/ci-docker.yml': - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.2' @@ -35,6 +35,7 @@ workflows: - 'actions/setup-python@v7.1.0' - 'actions/upload-artifact@v7.0.2' - 'astral-sh/setup-uv@v10.3.2' + - 'astral-sh/setup-uv@v10.3.3' - 'awalsh128/cache-apt-pkgs-action@v1.6.3' - 'codecov/codecov-action@v7.1.1' - 'codspeedhq/action@v5.6.0' @@ -85,7 +86,7 @@ workflows: - 'actions/checkout@v7.0.1' - 'actions/create-github-app-token@v3.3.0' - 'actions/setup-python@v7.1.0' - - 'astral-sh/setup-uv@v10.3.2' + - 'astral-sh/setup-uv@v10.3.3' - 'peter-evans/create-pull-request@v8.1.1' '.github/workflows/sync-integration-durations.yml': - 'actions/checkout@v7.0.1' @@ -165,6 +166,11 @@ dependencies: commit: 'sha1-07a047e608db84b3d8c06cd9a7c7dcc9c355c18e' owner_id: 115962839 repo_id: 846733598 + 'astral-sh/setup-uv@v10.3.3': + ref: 'v10.3.3' + commit: 'sha1-68559e99cc807c1eb7e9238fd43a15bba460eec2' + owner_id: 115962839 + repo_id: 846733598 'awalsh128/cache-apt-pkgs-action@v1.6.3': ref: 'v1.6.3' commit: 'sha1-553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9' diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 1faa10012a3..e7ed922b6f5 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@v10.3.3 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index becc3b971dc..e0288736a93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@v10.3.3 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -493,7 +493,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@v10.3.3 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1641,7 +1641,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@v10.3.3 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a6338370bb7..39aaccf9278 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@v10.3.3 with: enable-cache: true # Pin uv version so the action does not have to fetch the From f96c0c7d0ed8c74ecc06ce68a096ec83cbbf0c26 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:40:54 -1000 Subject: [PATCH 108/170] Bump CodSpeedHQ/action from 5.6.0 to 5.6.1 (#22166) --- .github/workflows/actions.lock | 8 ++++---- .github/workflows/ci.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 1883f8f7f28..90d608d6446 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -38,7 +38,7 @@ workflows: - 'astral-sh/setup-uv@v10.3.3' - 'awalsh128/cache-apt-pkgs-action@v1.6.3' - 'codecov/codecov-action@v7.1.1' - - 'codspeedhq/action@v5.6.0' + - 'codspeedhq/action@v5.6.1' - 'j178/prek-action@v3.1.0' - 'pre-commit-ci/lite-action@v1.2.0' '.github/workflows/close-pr-from-fork-default-branch.yml': @@ -186,9 +186,9 @@ dependencies: repo_id: 200299178 uses: - 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' - 'codspeedhq/action@v5.6.0': - ref: 'v5.6.0' - commit: 'sha1-78102a468f96d8e0ccb92d0ec49425753fc42062' + 'codspeedhq/action@v5.6.1': + ref: 'v5.6.1' + commit: 'sha1-61efe07e57e55bedeab750c434504de272a56e8c' owner_id: 116658140 repo_id: 561699778 uses: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0288736a93..08831927589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -753,7 +753,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@v5.6.0 + uses: CodSpeedHQ/action@v5.6.1 with: run: | . venv/bin/activate From 741310ef309ae38dcbdffea1bd501ae694414aa1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:43:23 -1000 Subject: [PATCH 109/170] Bump prek from 0.5.7 to 0.5.9 (#22165) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d815b80c5f3..81d4ce3cd4d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.1.3 ruff==0.17.3 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -prek==0.5.7 # .github/workflows/ci.yml reads this pin +prek==0.5.9 # .github/workflows/ci.yml reads this pin yamlrocks==0.7.1 # used by script/sync_dependency_versions.py # Unit tests From cf5519615a784052829365feace16ea2a59427c7 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 29 Oct 2026 00:22:43 +0100 Subject: [PATCH 110/170] [esp32][audio_http] Keep TLS 1.3 working (#21786) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- .../audio_http/audio_http_media_source.cpp | 5 ++ esphome/components/esp32/__init__.py | 17 ++++- tests/component_tests/esp32/test_esp32.py | 67 +++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index 6cea4d73115..4bfee1e0516 100644 --- a/esphome/components/audio_http/audio_http_media_source.cpp +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -20,7 +20,12 @@ static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; // Max blockin static constexpr uint32_t READER_WRITE_TIMEOUT_MS = 50; // Max blocking time when writing into the ring buffer static constexpr uint8_t READER_TASK_PRIORITY = 2; static constexpr uint8_t DECODER_TASK_PRIORITY = 2; +#ifdef CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 +// A TLS 1.3 handshake peaks at about 4.3 KB of reader stack +static constexpr size_t READER_TASK_STACK_SIZE = 6144; +#else static constexpr size_t READER_TASK_STACK_SIZE = 4096; +#endif static constexpr size_t DECODER_TASK_STACK_SIZE = 5120; static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20; static constexpr const char *const HTTP_URI_PREFIX = "http://"; diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index a24e031c043..ad29fed1ee3 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2672,8 +2672,21 @@ async def _reconcile_mbedtls_sdkconfig() -> None: if option not in data.tls_extras_required: set_idf_sdkconfig_default(option, False) - # Keeping the peer certificate costs ~4KB heap per connection. - if data.peer_cert_required: + # Keeping the peer certificate costs ~4KB heap per connection. TLS 1.3 + # depends on it in Kconfig, which silently drops TLS 1.3 without it. + tls13 = is_idf_sdkconfig_option_enabled("CONFIG_MBEDTLS_SSL_PROTO_TLS1_3") + if ( + tls13 + and "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE" in opts + and not is_idf_sdkconfig_option_enabled( + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE" + ) + ): + _LOGGER.warning( + "CONFIG_MBEDTLS_SSL_PROTO_TLS1_3 needs " + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE; TLS 1.3 is disabled" + ) + if data.peer_cert_required or tls13: set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) elif data.disable_peer_cert: set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 04416d7f57a..1a7379e22a8 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -625,6 +625,41 @@ _IDF6 = cv.Version(6, 0, 0) set(_ESP_TLS_LINKING_COMPONENTS), id="idf_advanced_disables_off", ), + pytest.param( + # TLS 1.3 depends on the peer certificate in Kconfig. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(tls_required=True), + {"CONFIG_MBEDTLS_SSL_PROTO_TLS1_3": RawSdkconfigValue("y")}, + { + "CONFIG_MBEDTLS_SSL_PROTO_TLS1_3": RawSdkconfigValue("y"), + **_TLS_CLIENT_ONLY, + **_TLS_EXTRAS_OFF, + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": True, + "CONFIG_MBEDTLS_PKCS7_C": False, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_tls13_keeps_peer_cert", + ), + pytest.param( + # A user choice for the peer certificate still wins over TLS 1.3. + PlatformFramework.ESP32_IDF, + _IDF5, + MbedtlsSdkconfigData(tls_required=True), + { + "CONFIG_MBEDTLS_SSL_PROTO_TLS1_3": RawSdkconfigValue("y"), + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": RawSdkconfigValue("n"), + }, + { + "CONFIG_MBEDTLS_SSL_PROTO_TLS1_3": RawSdkconfigValue("y"), + "CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE": RawSdkconfigValue("n"), + **_TLS_CLIENT_ONLY, + **_TLS_EXTRAS_OFF, + "CONFIG_MBEDTLS_PKCS7_C": False, + }, + set(_ESP_TLS_LINKING_COMPONENTS), + id="idf_tls13_user_peer_cert_wins", + ), pytest.param( # advanced: disable_mbedtls_tls: false keeps TLS with no requester. PlatformFramework.ESP32_IDF, @@ -690,6 +725,38 @@ def test_reconcile_mbedtls_sdkconfig( assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected +@pytest.mark.parametrize( + ("peer_cert", "warns"), + [ + pytest.param(RawSdkconfigValue("n"), True, id="user_off_warns"), + pytest.param(RawSdkconfigValue("y"), False, id="user_on"), + pytest.param(None, False, id="unset"), + ], +) +def test_reconcile_mbedtls_warns_when_peer_cert_drops_tls13( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + peer_cert: RawSdkconfigValue | None, + warns: bool, +) -> None: + """Turning off the peer certificate with TLS 1.3 on drops TLS 1.3, so say so.""" + set_core_config(PlatformFramework.ESP32_IDF) + preset: dict[str, Any] = {"CONFIG_MBEDTLS_SSL_PROTO_TLS1_3": RawSdkconfigValue("y")} + if peer_cert is not None: + preset["CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE"] = peer_cert + CORE.data[KEY_ESP32] = { + KEY_IDF_VERSION: _IDF5, + KEY_SDKCONFIG_OPTIONS: preset, + KEY_MBEDTLS_SDKCONFIG: MbedtlsSdkconfigData(tls_required=True), + KEY_EXCLUDE_COMPONENTS: set(_ESP_TLS_LINKING_COMPONENTS), + } + + with caplog.at_level(logging.WARNING): + asyncio.run(_reconcile_mbedtls_sdkconfig()) + + assert ("TLS 1.3 is disabled" in caplog.text) is warns + + def test_esp_tls_linking_components_are_excluded_by_default() -> None: """The fallback scan is only a real signal while every name is excluded by default.""" assert set(_ESP_TLS_LINKING_COMPONENTS) <= set(DEFAULT_EXCLUDED_IDF_COMPONENTS) From 86a0cdfb7abe449f804ecc70654a3bea440d6bf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Oct 2026 13:09:24 -1000 Subject: [PATCH 111/170] [core] Start the Apple Silicon cc1plus wrapper through sh when the Python path has a space (#21967) --- esphome/platformio/pch.py.script | 15 +++++++++++++-- tests/unit_tests/test_platformio_pch_script.py | 18 ++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/esphome/platformio/pch.py.script b/esphome/platformio/pch.py.script index 36751074d96..0950f57af46 100644 --- a/esphome/platformio/pch.py.script +++ b/esphome/platformio/pch.py.script @@ -37,8 +37,14 @@ _OLD_GCC_PIO_PLATFORM = "libretiny" _MOVABLE_PCH_GCC = 12 # Relative: compiles run in the project root _CC1_DIR = "pch_cc1" +_CC1PLUS_START = '#!/usr/bin/env -S "{python}" -IS' +# macOS splits a shebang on whitespace, so such a Python path goes through +# sh; to Python the exec line is a string +_CC1PLUS_START_SH = """\ +#!/bin/sh +''''exec {python} -IS "$0" "$@" # '''""" _CC1PLUS_WRAPPER = """\ -#!/usr/bin/env -S "{python}" -IS +{start} # Written by ESPHome: starts cc1plus without address randomisation, so it # loads the precompiled header. See esphome/platformio/pch.py.script. import ctypes @@ -144,7 +150,12 @@ def _old_gcc_flags(proj_dir: Path, pio_platform: str, cxx: str): wrapper = proj_dir / _CC1_DIR / "cc1plus" wrapper.parent.mkdir(exist_ok=True) real = os.path.normpath(real) - wrapper.write_text(_CC1PLUS_WRAPPER.format(python=sys.executable, real=real)) + python = sys.executable + if any(c.isspace() for c in python): + start = _CC1PLUS_START_SH.format(python=shlex.quote(python)) + else: + start = _CC1PLUS_START.format(python=python) + wrapper.write_text(_CC1PLUS_WRAPPER.format(start=start, real=real)) wrapper.chmod(0o755) return [f"-B{_CC1_DIR}/"] diff --git a/tests/unit_tests/test_platformio_pch_script.py b/tests/unit_tests/test_platformio_pch_script.py index af9ec4a8377..c1268c6b95d 100644 --- a/tests/unit_tests/test_platformio_pch_script.py +++ b/tests/unit_tests/test_platformio_pch_script.py @@ -200,10 +200,24 @@ def test_pch_script_gcc10_wrapper_on_apple_silicon(tmp_path: Path) -> None: @pytest.mark.skipif(sys.platform != "darwin", reason="the wrapper is macOS only") -def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus(tmp_path: Path) -> None: +@pytest.mark.parametrize( + ("python_dir", "first_line"), + [("bin", "#!/usr/bin/env -S"), ("Application Support", "#!/bin/sh")], +) +def test_pch_script_gcc10_wrapper_starts_the_real_cc1plus( + tmp_path: Path, python_dir: str, first_line: str +) -> None: + """The wrapper runs from any Python; one under a path with a space, as + the ESPHome Builder app on macOS installs it, is started through sh.""" cxx, real = _fake_toolchain(tmp_path) - _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) + # A link under tmp_path, so the case does not depend on where the test's own Python lives + link = tmp_path / python_dir / "python3" + link.parent.mkdir() + link.symlink_to(sys.executable) + with patch.object(sys, "executable", str(link)): + _run_on_host(tmp_path, "darwin", "arm64", _LibreTinyPlatform, cxx) wrapper = tmp_path / "dev" / pch.PCH_CC1_DIR / "cc1plus" + assert wrapper.read_text(encoding="utf-8").startswith(first_line) result = subprocess.run( [str(wrapper), "-quiet", "x.cpp"], capture_output=True, text=True, check=False ) From 7d29bdf1771acd99828eccf701419743c7365cb5 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:25:54 -0500 Subject: [PATCH 112/170] [host] Offer the built program as a download in the Device Builder (#22114) --- esphome/components/host/__init__.py | 17 +++++++++++++++++ tests/unit_tests/test_download_types.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index 16e4c81d344..e10c3e5b72b 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( Toolchain, ) from esphome.core import CORE, EsphomeError +from esphome.storage_json import StorageJSON from esphome.types import ConfigType from .const import KEY_HOST @@ -58,6 +59,22 @@ async def to_code(config: ConfigType) -> None: cg.add_define(ThreadModel.MULTI_ATOMICS) +def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: + """Download entries for a built host program; same contract as + ``esp32.get_download_types`` (title / description / file / download).""" + if storage_json.firmware_bin_path is None: + return [] + return [ + { + "title": "Executable", + "description": "Native program for the machine this was built on; " + "copy it to the host and run it.", + "file": "program", + "download": storage_json.name, + } + ] + + # Called by __main__.compile_program; True means this platform built the # program itself instead of falling through to the PlatformIO toolchain. def run_compile(args: object, config: ConfigType) -> bool: diff --git a/tests/unit_tests/test_download_types.py b/tests/unit_tests/test_download_types.py index 0f5aae444d0..96c2774cf65 100644 --- a/tests/unit_tests/test_download_types.py +++ b/tests/unit_tests/test_download_types.py @@ -15,7 +15,7 @@ import pytest from esphome.storage_json import StorageJSON -PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52"] +PLATFORMS = ["esp32", "esp8266", "rp2", "libretiny", "nrf52", "host"] def _download_types(platform: str, storage: StorageJSON) -> list[dict[str, Any]]: From 64b621f8f1b042a237fc2c95427b62e558088748 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 12:04:02 -1000 Subject: [PATCH 113/170] [bluetooth_proxy] Release held connections when a new client replaces the subscriber (#22077) --- .../bluetooth_proxy/bluetooth_proxy.cpp | 28 +++++++++++-------- .../bluetooth_proxy/bluetooth_proxy.h | 2 ++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index cb37057cd4b..48bc814ff22 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -635,16 +635,8 @@ void BluetoothProxy::loop() { #endif if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { -#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS - // The API subscriber is gone: tear down any connections it left behind - // (disconnect() on an already-disconnecting slot is a no-op). - for (uint8_t i = 0; i < this->connection_count_; i++) { - auto *connection = this->connections_[i]; - if (connection->get_address() != 0) { - connection->disconnect(); - } - } -#endif + // The API subscriber is gone: tear down any connections it left behind. + this->disconnect_all_connections_(); return; } @@ -737,6 +729,17 @@ void BluetoothProxy::reset_owed_replies_() { #endif } +void BluetoothProxy::disconnect_all_connections_() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto *connection = this->connections_[i]; + if (connection->get_address() != 0) { + connection->disconnect(); + } + } +#endif +} + void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { if (api_connection != this->api_connection_) { if (this->api_connection_ != nullptr) { @@ -754,8 +757,11 @@ void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection // Stale retry latches belong to the previous subscriber's session; a // re-subscribe by the current one keeps what it is still owed. this->reset_owed_replies_(); + this->api_connection_ = api_connection; + // Held links belong to a client that no longer gets their events; free them so idle-only + // advertisers are seen again. After the swap, so a sync slot free reports to the new client. + this->disconnect_all_connections_(); } - this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). this->send_scanner_state_(this->hub_->get_scanner_state()); diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 567109dc60b..dfa48ff88ef 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -328,6 +328,8 @@ class BluetoothProxy final : public Component { /// Drops state only, never sends: api_connection_ is the departing /// subscriber on subscribe and nullptr on unsubscribe. void reset_owed_replies_(); + /// Tear down every held link; repeat calls are no-ops. + void disconnect_all_connections_(); #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// Report a reply we deliberately do not latch, so no drop is silent. void log_reply_dropped_(const char *what, uint64_t address); From 863cb549823154b9d1ae4925020260aea3d0b14d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Nov 2026 11:53:12 -1000 Subject: [PATCH 114/170] [ota] Work around the LibreTiny OTA issue that bricks RTL8710B on a compressed update (#22111) Squash of #22111 for the release branch, with the conflicts resolved: release has no UpdateLock in the LibreTiny OTA backend, so abort() has no lock check and a successful end() clears wrote_; release negotiates inline in perform_ota(), so the RTL8710B check is made there. --- .../components/esphome/ota/ota_esphome.cpp | 5 +- esphome/components/ota/ota_backend.h | 4 +- .../ota/ota_backend_arduino_libretiny.cpp | 68 ++++++++++++++++--- .../ota/ota_backend_arduino_libretiny.h | 16 +++++ esphome/espota2.py | 33 +++++++++ .../host_ota_deflate_rtl8710b_bit.yaml | 9 +++ tests/integration/test_host_ota.py | 26 +++++++ tests/unit_tests/test_espota2.py | 59 ++++++++++++++++ 8 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index c399c0901c6..7e2605a0cb1 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -194,6 +194,9 @@ void ESPHomeOTAComponent::loop() { this->handle_handshake_(); } +// Deflate for an RTL8710B image: 2026.10 firmware does not know this bit, and +// there LibreTiny corrupts a deflate upload into OTA2 (esphome#22012) +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B = 0x40; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; @@ -350,7 +353,7 @@ void ESPHomeOTAComponent::handle_handshake_() { #endif #ifdef USE_OTA_DEFLATE // Offered only once the session memory is in hand; else uncompressed - if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) { + if ((this->ota_features_ & (CLIENT_FEATURE_SUPPORTS_DEFLATE | CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B)) != 0) { // Value initialized: a corrupt stream that back references the // window before it is filled then copies zeros, never stale memory. // Default placement, PSRAM first where present: the session lives for one diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 0c2e60cf4c8..d2056e5c9c6 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -102,7 +102,9 @@ enum OTAType : uint8_t { // (web_server upload) and the backend allows the largest image it can take. // ota_type defaults to an app update, so both call forms must be accepted. // - set_update_md5: expected digest of the incoming image, hex string. -// - write: consume the next chunk; end: finalize and mark bootable. +// - write: consume the next chunk without modifying it; the caller may reuse it +// (the deflate OTA window is also back reference history). end: finalize and +// mark bootable. // - abort: safe to call in any state, including after end(). // - supports_compression: constexpr, whether a gzip image is stored as is and // inflated at reboot. diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 231c4d2dd2e..83ab86d7e59 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -3,8 +3,11 @@ #include "ota_backend.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + #include namespace esphome::ota { @@ -24,6 +27,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ot } bool ret = Update.begin(image_size, U_FLASH); if (ret) { + this->md5_.init(); return OTA_RESPONSE_OK; } @@ -37,26 +41,52 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ot } void ArduinoLibreTinyOTABackend::set_update_md5(const char *md5) { - Update.setMD5(md5); + // Checked in end(); a checksum that does not parse refuses the update this->md5_set_ = true; + this->md5_parsed_ = parse_hex(md5, this->expected_md5_, 16); +} + +bool ArduinoLibreTinyOTABackend::feed_(const uint8_t *data, size_t len) { + while (len != 0) { + const size_t chunk = std::min(len, MAX_FEED); + if (Update.write(data, chunk) != chunk) { + ESP_LOGE(TAG, "Write error: %d", Update.getError()); + return false; + } + this->wrote_ = true; + data += chunk; + len -= chunk; + } + return true; } OTAResponseTypes ArduinoLibreTinyOTABackend::write(uint8_t *data, size_t len) { - size_t written = Update.write(data, len); - if (written == len) { + if (len == 0) return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "Write error: %d", error); - - return OTA_RESPONSE_ERROR_WRITING_FLASH; + this->md5_.add(data, len); + // Keep the last byte back so the image stays incomplete until end() + if ((this->has_pending_ && !this->feed_(&this->pending_, 1)) || !this->feed_(data, len - 1)) + return OTA_RESPONSE_ERROR_WRITING_FLASH; + this->pending_ = data[len - 1]; + this->has_pending_ = true; + return OTA_RESPONSE_OK; } OTAResponseTypes ArduinoLibreTinyOTABackend::end() { + if (this->md5_set_) { + this->md5_.calculate(); + if (!this->md5_parsed_ || !this->md5_.equals_bytes(this->expected_md5_)) { + ESP_LOGE(TAG, "MD5 mismatch"); + return OTA_RESPONSE_ERROR_MD5_MISMATCH; + } + } + if (this->has_pending_ && !this->feed_(&this->pending_, 1)) + return OTA_RESPONSE_ERROR_WRITING_FLASH; // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + // Nothing for a later abort() to revert + this->wrote_ = false; return OTA_RESPONSE_OK; } @@ -66,7 +96,25 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } +void ArduinoLibreTinyOTABackend::abort() { + Update.abort(); + // Bootloaders take a written image LibreTiny never marked: RTL8720C boots the + // valid one with the higher serial number, BK72xx and LN882H install a + // complete download. On LN882H the device image ends before the flasher + // blocks of a full UF2, so the held-back byte does not keep it incomplete. + if (!this->wrote_) + return; +#ifdef USE_LN882X + // lt_ota_switch() ignores revert here; erasing the header voids the download + const bool reverted = lt_flash_erase_block(FLASH_OTA_OFFSET); +#else + // On BK72xx this erases the download header + const bool reverted = lt_ota_switch(/* revert= */ true); +#endif + if (!reverted) { + ESP_LOGE(TAG, "Revert failed"); + } +} } // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c322ed21f23..afe6fa56849 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -2,6 +2,7 @@ #ifdef USE_LIBRETINY #include "ota_backend.h" +#include "esphome/components/md5/md5.h" #include "esphome/core/defines.h" namespace esphome::ota { @@ -16,7 +17,22 @@ class ArduinoLibreTinyOTABackend final { static constexpr bool supports_compression() { return false; } private: + // LibreTiny uses a full 512-byte UF2 block in place, and patches it there + // when writing the OTA2 slot, so it never gets more than one byte less + static constexpr size_t MAX_FEED = 511; + + bool feed_(const uint8_t *data, size_t len); + + md5::MD5Digest md5_{}; + uint8_t expected_md5_[16]; + // The last byte is held back until end() has checked the MD5: lt_ota_end(), + // which Update.abort() runs too, marks the image bootable once every block is + // written. + uint8_t pending_{0}; + bool has_pending_{false}; bool md5_set_{false}; + bool md5_parsed_{false}; + bool wrote_{false}; }; std::unique_ptr make_ota_backend(); diff --git a/esphome/espota2.py b/esphome/espota2.py index dbdf3233660..182a5d1b2e6 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -75,6 +75,33 @@ SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08 # Wire constant: the deflate bit promises a 4 KB window (OTA_INFLATE_WINDOW_SIZE) DEFLATE_WINDOW_BITS = 12 +# Asks for deflate in place of CLIENT_FEATURE_SUPPORTS_DEFLATE for an RTL8710B +# image. 2026.10 firmware does not know it: there an upstream issue corrupts a +# deflate upload into OTA2 and makes it the boot image (esphome#22012) +CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B = 0x40 +_UF2_MAGIC_HEAD = (0x0A324655).to_bytes(4, "little") + (0x9E5D5157).to_bytes( + 4, "little" +) +_UF2_FAMILY_RTL8710B = 0x22E0D6FC +RTL8710B_DEFLATE_NOTICE = ( + "Uploading uncompressed, so this OTA takes longer: the device did not accept " + "a compressed upload. On RTL8710B this is expected while it runs ESPHome " + "2026.10, because of an upstream issue " + "(https://github.com/esphome/esphome/issues/22012); once this update is " + "installed, later OTAs are compressed again." +) + + +def _is_rtl8710b_image(image: bytes) -> bool: + """Whether the image is a LibreTiny UF2 for the RTL8710B (AmebaZ).""" + return ( + len(image) >= 32 + and image[:8] == _UF2_MAGIC_HEAD + and int.from_bytes(image[8:12], "little") & 0x2000 != 0 + and int.from_bytes(image[28:32], "little") == _UF2_FAMILY_RTL8710B + ) + + NOISE_FRAME_INDICATOR = 0x01 NOISE_HANDSHAKE_OK = 0x00 # The device decrypts frames in its transfer buffer (OTA_BUFFER_SIZE, sized @@ -564,6 +591,7 @@ def perform_ota( file_contents = file_handle.read() file_size = len(file_contents) _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) + rtl8710b = _is_rtl8710b_image(file_contents) # Enable nodelay, we need it for phase 1 sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) @@ -584,6 +612,9 @@ def perform_ota( | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL | CLIENT_FEATURE_SUPPORTS_DEFLATE ) + if rtl8710b: + features_to_send &= ~CLIENT_FEATURE_SUPPORTS_DEFLATE + features_to_send |= CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B if noise_psk: features_to_send |= CLIENT_FEATURE_SUPPORTS_NOISE send_check(sock, features_to_send, "features") @@ -686,6 +717,8 @@ def perform_ota( ) deflate = bool(extended_proto and features & SERVER_FEATURE_SUPPORTS_DEFLATE) + if rtl8710b and not deflate: + _LOGGER.warning(RTL8710B_DEFLATE_NOTICE) if deflate: import zlib diff --git a/tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml b/tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml new file mode 100644 index 00000000000..ebf7977123d --- /dev/null +++ b/tests/integration/fixtures/host_ota_deflate_rtl8710b_bit.yaml @@ -0,0 +1,9 @@ +esphome: + name: host-ota-test +host: +api: +ota: + - platform: esphome + port: __OTA_PORT__ +logger: + level: DEBUG diff --git a/tests/integration/test_host_ota.py b/tests/integration/test_host_ota.py index 17c4dea25f9..bab4994cf4a 100644 --- a/tests/integration/test_host_ota.py +++ b/tests/integration/test_host_ota.py @@ -245,6 +245,32 @@ async def test_host_ota_self_update( await dev.ota(None, None, "second OTA failed -- listener leaked across execv") +@pytest.mark.asyncio +async def test_host_ota_deflate_rtl8710b_bit( + yaml_config: str, + write_yaml_config: ConfigWriter, + compile_esphome: CompileFunction, + reserved_tcp_port: tuple[int, socket.socket], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The device inflates an upload asked for with the RTL8710B deflate bit.""" + dev = _Device( + *await _build( + yaml_config, write_yaml_config, compile_esphome, reserved_tcp_port + ) + ) + monkeypatch.setattr(espota2, "_is_rtl8710b_image", lambda image: True) + + async with run_binary(dev.binary_path, line_callback=dev.on_log) as ( + proc, + _lines, + ): + dev.proc = proc + await _wait_for_port(LOCALHOST, dev.api_port, PORT_WAIT_TIMEOUT) + await dev.ota(None, None, "RTL8710B deflate upload failed") + assert dev.inflates == 1, "device ignored the RTL8710B deflate offer" + + @pytest.mark.asyncio async def test_host_ota_deflate( yaml_config: str, diff --git a/tests/unit_tests/test_espota2.py b/tests/unit_tests/test_espota2.py index f4c5935059c..3ffa0bee073 100644 --- a/tests/unit_tests/test_espota2.py +++ b/tests/unit_tests/test_espota2.py @@ -1527,6 +1527,65 @@ _UPLOAD_TAIL = [ ] +def _uf2_image(family: int, flags: int = 0x2000) -> bytes: + header = struct.pack("<8I", 0x0A324655, 0x9E5D5157, flags, 0, 256, 0, 1, family) + return header + b"firmware" * 100 + + +@pytest.mark.parametrize( + ("image", "expected"), + [ + (_uf2_image(0x22E0D6FC), True), + (_uf2_image(0xE08F7564), False), + (_uf2_image(0x22E0D6FC, flags=0), False), + (b"\xe9" + bytes(31), False), + ], + ids=["rtl8710b", "rtl8720c", "no-family", "esp"], +) +def test_is_rtl8710b_image(image: bytes, expected: bool) -> None: + assert espota2._is_rtl8710b_image(image) is expected + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_rtl8710b_deflate_with_fixed_firmware( + mock_socket: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """An RTL8710B image asks for deflate with the bit only fixed firmware knows.""" + original_content = _uf2_image(0x22E0D6FC) + mock_socket.recv.side_effect = ( + _no_auth_handshake( + espota2.OTA_VERSION_2_0, espota2.SERVER_FEATURE_SUPPORTS_DEFLATE + ) + + _UPLOAD_TAIL + ) + + espota2.perform_ota(mock_socket, None, io.BytesIO(original_content), "test.uf2") + + sent = [c[0][0] for c in mock_socket.sendall.call_args_list] + assert sent[1][0] & espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE_RTL8710B + assert not sent[1][0] & espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + assert zlib.decompress(sent[6], -espota2.DEFLATE_WINDOW_BITS) == original_content + assert espota2.RTL8710B_DEFLATE_NOTICE not in caplog.text + + +@pytest.mark.usefixtures("mock_time") +def test_perform_ota_rtl8710b_uncompressed_with_old_firmware( + mock_socket: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """2026.10 firmware does not know the RTL8710B bit, so the image goes as it is.""" + original_content = _uf2_image(0x22E0D6FC) + mock_socket.recv.side_effect = ( + _no_auth_handshake(espota2.OTA_VERSION_2_0, 0) + _UPLOAD_TAIL + ) + + espota2.perform_ota(mock_socket, None, io.BytesIO(original_content), "test.uf2") + + sent = [c[0][0] for c in mock_socket.sendall.call_args_list] + assert not sent[1][0] & espota2.CLIENT_FEATURE_SUPPORTS_DEFLATE + assert original_content in sent + assert espota2.RTL8710B_DEFLATE_NOTICE in caplog.text + + @pytest.mark.usefixtures("mock_time") @pytest.mark.parametrize( "server_features", From f0c4dc67e7664402f4dd306e720f7eb3517f3aff Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:51:12 +1300 Subject: [PATCH 115/170] Bump version to 2026.10.2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index f19755b57a7..fd42c28044b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.10.1 +PROJECT_NUMBER = 2026.10.2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index de49a8f887e..c265eb2afc9 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.1" +__version__ = "2026.10.2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From 8e87cfc17e89675a7baa917b30a9a46ef99a4c14 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:47:34 +1100 Subject: [PATCH 116/170] [lvgl] Make a scrollable container automatically clickable (#22094) Co-authored-by: Claude --- esphome/components/lvgl/widgets/container.py | 16 ++++++- .../config/container_scrollable_test.yaml | 48 +++++++++++++++++++ .../lvgl/test_container_scrollable.py | 44 +++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 5 ++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/lvgl/config/container_scrollable_test.yaml create mode 100644 tests/component_tests/lvgl/test_container_scrollable.py diff --git a/esphome/components/lvgl/widgets/container.py b/esphome/components/lvgl/widgets/container.py index 427b46affa1..1339855ab10 100644 --- a/esphome/components/lvgl/widgets/container.py +++ b/esphome/components/lvgl/widgets/container.py @@ -1,8 +1,16 @@ import esphome.config_validation as cv from esphome.const import CONF_HEIGHT, CONF_WIDTH +from esphome.cpp_generator import MockObj -from ..defines import CONF_CONTAINER, CONF_MAIN, CONF_SCROLLBAR +from ..defines import ( + CONF_CLICKABLE, + CONF_CONTAINER, + CONF_MAIN, + CONF_SCROLLABLE, + CONF_SCROLLBAR, +) from ..lv_validation import size +from ..lvcode import lv_obj from ..types import lv_obj_t from . import WidgetType @@ -31,5 +39,11 @@ class ContainerType(WidgetType): ) self.styles = {} + async def on_create(self, var: MockObj, config: dict) -> None: + # A container is not clickable, so dragging its background does not scroll it. + # An explicit `scrollable: true` asks for that, unless `clickable` is also set. + if config.get(CONF_SCROLLABLE) is True and CONF_CLICKABLE not in config: + lv_obj.set_clickable(var, True) + container_spec = ContainerType() diff --git a/tests/component_tests/lvgl/config/container_scrollable_test.yaml b/tests/component_tests/lvgl/config/container_scrollable_test.yaml new file mode 100644 index 00000000000..eae679ce7aa --- /dev/null +++ b/tests/component_tests/lvgl/config/container_scrollable_test.yaml @@ -0,0 +1,48 @@ +esphome: + name: test-container-scrollable + +esp32: + board: esp32dev + framework: + type: esp-idf + +spi: + - id: spi_bus + clk_pin: GPIO18 + mosi_pin: GPIO23 + +display: + - platform: mipi_spi + spi_id: spi_bus + model: st7789v + id: tft_display + dimensions: + width: 240 + height: 320 + cs_pin: GPIO22 + dc_pin: GPIO21 + auto_clear_enabled: false + invert_colors: false + update_interval: never + +lvgl: + displays: tft_display + widgets: + - container: + id: cont_default + - container: + id: cont_scrollable + scrollable: true + - container: + id: cont_not_scrollable + scrollable: false + - container: + id: cont_scrollable_not_clickable + scrollable: true + clickable: false + - container: + id: cont_scrollable_lambda + scrollable: !lambda return true; + - obj: + id: obj_scrollable + scrollable: true diff --git a/tests/component_tests/lvgl/test_container_scrollable.py b/tests/component_tests/lvgl/test_container_scrollable.py new file mode 100644 index 00000000000..d37b32d8cc5 --- /dev/null +++ b/tests/component_tests/lvgl/test_container_scrollable.py @@ -0,0 +1,44 @@ +"""An explicit ``scrollable: true`` on a container also makes it clickable, so a drag +on its background scrolls it. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +@pytest.fixture +def main_cpp( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> str: + return generate_main(component_config_path("container_scrollable_test.yaml")) + + +def _set_clickable_calls(main_cpp: str, widget_id: str) -> list[str]: + prefix = f"lv_obj_set_clickable({widget_id}, " + return [ + line.strip().removeprefix(prefix).removesuffix(");") + for line in main_cpp.splitlines() + if line.strip().startswith(prefix) + ] + + +@pytest.mark.parametrize( + ("widget_id", "expected"), + [ + ("cont_scrollable", ["true"]), + ("cont_default", []), + ("cont_not_scrollable", []), + ("cont_scrollable_not_clickable", ["false"]), + ("cont_scrollable_lambda", []), + ("obj_scrollable", []), + ], +) +def test_container_set_clickable( + main_cpp: str, widget_id: str, expected: list[str] +) -> None: + assert _set_clickable_calls(main_cpp, widget_id) == expected diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 18a7fd0913a..3e9f1d56b38 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -569,6 +569,11 @@ lvgl: align: top_left text_letter_space: -4 text_line_space: -4 + - container: + id: scrollable_container + scrollable: true + width: 50 + height: 50 - container: align: center anim_duration: 1s From d516fd5c92ca6e452a1810095a78da02daf4b40c Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:11:05 +1100 Subject: [PATCH 117/170] [http_request] Fix macOS host build with the precompiled header (#21705) --- esphome/components/http_request/httplib.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/http_request/httplib.h b/esphome/components/http_request/httplib.h index 8b086997020..3f8b8a71de2 100644 --- a/esphome/components/http_request/httplib.h +++ b/esphome/components/http_request/httplib.h @@ -289,8 +289,12 @@ using socket_t = int; #elif defined(CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN) && defined(__APPLE__) #include #if TARGET_OS_OSX +// The precompiled header brings in ArduinoJson.h, whose `using namespace ArduinoJson` makes MacTypes.h's +// global Ptr ambiguous with ArduinoJson's Ptr class; give the macOS one another name here +#define Ptr esphome_mactypes_Ptr #include #include +#undef Ptr #endif // TARGET_OS_OSX #endif // _WIN32 From c11c788d2557cddf104ba94613d3047278a6da8d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:11:19 +1300 Subject: [PATCH 118/170] Revert "[lvgl] Make a scrollable container automatically clickable (#22094)" This reverts commit 8e87cfc17e89675a7baa917b30a9a46ef99a4c14. --- esphome/components/lvgl/widgets/container.py | 16 +------ .../config/container_scrollable_test.yaml | 48 ------------------- .../lvgl/test_container_scrollable.py | 44 ----------------- tests/components/lvgl/lvgl-package.yaml | 5 -- 4 files changed, 1 insertion(+), 112 deletions(-) delete mode 100644 tests/component_tests/lvgl/config/container_scrollable_test.yaml delete mode 100644 tests/component_tests/lvgl/test_container_scrollable.py diff --git a/esphome/components/lvgl/widgets/container.py b/esphome/components/lvgl/widgets/container.py index 1339855ab10..427b46affa1 100644 --- a/esphome/components/lvgl/widgets/container.py +++ b/esphome/components/lvgl/widgets/container.py @@ -1,16 +1,8 @@ import esphome.config_validation as cv from esphome.const import CONF_HEIGHT, CONF_WIDTH -from esphome.cpp_generator import MockObj -from ..defines import ( - CONF_CLICKABLE, - CONF_CONTAINER, - CONF_MAIN, - CONF_SCROLLABLE, - CONF_SCROLLBAR, -) +from ..defines import CONF_CONTAINER, CONF_MAIN, CONF_SCROLLBAR from ..lv_validation import size -from ..lvcode import lv_obj from ..types import lv_obj_t from . import WidgetType @@ -39,11 +31,5 @@ class ContainerType(WidgetType): ) self.styles = {} - async def on_create(self, var: MockObj, config: dict) -> None: - # A container is not clickable, so dragging its background does not scroll it. - # An explicit `scrollable: true` asks for that, unless `clickable` is also set. - if config.get(CONF_SCROLLABLE) is True and CONF_CLICKABLE not in config: - lv_obj.set_clickable(var, True) - container_spec = ContainerType() diff --git a/tests/component_tests/lvgl/config/container_scrollable_test.yaml b/tests/component_tests/lvgl/config/container_scrollable_test.yaml deleted file mode 100644 index eae679ce7aa..00000000000 --- a/tests/component_tests/lvgl/config/container_scrollable_test.yaml +++ /dev/null @@ -1,48 +0,0 @@ -esphome: - name: test-container-scrollable - -esp32: - board: esp32dev - framework: - type: esp-idf - -spi: - - id: spi_bus - clk_pin: GPIO18 - mosi_pin: GPIO23 - -display: - - platform: mipi_spi - spi_id: spi_bus - model: st7789v - id: tft_display - dimensions: - width: 240 - height: 320 - cs_pin: GPIO22 - dc_pin: GPIO21 - auto_clear_enabled: false - invert_colors: false - update_interval: never - -lvgl: - displays: tft_display - widgets: - - container: - id: cont_default - - container: - id: cont_scrollable - scrollable: true - - container: - id: cont_not_scrollable - scrollable: false - - container: - id: cont_scrollable_not_clickable - scrollable: true - clickable: false - - container: - id: cont_scrollable_lambda - scrollable: !lambda return true; - - obj: - id: obj_scrollable - scrollable: true diff --git a/tests/component_tests/lvgl/test_container_scrollable.py b/tests/component_tests/lvgl/test_container_scrollable.py deleted file mode 100644 index d37b32d8cc5..00000000000 --- a/tests/component_tests/lvgl/test_container_scrollable.py +++ /dev/null @@ -1,44 +0,0 @@ -"""An explicit ``scrollable: true`` on a container also makes it clickable, so a drag -on its background scrolls it. -""" - -from __future__ import annotations - -from collections.abc import Callable -from pathlib import Path - -import pytest - - -@pytest.fixture -def main_cpp( - generate_main: Callable[[str | Path], str], - component_config_path: Callable[[str], Path], -) -> str: - return generate_main(component_config_path("container_scrollable_test.yaml")) - - -def _set_clickable_calls(main_cpp: str, widget_id: str) -> list[str]: - prefix = f"lv_obj_set_clickable({widget_id}, " - return [ - line.strip().removeprefix(prefix).removesuffix(");") - for line in main_cpp.splitlines() - if line.strip().startswith(prefix) - ] - - -@pytest.mark.parametrize( - ("widget_id", "expected"), - [ - ("cont_scrollable", ["true"]), - ("cont_default", []), - ("cont_not_scrollable", []), - ("cont_scrollable_not_clickable", ["false"]), - ("cont_scrollable_lambda", []), - ("obj_scrollable", []), - ], -) -def test_container_set_clickable( - main_cpp: str, widget_id: str, expected: list[str] -) -> None: - assert _set_clickable_calls(main_cpp, widget_id) == expected diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 3e9f1d56b38..18a7fd0913a 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -569,11 +569,6 @@ lvgl: align: top_left text_letter_space: -4 text_line_space: -4 - - container: - id: scrollable_container - scrollable: true - width: 50 - height: 50 - container: align: center anim_duration: 1s From 47a526d35cfe1242945658f5f6273dbe81d9157b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:40:35 -0600 Subject: [PATCH 119/170] Bump astral-sh/setup-uv from 10.3.2 to 10.3.3 in /.github/actions/restore-python (#22167) Signed-off-by: dependabot[bot] --- .github/actions/restore-python/action.yml | 2 +- .github/workflows/actions.lock | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index c8f56012b24..805fc75f4ce 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@v10.3.3 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 90d608d6446..a698da1b5d7 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -26,7 +26,7 @@ workflows: - 'actions/cache@v6.1.0' - 'actions/checkout@v7.0.1' - 'actions/setup-python@v7.1.0' - - 'astral-sh/setup-uv@v10.3.2' + - 'astral-sh/setup-uv@v10.3.3' '.github/workflows/ci.yml': - 'actions/cache@v5.0.5' - 'actions/cache@v6.1.0' @@ -34,7 +34,6 @@ workflows: - 'actions/download-artifact@v8.0.2' - 'actions/setup-python@v7.1.0' - 'actions/upload-artifact@v7.0.2' - - 'astral-sh/setup-uv@v10.3.2' - 'astral-sh/setup-uv@v10.3.3' - 'awalsh128/cache-apt-pkgs-action@v1.6.3' - 'codecov/codecov-action@v7.1.1' @@ -161,11 +160,6 @@ dependencies: commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9' owner_id: 44036562 repo_id: 192625955 - 'astral-sh/setup-uv@v10.3.2': - ref: 'v10.3.2' - commit: 'sha1-07a047e608db84b3d8c06cd9a7c7dcc9c355c18e' - owner_id: 115962839 - repo_id: 846733598 'astral-sh/setup-uv@v10.3.3': ref: 'v10.3.3' commit: 'sha1-68559e99cc807c1eb7e9238fd43a15bba460eec2' From 912e9dedae523966aaa0bbe99636ce15d8eb42a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:05:44 -0600 Subject: [PATCH 120/170] Bump esphome/workflows/.github/workflows/stale.yml from 2026.10.2 to 2026.10.3 (#22169) Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5146dd65eaf..327be55ca62 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # main + uses: esphome/workflows/.github/workflows/stale.yml@e1432c7b31fec7d24a13dc192953ebac836ed98a # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: From a24b994a5ef185b8a9eb78689af2be6296d18af4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:05:58 -0600 Subject: [PATCH 121/170] Bump esphome/workflows/.github/workflows/lock.yml from 2026.10.2 to 2026.10.3 (#22168) Signed-off-by: dependabot[bot] --- .github/workflows/lock.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 3f61c9add06..bf42b2725e1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -14,4 +14,4 @@ jobs: permissions: issues: write # issues.lock on closed issues pull-requests: write # issues.lock on closed pull requests - uses: esphome/workflows/.github/workflows/lock.yml@31d160e5f0ffa0ee65d5b27cb55c153d415f2abe # 2026.10.2 + uses: esphome/workflows/.github/workflows/lock.yml@e1432c7b31fec7d24a13dc192953ebac836ed98a # 2026.10.3 From 51db039f5944528bd03c2aee773bc64a9bdc0c0f Mon Sep 17 00:00:00 2001 From: iskyguy <53022829+iskyguy@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:45:30 -0600 Subject: [PATCH 122/170] [sht4x] Increase serial number response wait to 10 ms (#22183) --- esphome/components/sht4x/sht4x.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 6bf76769a35..a66d8c391e4 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -18,7 +18,7 @@ static constexpr float RAW_MAX = 65535.0f; void SHT4XComponent::read_serial_number_() { uint16_t buffer[2]; - if (!this->get_8bit_register(SERIAL_NUMBER_COMMAND, buffer, 2, 1)) { + if (!this->get_8bit_register(SERIAL_NUMBER_COMMAND, buffer, 2, 10)) { ESP_LOGE(TAG, "Get serial number failed"); this->serial_number_ = 0; return; From ed4db4b20e611f56170b648fadfab75e81a3a85e Mon Sep 17 00:00:00 2001 From: iskyguy <53022829+iskyguy@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:20:42 -0600 Subject: [PATCH 123/170] [sht4x] Expose serial number as a text sensor (#22176) --- esphome/components/sht4x/sensor.py | 18 ++++++++++++++++-- esphome/components/sht4x/sht4x.cpp | 8 ++++++++ esphome/components/sht4x/sht4x.h | 6 ++++++ tests/components/sht4x/common.yaml | 2 ++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/esphome/components/sht4x/sensor.py b/esphome/components/sht4x/sensor.py index 48c652224ab..c05544a1ed9 100644 --- a/esphome/components/sht4x/sensor.py +++ b/esphome/components/sht4x/sensor.py @@ -1,11 +1,13 @@ import esphome.codegen as cg -from esphome.components import i2c, sensirion_common, sensor +from esphome.components import i2c, sensirion_common, sensor, text_sensor +from esphome.components.const import CONF_SERIAL_NUMBER import esphome.config_validation as cv from esphome.const import ( CONF_HUMIDITY, CONF_TEMPERATURE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, ICON_THERMOMETER, ICON_WATER_PERCENT, STATE_CLASS_MEASUREMENT, @@ -16,7 +18,13 @@ from esphome.types import ConfigType CODEOWNERS = ["@sjtrny"] DEPENDENCIES = ["i2c"] -AUTO_LOAD = ["sensirion_common"] + + +def AUTO_LOAD(config: ConfigType | None) -> list[str]: + if not config or CONF_SERIAL_NUMBER in config: + return ["sensirion_common", "text_sensor"] + return ["sensirion_common"] + sht4x_ns = cg.esphome_ns.namespace("sht4x") @@ -62,6 +70,9 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_SERIAL_NUMBER): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), cv.Optional(CONF_PRECISION, default="High"): cv.enum(PRECISION_OPTIONS), cv.Optional(CONF_HEATER_POWER, default="High"): cv.enum( HEATER_POWER_OPTIONS @@ -94,3 +105,6 @@ async def to_code(config: ConfigType) -> None: if key in config: sens = await sensor.new_sensor(config[key]) cg.add(getattr(var, funcName)(sens)) + + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_SERIAL_NUMBER, var.set_serial_number_text_sensor) diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index a66d8c391e4..2ff194e0b26 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -1,5 +1,6 @@ #include "sht4x.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::sht4x { @@ -25,6 +26,13 @@ void SHT4XComponent::read_serial_number_() { } this->serial_number_ = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1])); ESP_LOGD(TAG, "Serial number: %08" PRIx32, this->serial_number_); +#ifdef USE_TEXT_SENSOR + if (this->serial_number_text_sensor_ != nullptr) { + char serial_number[format_hex_size(sizeof(this->serial_number_))]; + format_hex_to(serial_number, this->serial_number_); + this->serial_number_text_sensor_->publish_state(serial_number); + } +#endif } void SHT4XComponent::setup() { diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index 0c20e2864a8..00d8e0f811c 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -3,6 +3,9 @@ #include "esphome/core/component.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/sensirion_common/i2c_sensirion.h" +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include @@ -17,6 +20,9 @@ enum SHT4XHEATERTIME : uint16_t { SHT4X_HEATERTIME_LONG = 1100, SHT4X_HEATERTIME class SHT4XComponent final : public PollingComponent, public sensirion_common::SensirionI2CDevice { SUB_SENSOR(temp) SUB_SENSOR(humidity) +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(serial_number) +#endif public: void setup() override; diff --git a/tests/components/sht4x/common.yaml b/tests/components/sht4x/common.yaml index bec192d6db5..ea411bf01e2 100644 --- a/tests/components/sht4x/common.yaml +++ b/tests/components/sht4x/common.yaml @@ -5,6 +5,8 @@ sensor: name: SHT4X Temperature humidity: name: SHT4X Humidity + serial_number: + name: SHT4X Serial Number address: 0x44 precision: High heater_max_duty: 0.02 From e236c741db22333d966a280afc28c844420f3366 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:22:44 -1000 Subject: [PATCH 124/170] [core] Add setup_check() (#22177) --- AGENTS.md | 3 ++- esphome/core/component.h | 11 ++++++++ tests/components/core/test_setup_check.cpp | 30 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/components/core/test_setup_check.cpp diff --git a/AGENTS.md b/AGENTS.md index 92ff32754c1..6b965318e3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,7 +232,8 @@ file does, and it is the authority when they disagree. The most useful starting * **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with `LineReader`, fixed-length binary frames with `FrameReader`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`. - `if (!this->status_comm_result(ok)) return;` sets or clears the warning. + `if (!this->status_comm_result(ok)) return;` sets or clears the warning; in `setup()`, + `if (!this->setup_check(ok)) return;` marks the component failed on error. * **A hub's pointer to a child entity** comes from `SUB_SENSOR(name)`, `SUB_BINARY_SENSOR`, `SUB_TEXT_SENSOR`, `SUB_SWITCH`, `SUB_NUMBER`, `SUB_SELECT` or `SUB_BUTTON`, which declare the `set__()` setter and the `__` member (`SUB_SWITCH(turbo)` gives `set_turbo_switch()`); do not write the pair by hand. diff --git a/esphome/core/component.h b/esphome/core/component.h index df51af65df5..ccf487a1448 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -227,6 +227,17 @@ class Component { this->mark_failed(); } + /// Marks the component failed with `message` (default "Communication failed") unless `ok`; returns `ok`. + /// For any setup() check: `if (!this->setup_check(this->write_byte(...))) return;`. Inlined so the failure + /// branches still merge. + bool ESPHOME_ALWAYS_INLINE setup_check(bool ok) { return this->setup_check(ok, LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } + bool ESPHOME_ALWAYS_INLINE setup_check(bool ok, const LogString *message) { + if (ok) + return true; + this->mark_failed(message); + return false; + } + /** Disable this component's loop. The loop() method will no longer be called. * * This is useful for components that only need to run for a certain period of time diff --git a/tests/components/core/test_setup_check.cpp b/tests/components/core/test_setup_check.cpp new file mode 100644 index 00000000000..a7d9b5f80f0 --- /dev/null +++ b/tests/components/core/test_setup_check.cpp @@ -0,0 +1,30 @@ +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" + +namespace esphome { + +TEST(SetupCheck, FailureMarksFailed) { + Component component; + EXPECT_FALSE(component.setup_check(false)); + EXPECT_TRUE(component.is_failed()); + EXPECT_TRUE(component.status_has_error()); +} + +TEST(SetupCheck, CustomMessageOverloadMarksFailed) { + Component component; + EXPECT_FALSE(component.setup_check(false, LOG_STR("Wrong chip ID"))); + EXPECT_TRUE(component.is_failed()); + EXPECT_TRUE(component.status_has_error()); +} + +TEST(SetupCheck, SuccessLeavesComponentUntouched) { + Component component; + EXPECT_TRUE(component.setup_check(true)); + EXPECT_TRUE(component.setup_check(true, LOG_STR("Wrong chip ID"))); + EXPECT_FALSE(component.is_failed()); + EXPECT_FALSE(component.status_has_error()); +} + +} // namespace esphome From be3113e7d56e30679bf317f727a0ec1930b22664 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:23:02 -1000 Subject: [PATCH 125/170] [m5stack_unit_bldc] Declare child entities with the SUB_* macros (#22181) --- .../m5stack_unit_bldc/m5stack_unit_bldc.h | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h index 9e0faf84710..ff7721de252 100644 --- a/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h +++ b/esphome/components/m5stack_unit_bldc/m5stack_unit_bldc.h @@ -64,24 +64,22 @@ class M5StackUnitBldc : public i2c::I2CDevice, public PollingComponent { } #ifdef USE_SENSOR - void set_rpm_sensor(sensor::Sensor *rpm_sensor) { this->rpm_sensor_ = rpm_sensor; } - void set_frequency_sensor(sensor::Sensor *frequency_sensor) { this->frequency_sensor_ = frequency_sensor; } + SUB_SENSOR(rpm) + SUB_SENSOR(frequency) #endif #ifdef USE_TEXT_SENSOR - void set_status_text_sensor(text_sensor::TextSensor *status_text_sensor) { - this->status_text_sensor_ = status_text_sensor; - } + SUB_TEXT_SENSOR(status) #endif #ifdef USE_NUMBER /// The PWM duty number in open loop, the target RPM number in closed loop. - void set_command_number(number::Number *command_number) { this->command_number_ = command_number; } + SUB_NUMBER(command) void control_command(float value) { if (this->write_command(value)) this->command_number_->publish_state(value); } #endif #ifdef USE_SELECT - void set_direction_select(select::Select *direction_select) { this->direction_select_ = direction_select; } + SUB_SELECT(direction) void control_direction(size_t index) { if (this->write_direction(static_cast(index))) this->direction_select_->publish_state(index); @@ -107,19 +105,6 @@ class M5StackUnitBldc : public i2c::I2CDevice, public PollingComponent { bool read_command_raw_(float *value); void finish_direction_change_(); -#ifdef USE_SENSOR - sensor::Sensor *rpm_sensor_{nullptr}; - sensor::Sensor *frequency_sensor_{nullptr}; -#endif -#ifdef USE_TEXT_SENSOR - text_sensor::TextSensor *status_text_sensor_{nullptr}; -#endif -#ifdef USE_SELECT - select::Select *direction_select_{nullptr}; -#endif -#ifdef USE_NUMBER - number::Number *command_number_{nullptr}; -#endif int32_t pid_[3]{}; ESPPreferenceObject save_pref_; // Last command, restored after the stop a direction change needs From 3d4e20f574ca0d16905ae43d12a0c64f58bd7acb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:23:22 -1000 Subject: [PATCH 126/170] [components] Build enum options with options(prefix=) in ld600x and rx8111 (#22180) --- esphome/components/ld600x/entities.py | 8 +++----- esphome/components/rx8111/binary_sensor.py | 10 +--------- esphome/components/rx8111/text_sensor.py | 8 +++----- 3 files changed, 7 insertions(+), 19 deletions(-) diff --git a/esphome/components/ld600x/entities.py b/esphome/components/ld600x/entities.py index 2a7c5ade415..8706b7d7042 100644 --- a/esphome/components/ld600x/entities.py +++ b/esphome/components/ld600x/entities.py @@ -77,11 +77,9 @@ from .const import ( MAX_TARGETS, ) -_AREA_KINDS = { - "interference": AreaKind.AREA_KIND_INTERFERENCE, - "detection": AreaKind.AREA_KIND_DETECTION, - "dwell": AreaKind.AREA_KIND_DWELL, -} +_AREA_KINDS = AreaKind.options( + "interference", "detection", "dwell", prefix="AREA_KIND_" +) _AREA_AXES = ( (KEY_X_MIN, AreaAxis.AREA_AXIS_X_MIN), diff --git a/esphome/components/rx8111/binary_sensor.py b/esphome/components/rx8111/binary_sensor.py index 3ce56bf7597..8b796234346 100644 --- a/esphome/components/rx8111/binary_sensor.py +++ b/esphome/components/rx8111/binary_sensor.py @@ -26,15 +26,7 @@ STATUS_SENSORS: dict[str, str | None] = { "evin": None, } StatusSensor = rx8111_ns.enum("StatusSensor") -STATUS_SENSOR_SLOTS = { - "vlf": StatusSensor.STATUS_SENSOR_VLF, - "alarm_flag": StatusSensor.STATUS_SENSOR_ALARM_FLAG, - "timer_flag": StatusSensor.STATUS_SENSOR_TIMER_FLAG, - "xst": StatusSensor.STATUS_SENSOR_XST, - "event_flag": StatusSensor.STATUS_SENSOR_EVENT_FLAG, - "battery_low": StatusSensor.STATUS_SENSOR_BATTERY_LOW, - "evin": StatusSensor.STATUS_SENSOR_EVIN, -} +STATUS_SENSOR_SLOTS = StatusSensor.options(*STATUS_SENSORS, prefix="STATUS_SENSOR_") CONFIG_SCHEMA = cv.All( cv.Schema( diff --git a/esphome/components/rx8111/text_sensor.py b/esphome/components/rx8111/text_sensor.py index f2eb69e8dd5..6de506da1ef 100644 --- a/esphome/components/rx8111/text_sensor.py +++ b/esphome/components/rx8111/text_sensor.py @@ -14,11 +14,9 @@ DEPENDENCIES = ["time"] CONF_TIMESTAMP_RECORD_MODE = "timestamp_record_mode" TimestampMode = rx8111_ns.enum("TimestampMode") -TIMESTAMP_MODES = { - "latest": TimestampMode.TIMESTAMP_MODE_LATEST, - "stop_when_full": TimestampMode.TIMESTAMP_MODE_STOP_WHEN_FULL, - "overwrite": TimestampMode.TIMESTAMP_MODE_OVERWRITE, -} +TIMESTAMP_MODES = TimestampMode.options( + "latest", "stop_when_full", "overwrite", prefix="TIMESTAMP_MODE_" +) CONFIG_SCHEMA = text_sensor.text_sensor_schema( device_class=DEVICE_CLASS_TIMESTAMP From 93d00b5920436628e295d6caece209ffd74f4eb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:24:03 -1000 Subject: [PATCH 127/170] [rx8111] Keep the scheduled event poll out of the unit tests (#22179) --- tests/components/rx8111/test_rx8111.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/components/rx8111/test_rx8111.cpp b/tests/components/rx8111/test_rx8111.cpp index 14d2f059d13..bbe0afe279d 100644 --- a/tests/components/rx8111/test_rx8111.cpp +++ b/tests/components/rx8111/test_rx8111.cpp @@ -121,6 +121,9 @@ class RX8111Test : public ::testing::Test { void SetUp() override { this->rtc_.set_i2c_bus(&this->bus_); this->rtc_.set_i2c_address(ADDRESS); + // The scheduler starts an interval after a random share of its period, which can be 0 ms and add + // a poll to any scheduler call. Tests poll by hand, or set their own interval. + this->rtc_.set_event_poll_interval(SCHEDULER_DONT_RUN); EXPECT_EQ(this->rtc_.boot_retry_delay_ms_, 1000U); // Production default remains one second. this->rtc_.boot_retry_delay_ms_ = 5; this->rtc_.add_on_time_sync_callback([this]() { this->syncs_++; }); @@ -386,6 +389,7 @@ TEST_F(RX8111Test, BinaryFailedStartupReadsLeaveEachRegisterGroupUnknown) { binary_sensor::BinarySensor event, battery; rtc.set_i2c_bus(&this->bus_); rtc.set_i2c_address(ADDRESS); + rtc.set_event_poll_interval(SCHEDULER_DONT_RUN); rtc.set_status_sensor(STATUS_SENSOR_EVENT_FLAG, &event); rtc.set_status_sensor(STATUS_SENSOR_BATTERY_LOW, &battery); this->bus_.fail_read = failed_register; @@ -640,6 +644,7 @@ TEST_F(RX8111Test, BinaryValidCalendarReusesFlagsWithoutSyncCallbacks) { binary_sensor::BinarySensor event; rtc.set_i2c_bus(&this->bus_); rtc.set_i2c_address(ADDRESS); + rtc.set_event_poll_interval(SCHEDULER_DONT_RUN); rtc.set_status_sensor(STATUS_SENSOR_EVENT_FLAG, &event); auto before = this->bus_.flag_reads; rtc.setup(); @@ -1028,6 +1033,7 @@ TEST_F(RX8111Test, TimestampStartupTransitionsConfigureBeforeCallbacksAndRetainS text_sensor::TextSensor sensor; rtc.set_i2c_bus(&bus); rtc.set_i2c_address(ADDRESS); + rtc.set_event_poll_interval(SCHEDULER_DONT_RUN); const bool old_buffered = mode == 0; this->timestamp_(0x20, 0x10); this->timestamp_(old_buffered ? 0x40 : 0x20); @@ -1087,6 +1093,7 @@ TEST_F(RX8111Test, TimestampStartupRetainsBufferAcrossAmbiguousClearFailure) { bus.accept_failed_timestamp_clear = accepted; rtc.set_i2c_bus(&bus); rtc.set_i2c_address(ADDRESS); + rtc.set_event_poll_interval(SCHEDULER_DONT_RUN); rtc.set_event_timestamp_sensor(&sensor, mode); std::vector states; sensor.add_on_state_callback([&](const std::string &state) { @@ -1156,6 +1163,7 @@ TEST_F(RX8111Test, TimestampStartupPreservesFirstFlagSnapshotAcrossFollowupReadF bus.fail_read_once = failed_reg; rtc.set_i2c_bus(&bus); rtc.set_i2c_address(ADDRESS); + rtc.set_event_poll_interval(SCHEDULER_DONT_RUN); rtc.set_event_timestamp_sensor(&sensor, TIMESTAMP_MODE_LATEST); this->configure_event_(rtc); EXPECT_TRUE(sensor.state.empty()); @@ -1222,6 +1230,7 @@ TEST_F(RX8111Test, TimestampExhaustedStartupPublishesStagedBufferOnceWithoutEnab text_sensor::TextSensor after_reboot; rebooted.set_i2c_bus(&this->bus_); rebooted.set_i2c_address(ADDRESS); + rebooted.set_event_poll_interval(SCHEDULER_DONT_RUN); rebooted.set_event_timestamp_sensor(&after_reboot, TIMESTAMP_MODE_STOP_WHEN_FULL); this->configure_event_(rebooted); EXPECT_TRUE(after_reboot.state.empty()); @@ -1814,6 +1823,7 @@ TEST_F(RX8111Test, FailedRestartKeepsStopAcrossHostResetUntilFreshTrustedWrite) RX8111Component rebooted; rebooted.set_i2c_bus(&this->bus_); rebooted.set_i2c_address(ADDRESS); + rebooted.set_event_poll_interval(SCHEDULER_DONT_RUN); rebooted.add_on_time_sync_callback([this]() { this->syncs_++; }); rebooted.setup(); EXPECT_EQ(this->syncs_, 0); // The RAM guard is gone, but STOP survives. @@ -1870,6 +1880,7 @@ TEST_F(RX8111Test, IncompleteWritesRemainInvalidAfterComponentRecreation) { RX8111Component rebooted; rebooted.set_i2c_bus(&this->bus_); rebooted.set_i2c_address(ADDRESS); + rebooted.set_event_poll_interval(SCHEDULER_DONT_RUN); unsigned syncs = 0; rebooted.add_on_time_sync_callback([&syncs]() { syncs++; }); rebooted.setup(); From 4af8e50dca602b9d94ffd0a9c72f66168bbdd1e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:24:24 -1000 Subject: [PATCH 128/170] [ai] Point reviewers at existing helpers (#22178) --- .agents/skills/code-review/SKILL.md | 20 ++++++++++++++++++++ AGENTS.md | 1 + 2 files changed, 21 insertions(+) diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 3cc2af0c81f..191a7fd19bd 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -69,6 +69,26 @@ performance nit, because it fragments a small shared heap. Flag: typed as `std::function`, so lightweight forwarders avoid a heap allocation. - Two-space indent, `using` over `typedef`, wrap at 120 columns. +## Use the existing helper + +New components often hand-write code a helper already covers. Ask for the +helper; `AGENTS.md` section 4 and the `component-codegen` skill have the detail. + +| Seen in the PR | Ask for | +| --- | --- | +| `if (this->x_sensor_ != nullptr) this->x_sensor_->publish_state(v);` | `PUBLISH_STATE(this->x_sensor_, v);` (`esphome/core/entity_base.h`) | +| set warning / `return` / clear warning around a bus result | `if (!this->status_comm_result(ok)) return;` | +| hand-written `set_x_sensor()` plus `x_sensor_` member | `SUB_SENSOR(x)`, `SUB_BINARY_SENSOR`, `SUB_TEXT_SENSOR`, `SUB_SWITCH`, ... | +| `if CONF_X in config: s = await sensor.new_sensor(config[CONF_X])` plus setter | `sensors = sensor.sub_sensors(config)` then `await sensors(CONF_X, hub.set_x_sensor)` | +| hand-written `{"a": MyEnum.MY_ENUM_A, ...}` dict | `MyEnum.options("a", "b", prefix="MY_ENUM_")` | +| loops on `available()` / `read_byte()` | UART `read_available(on_byte)`, `discard_available()`, `LineReader`, `FrameReader` | +| hand-rolled CRC, checksum or bit loop | `crc8`, `crc16`, `xor_bytes`, `sum16_bytes`, `encode_uint16`, ... (`esphome/core/helpers.h`) | +| bare `mark_failed()`, or `ESP_LOGE` then `mark_failed()` | `this->mark_failed(LOG_STR("reason"))` or `LOG_STR(ESP_LOG_MSG_COMM_FAIL)` | +| button/switch/number/select C++ class that only calls the hub | `ApplyButton`, `ApplySwitch`, `ApplyNumber`, `ApplySelect` with `apply=` | +| action or condition class that only forwards to the parent | `register_apply_action` / `register_apply_condition` | +| enum-to-string `switch` returning literals | `PROGMEM_STRING_TABLE` with `get_log_str` / `get_progmem_str` | +| `if (x := config.get(CONF_X)) is not None: cg.add(var.set_x(x))` | `cg.add_optional(var.set_x, config.get(CONF_X))`, when the setter takes the value unchanged | + ## Python conventions - Type-annotate every new function signature (params and return), new dataclass diff --git a/AGENTS.md b/AGENTS.md index 6b965318e3a..188bc329584 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,6 +238,7 @@ file does, and it is the authority when they disagree. The most useful starting `SUB_SWITCH`, `SUB_NUMBER`, `SUB_SELECT` or `SUB_BUTTON`, which declare the `set__()` setter and the `__` member (`SUB_SWITCH(turbo)` gives `set_turbo_switch()`); do not write the pair by hand. A child sensor that skips repeated values is `SUB_SENSOR_WITH_DEDUP(name, type)`. + Publish to an optional child with `PUBLISH_STATE(this->x_sensor_, value);`, not a hand-written null check. * **An unset float is `NAN`**, not `optional`; the optional costs 4 to 8 bytes of padding per member. * **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the end of `setup()` when the entities need a state at boot. From 78d2fdbfc0e54461859282d29ebe13ba60e8a786 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:37:33 -1000 Subject: [PATCH 129/170] [wifi] Keep scans working after a join with a BSSID on RTL87xx (#21754) --- .../wifi/wifi_component_libretiny.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index beeddd4b29f..85b58e904de 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -25,6 +25,7 @@ extern "C" { extern "C" { #include #include +#include } #endif @@ -252,12 +253,24 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTING); s_ignored_disconnect_count = 0; + uint8_t channel = ap.get_channel(); // 0 = auto +#ifdef USE_RTL87XX + // Given a channel and a BSSID, LibreTiny narrows the driver's scan list to the channel for the join, then leaves it + // disabled, so the next scan finds nothing. Narrow it here and pass no channel (LibreTiny ignores a channel without + // a BSSID); the join's own scan restores the full list when it ends. The placeholder SSID starts that scan, as + // LibreTiny's does; narrowed before it, the scan can miss the beacon. + if (channel != 0 && ap.has_bssid()) { + wext_set_ssid(WLAN0_NAME, reinterpret_cast("-"), 1); + uint8_t pscan_config = PSCAN_ENABLE | PSCAN_FAST_SURVEY; + wifi_set_pscan_chan(&channel, &pscan_config, 1); + } + channel = 0; +#endif WiFiStatus status; { // The driver blocks until the join has started, or finished on Realtek UnavoidableBlockingScope scope; - status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), - ap.get_channel(), // 0 = auto + status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), channel, ap.has_bssid() ? ap.get_bssid().data() : NULL); } if (status != WL_CONNECTED) { From d8bfae306f00d0d42d450ee3a33bb1c0dd16ae99 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:38:22 -1000 Subject: [PATCH 130/170] [core] Add AutomationList for hubs that trigger their own automations (#21788) --- .agents/skills/automations/SKILL.md | 2 + esphome/automation.py | 34 +++++++++- esphome/core/automation.h | 24 +++++++ .../components/core/test_automation_list.cpp | 62 +++++++++++++++++++ tests/unit_tests/test_automation.py | 53 ++++++++++++++++ 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 tests/components/core/test_automation_list.cpp diff --git a/.agents/skills/automations/SKILL.md b/.agents/skills/automations/SKILL.md index 007d6d06209..540288296a6 100644 --- a/.agents/skills/automations/SKILL.md +++ b/.agents/skills/automations/SKILL.md @@ -116,6 +116,8 @@ void add_on_state_callback(std::function &&callback) { |------|----------------------|-------------| | `CallbackManager` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered | | `LazyCallbackManager` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) | +| `AutomationList` + `build_automation_lists` | 4 bytes per automation + count, none when unused | A hub fires its own `on_*` automations with no filter (4 B per automation vs 8 B per `Callback`) | +| `automation.new_automation(args, conf)` + the parent's own fixed table | the table entry (pointer plus its own data) | Like `AutomationList`, but each automation needs a stored value of its own, e.g. a threshold the parent compares against | Both live in `esphome/core/helpers.h` and accept `std::function`, lambdas and forwarder structs through their templatized `add()` method. `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first diff --git a/esphome/automation.py b/esphome/automation.py index d91c5f6fd35..6805b97b148 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1873,10 +1873,13 @@ def validate_synchronous_automation( ) -async def _new_automation( +async def new_automation( args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: - """Create the Automation for ``config`` with its actions.""" + """Create the ``Automation`` for an ``on_*`` config with its actions. + + For a parent that stores and fires the automation itself; ``build_automation`` attaches it to a trigger. + """ plan = await _build_automation_plan(args, config) return _automation_from_plan(plan, args, config, *ctor_args) @@ -1941,7 +1944,7 @@ def _when_call( async def build_automation( trigger: MockObj, args: TemplateArgsType, config: ConfigType ) -> MockObj: - return await _new_automation(args, config, trigger) + return await new_automation(args, config, trigger) async def build_trigger_callback( @@ -2102,6 +2105,31 @@ async def build_trigger_automations( await build_automation(trigger, args, conf) +@dataclass(frozen=True, slots=True) +class AutomationList: + """Automations a parent keeps in an ``AutomationList`` and triggers itself. + + The parent adds each one with ``add__automation()``. ``count_define`` is the most + automations any one parent gets; it is absent when none do. + """ + + conf_key: str + count_define: str + args: TemplateArgsType + + +async def build_automation_lists( + parent: MockObj, config: ConfigType, entries: tuple[AutomationList, ...] +) -> None: + """Build each entry's automations and add them to the parent's slot-counted list.""" + for entry in entries: + request_slot = cg.slot_counter(entry.count_define) + for conf in config.get(entry.conf_key, []): + request_slot(str(parent)) + obj = await new_automation(entry.args, conf) + cg.add(getattr(parent, f"add_{entry.conf_key}_automation")(obj)) + + @dataclass(frozen=True, slots=True) class CallbackAutomation: """A single callback automation entry for build_callback_automations.""" diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 87ff7094fa0..d00acd0a6d2 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -8,6 +8,7 @@ #include "esphome/core/string_ref.h" #include #include +#include #include #include #include @@ -753,6 +754,29 @@ template class Automation { ActionList actions_; }; +/// Triggers each automation in order; shared by every AutomationList size with the same arguments. +template +void trigger_automations(std::span *const> automations, const std::type_identity_t &...x) { + for (auto *automation : automations) + automation->trigger(x...); +} + +/// Automations a component triggers itself, sized at codegen time (count from cg.slot_counter). +template class AutomationList { + public: + void add(Automation *automation) { this->automations_.push_back(automation); } + bool empty() const { return this->automations_.empty(); } + /// Arguments convert to Ts only when the list is not empty (e.g. `const char *` to std::string). + template void trigger(Args &&...x) { + if (this->automations_.empty()) + return; + trigger_automations(std::span *const>(this->automations_), x...); + } + + protected: + StaticVector *, N> automations_; +}; + /// Callback forwarder that triggers an Automation directly. /// One operator() instantiation per Automation signature, shared across all call sites. /// Must stay pointer-sized to fit inline in Callback::ctx_ without heap allocation. diff --git a/tests/components/core/test_automation_list.cpp b/tests/components/core/test_automation_list.cpp new file mode 100644 index 00000000000..d62f2d13442 --- /dev/null +++ b/tests/components/core/test_automation_list.cpp @@ -0,0 +1,62 @@ +#include + +#include +#include +#include + +#include "esphome/core/automation.h" + +namespace esphome::testing { + +using Seen = std::vector>; + +class ListRecordAction : public Action { + public: + ListRecordAction(char label, Seen *seen) : label_(label), seen_(seen) {} + void play(const std::string &value, const int &index) override { + this->seen_->emplace_back(this->label_, value, index); + } + + protected: + char label_; + Seen *seen_; +}; + +TEST(AutomationListTest, TriggersEachAutomationInOrder) { + Seen seen; + ListRecordAction first_action('1', &seen); + ListRecordAction second_action('2', &seen); + Automation first; + Automation second; + first.add_action(&first_action); + second.add_action(&second_action); + + AutomationList<2, std::string, int> list; + EXPECT_TRUE(list.empty()); + list.trigger("none", 0); + EXPECT_TRUE(seen.empty()); + + list.add(&first); + list.add(&second); + EXPECT_FALSE(list.empty()); + list.trigger("tag", 7); + EXPECT_EQ(seen, (Seen{{'1', "tag", 7}, {'2', "tag", 7}})); +} + +TEST(AutomationListTest, ListsOfDifferentSizesTriggerInOrder) { + Seen seen; + ListRecordAction action('a', &seen); + Automation automation; + automation.add_action(&action); + + AutomationList<1, std::string, int> small; + AutomationList<3, std::string, int> large; + small.add(&automation); + large.add(&automation); + large.add(&automation); + small.trigger("a", 1); + large.trigger("b", 2); + EXPECT_EQ(seen, (Seen{{'a', "a", 1}, {'a', "b", 2}, {'a', "b", 2}})); +} + +} // namespace esphome::testing diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index d8ed685a05e..4a7e248176d 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -22,6 +22,7 @@ from esphome.automation import ( ApplyCondition, ApplyField, Automation, + AutomationList, CallbackAutomation, DelayAction, ForCondition, @@ -43,6 +44,7 @@ from esphome.automation import ( apply_statements_action, build_action, build_action_list, + build_automation_lists, build_callback_automation, build_callback_automations, build_condition, @@ -54,6 +56,7 @@ from esphome.automation import ( if_action_to_code, literal_with_length, maybe_simple_id, + new_automation, new_lambda_pvariable, owning_args, parent_ref, @@ -105,6 +108,7 @@ from esphome.cpp_generator import LambdaExpression, MockObj, RawExpression from esphome.schema_extractors import SCHEMA_EXTRACT from esphome.types import ConfigType from esphome.util import Registry, RegistryEntry +from tests.component_tests.helpers import get_define_value def _make_registry(non_synchronous_actions: set[str]) -> dict[str, RegistryEntry]: @@ -3369,3 +3373,52 @@ async def test_apply_statements_action_merges_with_neighbours() -> None: assert "ApplyRunAction" in CORE.cpp_main_section text = CORE.cpp_global_section assert text.index("use(esphome__v);") < text.index("::comp->do_a();") + + +@pytest.mark.asyncio +async def test_build_automation_lists(mock_cg: MockCodegen) -> None: + """Each automation is added to its parent's list; the count is the busiest parent's.""" + mock_cg.new_pvariable.side_effect = lambda id_, *args: MockObj(str(id_), "->") + entries = ( + AutomationList("on_key", "TEST_ON_KEY_COUNT", [(cg.uint8, "x")]), + AutomationList("on_tag", "TEST_ON_TAG_COUNT", []), + AutomationList("on_raw", "TEST_ON_RAW_COUNT", []), + ) + + def conf(name: str) -> ConfigType: + return {CONF_AUTOMATION_ID: ID(name), CONF_THEN: []} + + hub_a = MockObj("hub_a", "->") + hub_b = MockObj("hub_b", "->") + await build_automation_lists( + hub_a, {"on_key": [conf("a1"), conf("a2")], "on_tag": [conf("a3")]}, entries + ) + await build_automation_lists(hub_b, {"on_key": [conf("b1")]}, entries) + assert [str(c.args[0]) for c in mock_cg.add.call_args_list] == [ + "hub_a->add_on_key_automation(a1)", + "hub_a->add_on_key_automation(a2)", + "hub_a->add_on_tag_automation(a3)", + "hub_b->add_on_key_automation(b1)", + ] + assert [str(c.args[1]) for c in mock_cg.new_pvariable.call_args_list] == [ + "", + "", + "<>", + "", + ] + CORE.flush_tasks() + assert get_define_value("TEST_ON_KEY_COUNT") == "2" + assert get_define_value("TEST_ON_TAG_COUNT") == "1" + assert get_define_value("TEST_ON_RAW_COUNT") is None + + +@pytest.mark.asyncio +async def test_new_automation(mock_cg: MockCodegen) -> None: + """A bare automation takes its template from args and gets no constructor arguments.""" + mock_cg.new_pvariable.side_effect = lambda id_, *args: MockObj(str(id_), "->") + obj = await new_automation( + [(cg.float_, "x")], {CONF_AUTOMATION_ID: ID("a1"), CONF_THEN: []} + ) + assert str(obj) == "a1" + ((id_, templ), _) = mock_cg.new_pvariable.call_args + assert (str(id_), str(templ)) == ("a1", "") From 3d95a0ba33d58901c3692a82cbea9f850e6741ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:39:17 -1000 Subject: [PATCH 131/170] [libretiny] Report the previous boot's crash on RTL8710B (#21752) --- esphome/components/libretiny/__init__.py | 10 +++- .../components/libretiny/crash_handler.cpp | 33 ++++++++++-- esphome/components/libretiny/crash_handler.h | 2 +- esphome/core/defines.h | 2 +- esphome/stacktrace.py | 15 ++++-- .../libretiny/test_crash_handler.py | 4 +- tests/unit_tests/test_stacktrace.py | 53 +++++++++++++++++++ 7 files changed, 106 insertions(+), 13 deletions(-) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index ec79626d299..e8ab39954b8 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -61,8 +61,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) # Fault handlers the crash handler wraps (BK7238, BK7231N) BK_TRAP_HANDLERS = ("bk_trap_udef", "bk_trap_pabt", "bk_trap_dabt") -# Families with a crash handler; the RTL8720C and LN882H install it at run time (no wraps). -CRASH_HANDLER_FAMILIES = (FAMILY_BK7231N, FAMILY_BK7238, FAMILY_LN882H, FAMILY_RTL8720C) +# Families with a crash handler; the Cortex-M ones install it at run time (no wraps). +CRASH_HANDLER_FAMILIES = ( + FAMILY_BK7231N, + FAMILY_BK7238, + FAMILY_LN882H, + FAMILY_RTL8710B, + FAMILY_RTL8720C, +) CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["preferences"] diff --git a/esphome/components/libretiny/crash_handler.cpp b/esphome/components/libretiny/crash_handler.cpp index df5a7218195..77b4a7e57ba 100644 --- a/esphome/components/libretiny/crash_handler.cpp +++ b/esphome/components/libretiny/crash_handler.cpp @@ -59,6 +59,18 @@ extern char __ram_code_text_end__[]; // NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) #endif // USE_LIBRETINY_VARIANT_RTL8720C +#ifdef USE_LIBRETINY_VARIANT_RTL8710B +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { +// Bounds of the code in flash (XIP) and of the IRAM_ATTR code (linker symbols; the latter from patch_linker.py). +extern char __flash_text_start__[]; +extern char __flash_text_end__[]; +extern char __esphome_sram_text_start[]; +extern char __esphome_sram_text_end[]; +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif // USE_LIBRETINY_VARIANT_RTL8710B + #ifdef USE_LN882X // NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) extern "C" { @@ -82,7 +94,7 @@ static constexpr uint32_t CRASH_MAGIC_V2 = 0xDEAD0002; static constexpr uint32_t CRASH_REASON_UNDEFINED = 1; static constexpr uint32_t CRASH_REASON_PREFETCH_ABORT = 2; static constexpr uint32_t CRASH_REASON_DATA_ABORT = 3; -// RTL8720C and LN882H: the Cortex-M exception number (3 to 7) plus this offset. +// Cortex-M families: the exception number (3 to 7) plus this offset. static constexpr uint32_t CRASH_REASON_CORTEX_M_OFFSET = 1; PROGMEM_STRING_TABLE(CrashReasonStrings, "Unknown", "Undefined instruction", "Prefetch abort", "Data abort", "Hard fault", "MemManage fault", "Bus fault", "Usage fault", "Secure fault"); @@ -108,6 +120,10 @@ struct CrashRecord { #elif defined(USE_LN882X) // ln882hki_bsp.template.ld puts .noinit in RETENTION RAM, which startup does not clear. #define CRASH_RECORD_SECTION ".noinit" +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +// The AmebaZ linker script puts .bfsram.data* (the FreeRTOS heap) after the .bss that startup zeroes, and the +// image the bootloader loads ends before .bss. +#define CRASH_RECORD_SECTION ".bfsram.data.esphome_crash" #else // rtl8710c_ram.ld keeps .ram.bss.noinit* out of the startup zeroing. #define CRASH_RECORD_SECTION ".ram.bss.noinit.esphome_crash" @@ -162,6 +178,9 @@ static inline bool is_code_addr(uint32_t val) { #ifdef USE_BK72XX return (addr >= (uint32_t) _vector_start && addr < (uint32_t) _data_flash_begin) || (addr >= (uint32_t) _itcmcode_ram_begin && addr < (uint32_t) _itcmcode_ram_end); +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) + return (addr >= (uint32_t) __flash_text_start__ && addr < (uint32_t) __flash_text_end__) || + (addr >= (uint32_t) __esphome_sram_text_start && addr < (uint32_t) __esphome_sram_text_end); #elif defined(USE_LN882X) return (addr >= (uint32_t) _stext && addr < (uint32_t) _etext) || (addr >= (uint32_t) __esphome_sram_text_start && addr < (uint32_t) __esphome_sram_text_end); @@ -279,8 +298,9 @@ extern "C" void __wrap_bk_trap_dabt(arm_registers *regs) { #ifdef USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH // The SDK's RAM vector table (VTOR) points the fault vectors at a handler that prints a register dump and then -// spins: a ROM handler on the RTL8720C, CmBacktrace (hal_interrupt.c) on the LN882H. crash_handler_install() -// puts esphome_fault_entry() in front of it, as the RTL8720C SDK's own CONFIG_MIIO patch (app_start.c) does. +// spins: a ROM handler on the RTL8720C and RTL8710B, CmBacktrace (hal_interrupt.c) on the LN882H. +// crash_handler_install() puts esphome_fault_entry() in front of it, as the RTL8720C SDK's own CONFIG_MIIO patch +// (app_start.c) does. static_assert(LT_HW_WATCHDOG, "the crash reboot relies on the hardware watchdog"); // The RTL8720C ROM dump takes about 0.4 s at 115200 baud; without the re-arm the reset waits for the 10 s // watchdog from arch_init(). @@ -291,6 +311,11 @@ static constexpr uint32_t FAULT_VECTOR_COUNT = 5; // and SecureFault (ARMv8-M) // DTCM, where the app's stacks and heap live (rtl8710c_ram.ld). static constexpr uint32_t SRAM_START = 0x10000000; static constexpr uint32_t SRAM_END = 0x1003FA00; +#elif defined(USE_LIBRETINY_VARIANT_RTL8710B) +static constexpr uint32_t FAULT_VECTOR_COUNT = 4; +// All of SRAM: the heap regions span it (heap_5.c) and MSP starts at 0x1003EFFC. +static constexpr uint32_t SRAM_START = 0x10000000; +static constexpr uint32_t SRAM_END = 0x10040000; #else static constexpr uint32_t FAULT_VECTOR_COUNT = 4; // RAM0, where the stacks and heap live (ln882hki_bsp.template.ld). @@ -306,7 +331,7 @@ static constexpr uintptr_t SCB_BFAR = 0xE000ED38; static constexpr uint32_t CFSR_MMARVALID = 1u << 7; static constexpr uint32_t CFSR_BFARVALID = 1u << 15; -// Exception frame: r0-r3, r12, lr, pc, xPSR, then the FPU registers when EXC_RETURN says so (LN882H only). +// Exception frame: r0-r3, r12, lr, pc, xPSR, then the FPU registers when EXC_RETURN says so (FPU chips). static constexpr uint32_t FRAME_LR = 5; static constexpr uint32_t FRAME_PC = 6; static constexpr uint32_t FRAME_XPSR = 7; diff --git a/esphome/components/libretiny/crash_handler.h b/esphome/components/libretiny/crash_handler.h index acfed61c097..a46422f83b2 100644 --- a/esphome/components/libretiny/crash_handler.h +++ b/esphome/components/libretiny/crash_handler.h @@ -6,7 +6,7 @@ #ifdef USE_LIBRETINY_CRASH_HANDLER -#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(USE_LN882X) +#if defined(USE_LIBRETINY_VARIANT_RTL8710B) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(USE_LN882X) // Cortex-M families: the recorder goes in front of the SDK's fault handlers through the RAM vector table. #define USE_LIBRETINY_CRASH_HANDLER_VECTOR_PATCH #endif diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c966f117dff..caaa2b83564 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -809,7 +809,7 @@ #endif #ifdef USE_LIBRETINY -#if defined(USE_BK72XX) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(USE_LN882X) +#if defined(USE_BK72XX) || defined(USE_RTL87XX) || defined(USE_LN882X) #define USE_LIBRETINY_CRASH_HANDLER #endif #define USE_BK72XX_BLE diff --git a/esphome/stacktrace.py b/esphome/stacktrace.py index 452206263e0..8fff3608c21 100644 --- a/esphome/stacktrace.py +++ b/esphome/stacktrace.py @@ -131,6 +131,10 @@ CRASH_HANDLER_ADDR_RE = re.compile( r"(?:PC|LR|BT\d):\s+(0x[0-9a-fA-F]{8})\s+\((?:fault location|return address|stack backtrace)\)" ) +# AmebaZ links each OTA slot at its own flash address, next to the main ELF. A report's addresses come from one +# slot, so the ELF that last resolved one is tried first. +_resolving_elf: dict[Path, Path] = {} + def _addr2line(tool: str, elf: Path, addr: str) -> str: # Lazy: __main__ imports this module on every CLI start @@ -176,9 +180,14 @@ def process_crash_handler_line( elf, ) return False - _LOGGER.error( - " %s => %s", addr, _addr2line(idedata.addr2line_path, elf, addr) - ) + first = _resolving_elf.get(elf, elf) + candidates = (elf, *sorted(elf.parent.glob(f"{elf.stem}.ota*.elf"))) + for candidate in (first, *(c for c in candidates if c != first)): + decoded = _addr2line(idedata.addr2line_path, candidate, addr) + if "?? ??:0" not in decoded and "(decode failed" not in decoded: + _resolving_elf[elf] = candidate + break + _LOGGER.error(" %s => %s", addr, decoded) # The addr2line hint is the last line of the report if "addr2line" in line: diff --git a/tests/component_tests/libretiny/test_crash_handler.py b/tests/component_tests/libretiny/test_crash_handler.py index 0c6f5893414..050f80f80d1 100644 --- a/tests/component_tests/libretiny/test_crash_handler.py +++ b/tests/component_tests/libretiny/test_crash_handler.py @@ -1,4 +1,4 @@ -"""The crash handler is built for BK7238, BK7231N (both with an ITCM linker region), RTL8720C and LN882H.""" +"""The crash handler is built for BK7238, BK7231N (both with an ITCM linker region), RTL8710B, RTL8720C and LN882H.""" from __future__ import annotations @@ -18,7 +18,7 @@ from esphome.core import CORE ("bk72xx.yaml", True, True), # BK7231N ("bk7231t.yaml", False, False), ("rtl8720c.yaml", True, False), # installed at run time - ("rtl8710b.yaml", False, False), + ("rtl8710b.yaml", True, False), # installed at run time ("ln882x.yaml", True, False), # installed at run time ], ) diff --git a/tests/unit_tests/test_stacktrace.py b/tests/unit_tests/test_stacktrace.py index 5653b5b6577..db633705440 100644 --- a/tests/unit_tests/test_stacktrace.py +++ b/tests/unit_tests/test_stacktrace.py @@ -634,6 +634,59 @@ def test_crash_report_address_is_decoded( assert "0x0001D6DC => main.cpp:12" in caplog.text +SLOT_PC_LINE = "[E][libretiny.crash:157]: PC: 0x08083164 (fault location)" +SLOT_LR_LINE = "[E][libretiny.crash:158]: LR: 0x08083155 (return address)" + + +def _slot_elfs(tmp_path: Path) -> None: + (tmp_path / "raw_firmware.ota1.elf").write_bytes(b"") + (tmp_path / "raw_firmware.ota2.elf").write_bytes(b"") + + +def test_crash_report_address_falls_back_to_the_ota_slot_elf( + tmp_path: Path, mock_get_idedata: Mock, caplog: pytest.LogCaptureFixture +) -> None: + # AmebaZ: a device running from OTA slot 2 reports addresses only its own link has + mock_get_idedata.return_value = _idedata(tmp_path) + _slot_elfs(tmp_path) + with patch.object( + stacktrace, + "_addr2line", + side_effect=[ + "0x08083164: ?? ??:0", + "0x08083164 (decode failed: bad ELF)", + "0x08083164: probe() at main.cpp:42", + "0x08083155: setup() at main.cpp:40", + ], + ) as decode: + assert stacktrace.process_crash_handler_line(CONFIG, SLOT_PC_LINE, True) + # The next address goes straight to the ELF that resolved the last one + assert stacktrace.process_crash_handler_line(CONFIG, SLOT_LR_LINE, True) + assert [c.args[1].name for c in decode.call_args_list] == [ + "raw_firmware.elf", + "raw_firmware.ota1.elf", + "raw_firmware.ota2.elf", + "raw_firmware.ota2.elf", + ] + assert "0x08083164 => 0x08083164: probe() at main.cpp:42" in caplog.text + assert "0x08083155 => 0x08083155: setup() at main.cpp:40" in caplog.text + + +def test_crash_report_address_no_elf_resolves( + tmp_path: Path, mock_get_idedata: Mock, caplog: pytest.LogCaptureFixture +) -> None: + mock_get_idedata.return_value = _idedata(tmp_path) + _slot_elfs(tmp_path) + with patch.object( + stacktrace, "_addr2line", return_value="0x08083164: ?? ??:0" + ) as decode: + assert stacktrace.process_crash_handler_line(CONFIG, SLOT_PC_LINE, True) + assert stacktrace.process_crash_handler_line(CONFIG, SLOT_PC_LINE, True) + # Every ELF is tried each time; a miss is not remembered + assert decode.call_count == 6 + assert "0x08083164 => 0x08083164: ?? ??:0" in caplog.text + + def test_crash_report_hint_line_closes_the_region(mock_get_idedata: Mock) -> None: assert not stacktrace.process_crash_handler_line(CONFIG, REPORT_HINT_LINE, True) From f5ab5febb44ee05199cffc44f78eb1eb3e80bd12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Nov 2026 16:39:40 -1000 Subject: [PATCH 132/170] [wifi] Keep manual IP settings in flash (#21708) --- esphome/components/wifi/__init__.py | 62 ++++++++++--------- esphome/components/wifi/wifi_component.cpp | 23 ++++--- esphome/components/wifi/wifi_component.h | 24 ++++--- .../wifi/wifi_component_esp_idf.cpp | 4 +- .../wifi/wifi_component_libretiny.cpp | 2 +- .../components/wifi/wifi_component_pico_w.cpp | 2 +- .../wifi/config/manual_ip.yaml | 32 ++++++++++ tests/component_tests/wifi/test_manual_ip.py | 25 ++++++++ tests/components/wifi/common.yaml | 5 ++ tests/components/wifi/test.esp8266-ard.yaml | 4 ++ 10 files changed, 136 insertions(+), 47 deletions(-) create mode 100644 tests/component_tests/wifi/config/manual_ip.yaml create mode 100644 tests/component_tests/wifi/test_manual_ip.py diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 08cc65f55ad..168cc6c0d68 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,3 +1,4 @@ +from ipaddress import IPv4Address import logging import math from typing import Any @@ -19,7 +20,6 @@ from esphome.components.network import ( add_use_address, get_network_priority, has_high_performance_wifi, - ip_address_literal, require_high_performance_wifi, require_ipv4, ) @@ -190,7 +190,6 @@ DEFAULT_DPP_TIMEOUT = "1min" wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") -ManualIP = wifi_ns.struct("ManualIP") WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component) WiFiAP = wifi_ns.struct("WiFiAP") @@ -804,24 +803,20 @@ def eap_auth(config): ) -def safe_ip(ip): - return ip_address_literal(ip) - - -def manual_ip(config): - if config is None: - return None - return cg.StructInitializer( - ManualIP, - ("static_ip", safe_ip(config[CONF_STATIC_IP])), - ("gateway", safe_ip(config[CONF_GATEWAY])), - ("subnet", safe_ip(config[CONF_SUBNET])), - ("dns1", safe_ip(config.get(CONF_DNS1))), - ("dns2", safe_ip(config.get(CONF_DNS2))), +def manual_ip_entry(config: ConfigType) -> bytes: + """Pack manual IP settings as one MANUAL_IP_TABLE row (MANUAL_IP_ENTRY_SIZE bytes).""" + no_dns = IPv4Address(0) + addresses = ( + config[CONF_STATIC_IP], + config[CONF_GATEWAY], + config[CONF_SUBNET], + config.get(CONF_DNS1, no_dns), + config.get(CONF_DNS2, no_dns), ) + return b"".join(address.packed for address in addresses) -def wifi_network(config, ap, static_ip): +def wifi_network(config, ap, manual_ip_index: int | None): cg.add_optional(ap.set_ssid, config.get(CONF_SSID)) cg.add_optional(ap.set_password, config.get(CONF_PASSWORD)) if CONF_EAP in config: @@ -831,8 +826,7 @@ def wifi_network(config, ap, static_ip): cg.add(ap.set_bssid([HexInt(i) for i in config[CONF_BSSID].parts])) cg.add_optional(ap.set_hidden, config.get(CONF_HIDDEN)) cg.add_optional(ap.set_channel, config.get(CONF_CHANNEL)) - if static_ip is not None: - cg.add(ap.set_manual_ip(manual_ip(static_ip))) + cg.add_optional(ap.set_manual_ip, manual_ip_index) # priority_ is 0 in C++; skip the setter when the config matches it. if config.get(CONF_PRIORITY, 0) != 0: cg.add(ap.set_priority(config[CONF_PRIORITY])) @@ -851,8 +845,17 @@ async def to_code(config): # Track if any network uses Enterprise authentication has_eap = False - # Track if any network uses manual IP - has_manual_ip = False + # Rows of MANUAL_IP_TABLE; networks with the same settings share one. At most + # MAX_WIFI_NETWORKS + 1 (the AP) rows, so an index never reaches MANUAL_IP_NONE (0xFF). + manual_ip_entries: list[bytes] = [] + + def manual_ip_index(ip_config: ConfigType | None) -> int | None: + if ip_config is None: + return None + entry = manual_ip_entry(ip_config) + if entry not in manual_ip_entries: + manual_ip_entries.append(entry) + return manual_ip_entries.index(entry) # Initialize FixedVector with the count of networks networks = config.get(CONF_NETWORKS, []) @@ -864,24 +867,20 @@ async def to_code(config): def add_sta(ap: cg.MockObj, network: dict) -> None: ip_config = network.get(CONF_MANUAL_IP, config.get(CONF_MANUAL_IP)) - cg.add(var.add_sta(wifi_network(network, ap, ip_config))) + cg.add(var.add_sta(wifi_network(network, ap, manual_ip_index(ip_config)))) for network in networks: if CONF_EAP in network: has_eap = True - if network.get(CONF_MANUAL_IP) or config.get(CONF_MANUAL_IP): - has_manual_ip = True cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network) if CONF_AP in config: conf = config[CONF_AP] - ip_config = conf.get(CONF_MANUAL_IP) - if ip_config: - has_manual_ip = True + ap_ip_index = manual_ip_index(conf.get(CONF_MANUAL_IP)) cg.with_local_variable( conf[CONF_ID], WiFiAP(), - lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), + lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ap_ip_index))), ) # Skip the setter when the config matches the C++ initializer. if (ap_timeout := conf[CONF_AP_TIMEOUT]) != DEFAULT_AP_TIMEOUT_PERIOD: @@ -939,8 +938,13 @@ async def to_code(config): require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP - if has_manual_ip: + if manual_ip_entries: cg.add_define("USE_WIFI_MANUAL_IP") + cg.extern_progmem_array( + "esphome::wifi::MANUAL_IP_TABLE", + cg.uint8, + list(b"".join(manual_ip_entries)), + ) # The C++ initializers are DEFAULT_REBOOT_TIMEOUT, power save NONE and minimum # auth WPA2; skip the setters when the config matches them. diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index ce6a2028184..f555d992a3a 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1358,17 +1358,15 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { ESP_LOGV(TAG, " Channel not set"); } #ifdef USE_WIFI_MANUAL_IP - auto manual_ip = ap.get_manual_ip(); - if (manual_ip.has_value()) { - ManualIP m = *manual_ip; + if (const auto m = ap.get_manual_ip(); m.has_value()) { char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE]; char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE]; char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE]; - ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf), - m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf), - m.dns2.str_to(dns2_buf)); + ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m->static_ip.str_to(static_ip_buf), + m->gateway.str_to(gateway_buf), m->subnet.str_to(subnet_buf), m->dns1.str_to(dns1_buf), + m->dns2.str_to(dns2_buf)); } else #endif { @@ -1906,7 +1904,7 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { #if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) // On ESP8266, GOT_IP event may not fire for static IP configurations, // so notify IP state listeners here as a fallback. - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip()) { this->notify_ip_state_listeners_(); } #endif @@ -2879,6 +2877,17 @@ void WiFiComponent::process_roaming_scan_() { this->start_connecting(roam_params); } +#ifdef USE_WIFI_MANUAL_IP +optional WiFiAP::get_manual_ip() const { + if (!this->has_manual_ip()) + return {}; + uint8_t b[MANUAL_IP_ENTRY_SIZE]; + progmem_memcpy(b, MANUAL_IP_TABLE + this->manual_ip_index_ * MANUAL_IP_ENTRY_SIZE, sizeof(b)); + auto ip = [&b](size_t i) { return network::IPAddress(b[i], b[i + 1], b[i + 2], b[i + 3]); }; + return ManualIP{ip(0), ip(4), ip(8), ip(12), ip(16)}; +} +#endif + WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) } // namespace esphome::wifi diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index c95afaa38ab..1201c02954b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -188,6 +188,14 @@ struct ManualIP { network::IPAddress dns2; ///< The second DNS server. 0.0.0.0 for default. }; +#ifdef USE_WIFI_MANUAL_IP +/// One MANUAL_IP_TABLE row: static IP, gateway, subnet, dns1 and dns2 as 4 octets each. +static constexpr size_t MANUAL_IP_ENTRY_SIZE = 20; +static constexpr uint8_t MANUAL_IP_NONE = 0xFF; +/// Every configured manual IP, generated by codegen (components/wifi/__init__.py) and kept in flash. +extern const uint8_t MANUAL_IP_TABLE[] PROGMEM; +#endif + #ifdef USE_WIFI_WPA2_EAP struct EAPAuth { std::string identity; // required for all auth types @@ -300,7 +308,8 @@ class WiFiAP { void clear_channel() { this->channel_ = 0; } void set_priority(int8_t priority) { priority_ = priority; } #ifdef USE_WIFI_MANUAL_IP - void set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } + /// index is a row of MANUAL_IP_TABLE + void set_manual_ip(uint8_t index) { this->manual_ip_index_ = index; } #endif void set_hidden(bool hidden) { this->hidden_ = hidden; } StringRef get_ssid() const { return this->ssid_.ref(); } @@ -314,7 +323,8 @@ class WiFiAP { bool has_channel() const { return this->channel_ != 0; } int8_t get_priority() const { return priority_; } #ifdef USE_WIFI_MANUAL_IP - const optional &get_manual_ip() const { return this->manual_ip_; } + bool has_manual_ip() const { return this->manual_ip_index_ != MANUAL_IP_NONE; } + optional get_manual_ip() const; #endif bool get_hidden() const { return this->hidden_; } @@ -324,14 +334,14 @@ class WiFiAP { #ifdef USE_WIFI_WPA2_EAP optional eap_; #endif // USE_WIFI_WPA2_EAP -#ifdef USE_WIFI_MANUAL_IP - optional manual_ip_; -#endif // Group small types together to minimize padding bssid_t bssid_{}; // 6 bytes, all zeros = any/not set uint8_t channel_{0}; // 1 byte, 0 = auto/not set int8_t priority_{0}; // 1 byte - bool hidden_{false}; // 1 byte; WiFiAP is byte aligned unless manual IP or EAP adds a 4 byte aligned member + bool hidden_{false}; // 1 byte; WiFiAP is byte aligned unless EAP adds a 4 byte aligned member +#ifdef USE_WIFI_MANUAL_IP + uint8_t manual_ip_index_{MANUAL_IP_NONE}; +#endif }; class WiFiScanResult { @@ -1051,7 +1061,7 @@ class WiFiComponent final : public Component { uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py - // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the + // WiFiAP is byte aligned unless EAP is enabled; placed before the // 1-byte members so they pack into its trailing bytes instead of padding after it WiFiAP ap_; #endif // USE_WIFI_AP diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 01dc3f6d0c7..b6cd4832899 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -819,7 +819,7 @@ void WiFiComponent::save_lease_settings_() { return; #ifdef USE_WIFI_MANUAL_IP - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) + if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip()) return; #endif @@ -1125,7 +1125,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { bool static_ip = false; #ifdef USE_WIFI_MANUAL_IP const WiFiAP *config = this->get_selected_sta_(); - static_ip = config && config->get_manual_ip().has_value(); + static_ip = config && config->has_manual_ip(); #endif #ifdef USE_WIFI_LEASE_CACHE static_ip = static_ip || this->applied_cached_lease_; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 85b58e904de..31f86248d90 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -523,7 +523,7 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { #endif // For static IP configurations, GOT_IP event may not fire, so set connected state here #ifdef USE_WIFI_MANUAL_IP - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip()) { this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTED); #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 0c93100dd16..e0a56d51137 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -342,7 +342,7 @@ bool WiFiComponent::wifi_loop_() { #endif // For static IP configurations, notify IP listeners immediately as the IP is already configured #if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_manual_ip()) { s_sta_had_ip = true; this->notify_ip_state_listeners_(); } diff --git a/tests/component_tests/wifi/config/manual_ip.yaml b/tests/component_tests/wifi/config/manual_ip.yaml new file mode 100644 index 00000000000..e0600f02465 --- /dev/null +++ b/tests/component_tests/wifi/config/manual_ip.yaml @@ -0,0 +1,32 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + # Inherited by every network without its own manual_ip + manual_ip: + static_ip: 192.168.2.20 + gateway: 192.168.2.1 + subnet: 255.255.255.0 + dns1: 192.168.2.1 + networks: + - ssid: own + password: password1 + manual_ip: + static_ip: 192.168.1.10 + gateway: 192.168.1.1 + subnet: 255.255.255.0 + dns1: 1.1.1.1 + dns2: 8.8.8.8 + - ssid: inherits1 + password: password2 + - ssid: inherits2 + password: password3 + ap: + password: fallback_password + manual_ip: + static_ip: 192.168.4.1 + gateway: 192.168.4.1 + subnet: 255.255.255.0 diff --git a/tests/component_tests/wifi/test_manual_ip.py b/tests/component_tests/wifi/test_manual_ip.py new file mode 100644 index 00000000000..ecf84d15858 --- /dev/null +++ b/tests/component_tests/wifi/test_manual_ip.py @@ -0,0 +1,25 @@ +"""Tests for the manual IP table wifi codegen emits (MANUAL_IP_TABLE in wifi_component.h).""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_manual_ip_rows_are_shared( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Networks with the same settings share a row; the AP row packs its missing DNS as zeros.""" + main_cpp = generate_main(component_config_path("manual_ip.yaml")) + + own = "192, 168, 1, 10, 192, 168, 1, 1, 255, 255, 255, 0, 1, 1, 1, 1, 8, 8, 8, 8" + inherited = ( + "192, 168, 2, 20, 192, 168, 2, 1, 255, 255, 255, 0, 192, 168, 2, 1, 0, 0, 0, 0" + ) + ap = "192, 168, 4, 1, 192, 168, 4, 1, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0" + assert ( + f"esphome::wifi::MANUAL_IP_TABLE[] PROGMEM = {{{own}, {inherited}, {ap}}};" + in main_cpp + ) + # Three networks in config order, then the AP + assert re.findall(r"set_manual_ip\((\d+)\)", main_cpp) == ["0", "1", "1", "2"] diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index e254671fac6..82a9458b612 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -42,6 +42,11 @@ wifi: - ssid: MySSID2 password: password2 priority: 5 + manual_ip: + static_ip: 192.168.2.100 + gateway: 192.168.2.1 + subnet: 255.255.255.0 + dns1: 192.168.2.1 - ssid: MySSID3 password: password3 priority: 0 diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index 6b552c33da8..1ddcbf7e013 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -5,6 +5,10 @@ wifi: ap: ssid: Fallback AP password: fallback_password + manual_ip: + static_ip: 192.168.4.1 + gateway: 192.168.4.1 + subnet: 255.255.255.0 packages: - !include common.yaml From ff254ae9d893fa40a724c763b68dbe770ff05eb1 Mon Sep 17 00:00:00 2001 From: Petapton <14984292+Petapton@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:00:39 +0100 Subject: [PATCH 133/170] [daikin_madoka] Add new Daikin Madoka integration (#5951) --- CODEOWNERS | 1 + esphome/components/daikin_madoka/__init__.py | 2 + esphome/components/daikin_madoka/climate.py | 44 ++ .../daikin_madoka/daikin_madoka.cpp | 432 ++++++++++++++++++ .../components/daikin_madoka/daikin_madoka.h | 101 ++++ .../daikin_madoka/daikin_madoka_protocol.h | 173 +++++++ script/analyze_component_buses.py | 1 + tests/components/daikin_madoka/common.yaml | 14 + .../daikin_madoka/daikin_madoka_protocol.cpp | 285 ++++++++++++ .../daikin_madoka/test.esp32-idf.yaml | 2 + 10 files changed, 1055 insertions(+) create mode 100644 esphome/components/daikin_madoka/__init__.py create mode 100644 esphome/components/daikin_madoka/climate.py create mode 100644 esphome/components/daikin_madoka/daikin_madoka.cpp create mode 100644 esphome/components/daikin_madoka/daikin_madoka.h create mode 100644 esphome/components/daikin_madoka/daikin_madoka_protocol.h create mode 100644 tests/components/daikin_madoka/common.yaml create mode 100644 tests/components/daikin_madoka/daikin_madoka_protocol.cpp create mode 100644 tests/components/daikin_madoka/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9fe56375e88..9bb0f23c290 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -154,6 +154,7 @@ esphome/components/d01/* @ch604 esphome/components/dac7678/* @NickB1 esphome/components/daikin_arc/* @MagicBear esphome/components/daikin_brc/* @hagak +esphome/components/daikin_madoka/* @Petapton esphome/components/dallas_temp/* @ssieb esphome/components/daly_bms/* @s1lvi0 esphome/components/dashboard_import/* @esphome/core diff --git a/esphome/components/daikin_madoka/__init__.py b/esphome/components/daikin_madoka/__init__.py new file mode 100644 index 00000000000..10ff61ca983 --- /dev/null +++ b/esphome/components/daikin_madoka/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@Petapton"] +DOMAIN = "daikin_madoka" diff --git a/esphome/components/daikin_madoka/climate.py b/esphome/components/daikin_madoka/climate.py new file mode 100644 index 00000000000..09961073c28 --- /dev/null +++ b/esphome/components/daikin_madoka/climate.py @@ -0,0 +1,44 @@ +import esphome.codegen as cg +from esphome.components import ble_client, climate, esp32_ble +import esphome.config_validation as cv +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@Petapton"] +DEPENDENCIES = ["ble_client"] + +daikin_madoka_ns = cg.esphome_ns.namespace("daikin_madoka") +DaikinMadoka = daikin_madoka_ns.class_( + "DaikinMadoka", climate.Climate, ble_client.BLEClientNode, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + climate.climate_schema(DaikinMadoka) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("10s")) +) + +# IO capabilities that can confirm a numeric comparison pairing +_NUMERIC_COMPARISON_CAPABILITIES = ("display_yes_no", "keyboard_display") + + +def _final_validate(config: ConfigType) -> ConfigType: + # Pairing uses numeric comparison (NC_REQ). The esp32_ble default ("none") + # cannot satisfy the MITM encryption request and fails silently at runtime. + io_capability = fv.full_config.get()[esp32_ble.DOMAIN][esp32_ble.CONF_IO_CAPABILITY] + if io_capability not in _NUMERIC_COMPARISON_CAPABILITIES: + raise cv.Invalid( + "daikin_madoka requires MITM-capable BLE pairing (numeric comparison), " + "set 'esp32_ble' 'io_capability' to 'display_yes_no' or 'keyboard_display' " + f"(found '{io_capability}')" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) diff --git a/esphome/components/daikin_madoka/daikin_madoka.cpp b/esphome/components/daikin_madoka/daikin_madoka.cpp new file mode 100644 index 00000000000..58f1e858de1 --- /dev/null +++ b/esphome/components/daikin_madoka/daikin_madoka.cpp @@ -0,0 +1,432 @@ +#include "daikin_madoka.h" + +#include +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome::daikin_madoka { + +using namespace esphome::climate; + +ESPHOME_LOG_TAG(TAG, "daikin_madoka"); + +static constexpr uint16_t CMD_GET_SETTING_STATUS = 0x0020; +static constexpr uint16_t CMD_SET_SETTING_STATUS = 0x4020; +static constexpr uint16_t CMD_GET_OPERATION_MODE = 0x0030; +static constexpr uint16_t CMD_SET_OPERATION_MODE = 0x4030; +static constexpr uint16_t CMD_GET_SETPOINT = 0x0040; +static constexpr uint16_t CMD_SET_SETPOINT = 0x4040; +static constexpr uint16_t CMD_GET_FAN_SPEED = 0x0050; +static constexpr uint16_t CMD_SET_FAN_SPEED = 0x4050; +static constexpr uint16_t CMD_GET_SENSOR_INFORMATION = 0x0110; +static constexpr uint16_t CMD_SET_FLAG = 0x4000; + +// Indexed by the device's operation mode byte +static constexpr std::array DEVICE_MODES{CLIMATE_MODE_FAN_ONLY, CLIMATE_MODE_DRY, + CLIMATE_MODE_HEAT_COOL, CLIMATE_MODE_COOL, CLIMATE_MODE_HEAT}; +static constexpr uint8_t DEVICE_MODE_HEAT = 4; +// Publishes the parsed state when the poll's last reply, SENSOR_INFORMATION, does not arrive +static constexpr uint32_t PUBLISH_DELAY_MS = 800; + +void DaikinMadoka::dump_config() { LOG_CLIMATE(TAG, "Daikin Madoka Climate Controller", this); } + +// Replies arrive in order, so the last one, SENSOR_INFORMATION, ends the poll and publishes at once +static constexpr std::array POLL_CMDS{CMD_GET_SETTING_STATUS, CMD_GET_OPERATION_MODE, CMD_GET_SETPOINT, + CMD_GET_FAN_SPEED, CMD_GET_SENSOR_INFORMATION}; +static constexpr std::array NO_ARGS{0x00, 0x00}; + +static uint32_t get_command_cooldown(uint16_t cmd) { + switch (cmd) { + case CMD_GET_SETTING_STATUS: + case CMD_GET_OPERATION_MODE: + case CMD_GET_SETPOINT: + case CMD_GET_FAN_SPEED: + case CMD_GET_SENSOR_INFORMATION: + return 50; + case CMD_SET_SETTING_STATUS: + return 200; + case CMD_SET_OPERATION_MODE: + return 600; + case CMD_SET_SETPOINT: + return 400; + case CMD_SET_FAN_SPEED: + return 200; + default: + return 100; + } +} + +void DaikinMadoka::control(const ClimateCall &call) { + if (this->node_state != espbt::ClientState::ESTABLISHED) { + this->publish_state(); // Update state to reflect that the device is disconnected + return; + } + + auto mode_opt = call.get_mode(); + if (mode_opt.has_value()) { + const ClimateMode mode = mode_opt.value(); + if (mode == CLIMATE_MODE_OFF) { + this->enqueue_query_(CMD_SET_SETTING_STATUS, std::array{0x20, 0x01, 0}); + } else { + const auto *it = std::find(DEVICE_MODES.begin(), DEVICE_MODES.end(), mode); + if (it == DEVICE_MODES.end()) { + ESP_LOGW(TAG, "Unsupported mode: %d", mode); + } else { + const auto device_mode = static_cast(it - DEVICE_MODES.begin()); + this->enqueue_query_(CMD_SET_OPERATION_MODE, std::array{0x20, 0x01, device_mode}); + this->enqueue_query_(CMD_SET_SETTING_STATUS, std::array{0x20, 0x01, 1}); + } + } + } + QueryArgs setpoint_args; + if (auto high = call.get_target_temperature_high(); high.has_value()) { + append_setpoint(setpoint_args, 0x20, *high); + } + if (auto low = call.get_target_temperature_low(); low.has_value()) { + append_setpoint(setpoint_args, 0x21, *low); + } + if (!setpoint_args.empty()) { + this->enqueue_query_(CMD_SET_SETPOINT, setpoint_args); + } + auto fan_mode_opt = call.get_fan_mode(); + if (fan_mode_opt.has_value()) { + uint8_t fan_mode = fan_mode_opt.value(); + uint8_t fan_mode_out = 255; + switch (fan_mode) { + case climate::CLIMATE_FAN_AUTO: + fan_mode_out = 0; + break; + case climate::CLIMATE_FAN_LOW: + fan_mode_out = 1; + break; + case climate::CLIMATE_FAN_MEDIUM: + fan_mode_out = 3; + break; + case climate::CLIMATE_FAN_HIGH: + fan_mode_out = 5; + break; + default: + ESP_LOGW(TAG, "Unsupported fan mode: %d", fan_mode); + break; + } + if (fan_mode_out != 255) { + // Sets the cooling (0x20) and heating (0x21) fan speeds together + this->enqueue_query_(CMD_SET_FAN_SPEED, + std::array{0x20, 0x01, fan_mode_out, 0x21, 0x01, fan_mode_out}); + } + } + this->update(); +} + +void DaikinMadoka::fail_connection_(const LogString *reason) { + ESP_LOGW(TAG, "[%s] %s, disconnecting", this->parent_->address_str(), LOG_STR_ARG(reason)); + this->status_set_warning(reason); + this->parent_->disconnect(); +} + +void DaikinMadoka::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_NC_REQ_EVT: + if (!this->parent()->check_addr(param->ble_security.key_notif.bd_addr)) + return; + esp_ble_confirm_reply(param->ble_security.key_notif.bd_addr, true); + ESP_LOGI(TAG, "ESP_GAP_BLE_NC_REQ_EVT, the passkey Notify number: %06" PRIu32, + param->ble_security.key_notif.passkey); + break; + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!this->parent()->check_addr(param->ble_security.auth_cmpl.bd_addr)) + return; + if (!param->ble_security.auth_cmpl.success) { + ESP_LOGW(TAG, "Authentication failed, reason: 0x%x", param->ble_security.auth_cmpl.fail_reason); + this->fail_connection_(LOG_STR("Pairing failed")); + break; + } + auto *nfy = this->parent_->get_characteristic(MADOKA_SERVICE_UUID, NOTIFY_CHARACTERISTIC_UUID); + auto *wwr = this->parent_->get_characteristic(MADOKA_SERVICE_UUID, WWR_CHARACTERISTIC_UUID); + if (nfy == nullptr || wwr == nullptr) { + this->fail_connection_(LOG_STR("Madoka control service not found")); + break; + } + this->notify_handle_ = nfy->handle; + this->wwr_handle_ = wwr->handle; + + if (this->parent_->register_for_notify(nfy->handle) != ESP_OK) { + this->fail_connection_(LOG_STR("Notify registration failed")); + } + break; + } + default: + break; + } +} + +void DaikinMadoka::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + this->notify_handle_ = 0; + this->wwr_handle_ = 0; + + this->current_temperature = NAN; + this->target_temperature_high = NAN; + this->target_temperature_low = NAN; + this->mode = climate::CLIMATE_MODE_OFF; + this->fan_mode.reset(); + this->publish_state(); + + this->assembler_.reset(); + this->cancel_timeout(QUERY_TIMEOUT_ID); + this->cancel_timeout(PUBLISH_TIMEOUT_ID); + this->query_cooldown_ = false; + this->query_queue_.clear(); + this->poll_pending_ = false; + this->get_in_flight_ = false; + break; + } + case ESP_GATTC_WRITE_DESCR_EVT: + // The only descriptor written is the notification one, so polls get no replies without it + if (param->write.status != ESP_GATT_OK) { + ESP_LOGE(TAG, "Failed writing the notification descriptor, status = 0x%x", param->write.status); + this->fail_connection_(LOG_STR("Notification descriptor write failed")); + } + break; + case ESP_GATTC_SEARCH_CMPL_EVT: { + if (esp_ble_set_encryption(this->parent_->get_remote_bda(), ESP_BLE_SEC_ENCRYPT_MITM) != ESP_OK) { + this->fail_connection_(LOG_STR("Encryption request failed")); + } + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + if (param->reg_for_notify.handle != this->notify_handle_) { + break; + } + if (param->reg_for_notify.status != ESP_GATT_OK) { + this->fail_connection_(LOG_STR("Notify registration failed")); + break; + } + this->node_state = espbt::ClientState::ESTABLISHED; + this->status_clear_warning(); + this->update(); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.handle != this->notify_handle_) { + ESP_LOGW(TAG, "Different notify handle"); + break; + } + this->process_incoming_chunk_(std::span{param->notify.value, param->notify.value_len}); + break; + } + default: + break; + } +} + +void DaikinMadoka::update() { + ESP_LOGV(TAG, "Got update request..."); + if (this->node_state != espbt::ClientState::ESTABLISHED) { + ESP_LOGV(TAG, "...but device is disconnected"); + return; + } + this->poll_pending_ = true; + if (!this->query_cooldown_) + this->send_next_query_(); +} + +void DaikinMadoka::process_incoming_chunk_(std::span chunk) { + switch (this->assembler_.feed(chunk)) { + case ChunkResult::CHUNK_RESULT_COMPLETE: + this->parse_cb_(this->assembler_.message()); + break; + case ChunkResult::CHUNK_RESULT_INVALID_LENGTH: + ESP_LOGW(TAG, "Chunk discarded: invalid length"); + break; + case ChunkResult::CHUNK_RESULT_INVALID_ID: + ESP_LOGW(TAG, "Chunk discarded: invalid chunk id %u", chunk[0]); + break; + case ChunkResult::CHUNK_RESULT_TOO_LONG: + ESP_LOGW(TAG, "Message discarded: too long"); + break; + case ChunkResult::CHUNK_RESULT_PARTIAL: + break; + } +} + +void DaikinMadoka::enqueue_query_(uint16_t cmd, std::span args) { + Query *same = nullptr; + Query *mode_behind = nullptr; + for (Query &queued : this->query_queue_) { + if (queued.cmd == cmd) { + same = &queued; + } else if (cmd == CMD_SET_SETTING_STATUS && queued.cmd == CMD_SET_OPERATION_MODE && same != nullptr) { + mode_behind = &queued; + } + } + // A command queued again keeps its one entry with the newest values, so the backlog stays bounded + if (same != nullptr) { + // Power-on has to follow the mode queued with it, so a mode queued behind an older status + // entry takes that entry's place + if (mode_behind != nullptr) { + std::swap(*same, *mode_behind); + same = mode_behind; + } + if (merge_args(same->args, args)) + return; + } + Query query{.cmd = cmd, .args = {}}; + query.args.resize(args.size()); + std::copy(args.begin(), args.end(), query.args.begin()); + if (!this->query_queue_.push(query)) { + ESP_LOGW(TAG, "Query queue full, dropping command 0x%04X", cmd); + } +} + +void DaikinMadoka::send_next_query_() { + // BLE being disabled resets the client without a disconnect event, so the queue stops here + if (this->node_state != espbt::ClientState::ESTABLISHED) { + this->query_queue_.clear(); + this->query_cooldown_ = false; + this->poll_pending_ = false; + this->get_in_flight_ = false; + return; + } + if (this->query_queue_.empty()) { + if (!this->poll_pending_) { + this->query_cooldown_ = false; + return; + } + this->poll_pending_ = false; + // Chunks left from a partly lost reply must not combine with this batch's replies + this->assembler_.reset(); + for (auto cmd : POLL_CMDS) { + this->enqueue_query_(cmd, NO_ARGS); + } + } + const Query &query = this->query_queue_.front(); + const uint16_t cmd = query.cmd; + const bool is_set = (cmd & CMD_SET_FLAG) != 0; + // A GET's reply may still be arriving when the next command goes out, so a SET resets only when none is + if (is_set && !this->get_in_flight_) + this->assembler_.reset(); + // On a failed write the query stays queued for the next pass + if (this->write_query_(query)) { + this->query_queue_.pop(); + this->get_in_flight_ = !is_set; + } + this->query_cooldown_ = true; + this->set_timeout(QUERY_TIMEOUT_ID, get_command_cooldown(cmd), [this]() { this->send_next_query_(); }); +} + +bool DaikinMadoka::write_query_(const Query &query) { + static_assert(MAX_QUERY_ARGS + MESSAGE_HEADER_SIZE <= MAX_CHUNK_SIZE - 1, "A query must fit in one chunk"); + // Chunk id 0, then the message: length, reserved byte, command, args + std::array frame; + const uint8_t msg_len = MESSAGE_HEADER_SIZE + query.args.size(); + const auto cmd = decode_value(query.cmd); + frame[0] = 0; + frame[1] = msg_len; + frame[2] = 0; + frame[3] = cmd[0]; + frame[4] = cmd[1]; + std::copy(query.args.begin(), query.args.end(), frame.begin() + 1 + MESSAGE_HEADER_SIZE); + + ESP_LOGV(TAG, "Writing query. ID: 0x%04X, len: %u", query.cmd, msg_len + 1); + ESP_LOGV_HEX(TAG, frame.data(), msg_len + 1, MAX_CHUNK_SIZE, "%s"); + + esp_err_t err = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->wwr_handle_, + msg_len + 1, frame.data(), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (err != ESP_OK) { + ESP_LOGW(TAG, "[%s] Writing command 0x%04X failed, status=%d", this->parent_->address_str(), query.cmd, err); + return false; + } + return true; +} + +void DaikinMadoka::update_mode_from_status_() { + if (!this->power_on_) { + this->mode = CLIMATE_MODE_OFF; + } else if (this->device_mode_ < DEVICE_MODES.size()) { + this->mode = DEVICE_MODES[this->device_mode_]; + } +} + +void DaikinMadoka::parse_cb_(std::span msg) { + if (!is_valid_message(msg)) { + ESP_LOGW(TAG, "Discarding message: invalid length"); + return; + } + + ESP_LOGV(TAG, "Parsing message. ID: 0x%04X, len: %u", message_function_id(msg), msg.size()); + ESP_LOGV_HEX(TAG, msg.data(), msg.size(), 256, "%s"); + + switch (message_function_id(msg)) { + case CMD_GET_SETTING_STATUS: { + if (auto value = find_arg(msg, 0x20); value.size() == 1) { + this->power_on_ = value[0] == 1; + this->update_mode_from_status_(); + } + break; + } + case CMD_GET_OPERATION_MODE: { + if (auto value = find_arg(msg, 0x20); value.size() == 1) { + this->device_mode_ = value[0]; + this->update_mode_from_status_(); + } + break; + } + case CMD_GET_SETPOINT: { + if (auto high = find_arg(msg, 0x20); high.size() == 2) { + this->target_temperature_high = decode_setpoint(high); + } + if (auto low = find_arg(msg, 0x21); low.size() == 2) { + this->target_temperature_low = decode_setpoint(low); + } + break; + } + case CMD_GET_FAN_SPEED: { + // Heat mode uses the heating fan speed (0x21); the other modes use the cooling one (0x20) + auto value = find_arg(msg, this->device_mode_ == DEVICE_MODE_HEAT ? 0x21 : 0x20); + if (value.size() != 1) { + break; + } + switch (value[0]) { + case 0: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case 1: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case 2: + case 3: + case 4: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case 5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + default: + break; + } + break; + } + case CMD_GET_SENSOR_INFORMATION: { + if (auto value = find_arg(msg, 0x40); value.size() == 1) { + this->current_temperature = static_cast(value[0]); + } + this->cancel_timeout(PUBLISH_TIMEOUT_ID); + this->publish_state(); + return; + } + default: + return; + } + this->set_timeout(PUBLISH_TIMEOUT_ID, PUBLISH_DELAY_MS, [this]() { this->publish_state(); }); +} + +} // namespace esphome::daikin_madoka + +#endif diff --git a/esphome/components/daikin_madoka/daikin_madoka.h b/esphome/components/daikin_madoka/daikin_madoka.h new file mode 100644 index 00000000000..b4e7d7977c6 --- /dev/null +++ b/esphome/components/daikin_madoka/daikin_madoka.h @@ -0,0 +1,101 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include + +#include + +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/climate/climate.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "daikin_madoka_protocol.h" + +namespace esphome::daikin_madoka { + +namespace espbt = esphome::esp32_ble_tracker; + +// Deepest backlog is one poll (5) plus each SET command once (4): a command queued again replaces +// its queued entry, and the readback poll is only queued once the queue has drained +static constexpr uint8_t QUERY_QUEUE_SIZE = 9; + +// BLE service and characteristic UUIDs +static const espbt::ESPBTUUID MADOKA_SERVICE_UUID = espbt::ESPBTUUID::from_raw("2141e110-213a-11e6-b67b-9e71128cae77"); +static const espbt::ESPBTUUID NOTIFY_CHARACTERISTIC_UUID = + espbt::ESPBTUUID::from_raw("2141e111-213a-11e6-b67b-9e71128cae77"); +static const espbt::ESPBTUUID WWR_CHARACTERISTIC_UUID = + espbt::ESPBTUUID::from_raw("2141e112-213a-11e6-b67b-9e71128cae77"); + +struct Query { + uint16_t cmd; + QueryArgs args; +}; + +class DaikinMadoka final : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { + public: + void update() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + void dump_config() override; + climate::ClimateTraits traits() override { + auto traits = climate::ClimateTraits(); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_DRY, + }); + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO, + }); + traits.set_visual_min_temperature(MIN_TEMP); + traits.set_visual_max_temperature(MAX_TEMP); + traits.set_visual_temperature_step(1); + // control() only takes the low/high pair, so a single setpoint must not be offered in HEAT or COOL + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | + climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE | + climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + return traits; + } + + protected: + void control(const climate::ClimateCall &call) override; + + void enqueue_query_(uint16_t cmd, std::span args); + void send_next_query_(); + bool write_query_(const Query &query); + void process_incoming_chunk_(std::span chunk); + void parse_cb_(std::span msg); + void update_mode_from_status_(); + void fail_connection_(const LogString *reason); + + StaticRingBuffer query_queue_; + MessageAssembler assembler_; + uint16_t notify_handle_{0}; + uint16_t wwr_handle_{0}; + uint8_t device_mode_{0}; + bool power_on_{false}; + // True while the query timeout is armed to pace the next send + bool query_cooldown_{false}; + // A poll waits for the queue to drain, so it runs after any SET command ahead of it + bool poll_pending_{false}; + // The last command written was a GET, so its reply may still be arriving + bool get_in_flight_{false}; + + static constexpr uint32_t QUERY_TIMEOUT_ID = 0; + static constexpr uint32_t PUBLISH_TIMEOUT_ID = 1; +}; + +} // namespace esphome::daikin_madoka + +#endif diff --git a/esphome/components/daikin_madoka/daikin_madoka_protocol.h b/esphome/components/daikin_madoka/daikin_madoka_protocol.h new file mode 100644 index 00000000000..6dd8a876644 --- /dev/null +++ b/esphome/components/daikin_madoka/daikin_madoka_protocol.h @@ -0,0 +1,173 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "esphome/core/helpers.h" + +// Wire format helpers kept free of ESP-IDF so they can be unit tested on the host. +namespace esphome::daikin_madoka { + +static constexpr uint8_t MAX_CHUNK_SIZE = 20; +// A message's leading length byte is a uint8_t, so a full message is at most 255 bytes +static constexpr uint16_t MAX_MESSAGE_SIZE = 255; +// Each notification is a chunk id byte followed by up to 19 message bytes +static constexpr uint8_t MAX_CHUNK_PAYLOAD = MAX_CHUNK_SIZE - 1; +static constexpr uint8_t MAX_CHUNKS = (MAX_MESSAGE_SIZE + MAX_CHUNK_PAYLOAD - 1) / MAX_CHUNK_PAYLOAD; +// Longest args payload is a two-point setpoint: {0x20,0x02,hi,lo,0x21,0x02,hi,lo} +static constexpr uint8_t MAX_QUERY_ARGS = 8; +// Length byte, reserved byte and two command bytes precede the args +static constexpr uint8_t MESSAGE_HEADER_SIZE = 4; +// A record length of 0xFF marks an argument with no value +static constexpr uint8_t EMPTY_ARG_LENGTH = 0xFF; + +static constexpr float MIN_TEMP = 16.0f; +static constexpr float MAX_TEMP = 32.0f; +// Setpoints are sent as fixed point with 7 fractional bits +static constexpr float SETPOINT_SCALE = 128.0f; + +enum class ChunkResult : uint8_t { + CHUNK_RESULT_PARTIAL, + CHUNK_RESULT_COMPLETE, + CHUNK_RESULT_INVALID_LENGTH, + CHUNK_RESULT_INVALID_ID, + CHUNK_RESULT_TOO_LONG, +}; + +/// Reassembles notifications (one chunk id byte followed by payload) into a message. Chunks may +/// arrive in any order: each is kept in the slot of its id, and the message is complete once chunk 0 +/// is in and the chunks from 0 on add up to its length byte. +/// After CHUNK_RESULT_COMPLETE, message() holds the message until reset() or the next feed(). +class MessageAssembler { + public: + ChunkResult feed(std::span chunk) { + if (chunk.size() < 2) + return ChunkResult::CHUNK_RESULT_INVALID_LENGTH; + if (this->message_size_ != 0) + this->reset(); + const uint8_t chunk_id = chunk[0]; + const auto payload = chunk.subspan(1); + if (chunk_id >= MAX_CHUNKS) + return ChunkResult::CHUNK_RESULT_INVALID_ID; + if (payload.size() > MAX_CHUNK_PAYLOAD) { + this->reset(); + return ChunkResult::CHUNK_RESULT_TOO_LONG; + } + std::copy(payload.begin(), payload.end(), this->data_.begin() + chunk_id * MAX_CHUNK_PAYLOAD); + this->chunk_sizes_[chunk_id] = payload.size(); + if (this->chunk_sizes_[0] == 0) + return ChunkResult::CHUNK_RESULT_PARTIAL; + + const uint8_t length = this->data_[0]; + size_t total = this->chunk_sizes_[0]; + uint8_t count = 1; + while (total < length && count < MAX_CHUNKS && this->chunk_sizes_[count] != 0) + total += this->chunk_sizes_[count++]; + if (total > length) { + this->reset(); + return ChunkResult::CHUNK_RESULT_TOO_LONG; + } + if (total < length) + return ChunkResult::CHUNK_RESULT_PARTIAL; + // Close the gaps left by short chunks; each one only moves towards the start + size_t pos = this->chunk_sizes_[0]; + for (uint8_t i = 1; i < count; i++) { + std::copy_n(this->data_.begin() + i * MAX_CHUNK_PAYLOAD, this->chunk_sizes_[i], this->data_.begin() + pos); + pos += this->chunk_sizes_[i]; + } + this->message_size_ = length; + return ChunkResult::CHUNK_RESULT_COMPLETE; + } + + std::span message() const { return {this->data_.data(), this->message_size_}; } + + void reset() { + this->chunk_sizes_.fill(0); + this->message_size_ = 0; + } + + protected: + std::array data_; + // Payload bytes held for each chunk id, 0 while that chunk is missing + std::array chunk_sizes_{}; + uint8_t message_size_{0}; +}; + +/// True when the length byte matches and the header (length, reserved, function id) is present. +inline bool is_valid_message(std::span msg) { + return msg.size() >= MESSAGE_HEADER_SIZE && msg[0] == msg.size(); +} + +inline uint16_t message_function_id(std::span msg) { return encode_uint16(msg[2], msg[3]); } + +/// Returns the value of argument `id`, or an empty span when it is absent, has no value or the records are truncated. +inline std::span find_arg(std::span msg, uint8_t id) { + size_t i = MESSAGE_HEADER_SIZE; + while (i + 2 <= msg.size()) { + const uint8_t arg_id = msg[i]; + const uint8_t len = msg[i + 1] == EMPTY_ARG_LENGTH ? 0 : msg[i + 1]; + i += 2; + if (len > msg.size() - i) + return {}; + if (arg_id == id) + return msg.subspan(i, len); + i += len; + } + return {}; +} + +using QueryArgs = StaticVector; + +/// Merges the records {id, len, value...} of `incoming` into `args`: a record whose id is already +/// present is replaced in place, a new one is appended. False, with `args` unchanged, when a record +/// does not fit or the two records of one id differ in length. +inline bool merge_args(QueryArgs &args, std::span incoming) { + QueryArgs merged = args; + size_t i = 0; + while (i + 2 <= incoming.size()) { + const uint8_t id = incoming[i]; + const uint8_t len = incoming[i + 1]; + i += 2; + if (len > incoming.size() - i) + return false; + const auto value = incoming.subspan(i, len); + i += len; + size_t j = 0; + while (j + 2 <= merged.size() && merged[j] != id) + j += 2 + merged[j + 1]; + if (j + 2 <= merged.size()) { + if (merged[j + 1] != len) + return false; + std::copy(value.begin(), value.end(), merged.begin() + j + 2); + continue; + } + if (merged.size() + 2 + len > QueryArgs::capacity()) + return false; + merged.push_back(id); + merged.push_back(len); + for (uint8_t byte : value) + merged.push_back(byte); + } + args = merged; + return true; +} + +/// Appends a setpoint record {id, 2, msb, lsb}, clamped to the supported range. +inline void append_setpoint(QueryArgs &args, uint8_t id, float temperature) { + const auto value = static_cast(std::clamp(temperature, MIN_TEMP, MAX_TEMP) * SETPOINT_SCALE); + const auto bytes = decode_value(value); + args.push_back(id); + args.push_back(2); + args.push_back(bytes[0]); + args.push_back(bytes[1]); +} + +/// Decodes a two byte setpoint value; the caller checks the size. +inline float decode_setpoint(std::span value) { + return static_cast(encode_uint16(value[0], value[1])) / SETPOINT_SCALE; +} + +} // namespace esphome::daikin_madoka diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index c0d3e67901c..115a6ef2fd9 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -83,6 +83,7 @@ ISOLATED_COMPONENTS = { "animation": "Has display lambda in common.yaml that requires existing display platform - breaks when merged without display", "cdc_acm_uart": "Depends on tinyusb which conflicts with usb_host", "esphome": "Defines devices/areas in esphome: section that are referenced in other sections - breaks when merged", + "daikin_madoka": "Needs esp32_ble with io_capability: display_yes_no, which a merged esp32_ble: block overrides", "ecocomfort2": "Needs esp32_ble with io_capability: none and sc_bond, which a merged esp32_ble: block overrides", "ethernet": "Defines ethernet: which conflicts with wifi: used by most components", "ethernet_info": "Related to ethernet component which conflicts with wifi", diff --git a/tests/components/daikin_madoka/common.yaml b/tests/components/daikin_madoka/common.yaml new file mode 100644 index 00000000000..8d61bd0eb72 --- /dev/null +++ b/tests/components/daikin_madoka/common.yaml @@ -0,0 +1,14 @@ +esp32_ble_tracker: + +esp32_ble: + io_capability: display_yes_no + +ble_client: + - mac_address: 01:23:45:AB:CD:EF + id: madoka_climate + +climate: + - platform: daikin_madoka + name: "Madoka Climate" + ble_client_id: madoka_climate + update_interval: 15s diff --git a/tests/components/daikin_madoka/daikin_madoka_protocol.cpp b/tests/components/daikin_madoka/daikin_madoka_protocol.cpp new file mode 100644 index 00000000000..74c671a0022 --- /dev/null +++ b/tests/components/daikin_madoka/daikin_madoka_protocol.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include + +#include "esphome/components/daikin_madoka/daikin_madoka_protocol.h" + +namespace esphome::daikin_madoka::testing { + +// Builds a message {len, 0, cmd_hi, cmd_lo, args...} with the leading length byte filled in. +static std::vector make_message(uint16_t cmd, const std::vector &args) { + std::vector msg{0, 0, static_cast(cmd >> 8), static_cast(cmd & 0xFF)}; + msg.insert(msg.end(), args.begin(), args.end()); + msg[0] = static_cast(msg.size()); + return msg; +} + +// Splits a message into notifications of a chunk id byte plus up to 19 payload bytes. +static std::vector> make_chunks(const std::vector &msg) { + std::vector> chunks; + for (size_t pos = 0, id = 0; pos < msg.size(); pos += MAX_CHUNK_SIZE - 1, id++) { + std::vector chunk{static_cast(id)}; + size_t end = std::min(msg.size(), pos + MAX_CHUNK_SIZE - 1); + chunk.insert(chunk.end(), msg.begin() + pos, msg.begin() + end); + chunks.push_back(chunk); + } + return chunks; +} + +// A setpoint reply padded with extra arguments, like the 44 argument replies seen from real controllers. +static std::vector make_wide_setpoint_message() { + std::vector args; + for (uint8_t id = 0x30; id < 0x30 + 42; id++) { + args.push_back(id); + args.push_back(1); + args.push_back(id); + } + args.insert(args.end(), {0x20, 0x02, 0x0B, 0x00, 0x21, 0x02, 0x0A, 0x80}); + return make_message(0x0040, args); +} + +TEST(DaikinMadokaAssembler, SingleChunkMessage) { + MessageAssembler assembler; + auto msg = make_message(0x0020, {0x20, 0x01, 0x01}); + auto chunks = make_chunks(msg); + ASSERT_EQ(chunks.size(), 1u); + EXPECT_EQ(assembler.feed(chunks[0]), ChunkResult::CHUNK_RESULT_COMPLETE); + auto out = assembler.message(); + EXPECT_EQ(std::vector(out.begin(), out.end()), msg); +} + +TEST(DaikinMadokaAssembler, MultiChunkMessage) { + MessageAssembler assembler; + auto msg = make_wide_setpoint_message(); + auto chunks = make_chunks(msg); + ASSERT_GT(chunks.size(), 2u); + for (size_t i = 0; i + 1 < chunks.size(); i++) { + EXPECT_EQ(assembler.feed(chunks[i]), ChunkResult::CHUNK_RESULT_PARTIAL) << "chunk " << i; + } + EXPECT_EQ(assembler.feed(chunks.back()), ChunkResult::CHUNK_RESULT_COMPLETE); + auto out = assembler.message(); + EXPECT_EQ(std::vector(out.begin(), out.end()), msg); + + // The next chunk 0 starts a fresh message + auto next = make_message(0x0020, {0x20, 0x01, 0x00}); + EXPECT_EQ(assembler.feed(make_chunks(next)[0]), ChunkResult::CHUNK_RESULT_COMPLETE); + out = assembler.message(); + EXPECT_EQ(std::vector(out.begin(), out.end()), next); +} + +// A sensor information reply of 64 bytes, which takes four chunks. +static std::vector make_four_chunk_message() { + std::vector args; + for (uint8_t id = 0x40; id < 0x40 + 20; id++) { + args.push_back(id); + args.push_back(1); + args.push_back(id ^ 0x5A); + } + return make_message(0x0110, args); +} + +static std::vector to_vector(std::span data) { return {data.begin(), data.end()}; } + +TEST(DaikinMadokaAssembler, SwappedChunksAssemble) { + MessageAssembler assembler; + auto msg = make_four_chunk_message(); + auto chunks = make_chunks(msg); + ASSERT_EQ(chunks.size(), 4u); + EXPECT_EQ(assembler.feed(chunks[1]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[0]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[2]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[3]), ChunkResult::CHUNK_RESULT_COMPLETE); + EXPECT_EQ(to_vector(assembler.message()), msg); + + // Chunk 0 last works as well + EXPECT_EQ(assembler.feed(chunks[3]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[2]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[1]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[0]), ChunkResult::CHUNK_RESULT_COMPLETE); + EXPECT_EQ(to_vector(assembler.message()), msg); +} + +TEST(DaikinMadokaAssembler, DuplicateAndInvalidChunkIds) { + MessageAssembler assembler; + auto msg = make_four_chunk_message(); + auto chunks = make_chunks(msg); + ASSERT_EQ(chunks.size(), 4u); + EXPECT_EQ(assembler.feed(chunks[0]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[0]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[2]), ChunkResult::CHUNK_RESULT_PARTIAL); + // Ids past the last chunk a 255 byte message can need are rejected without losing the others + const std::vector past_end{MAX_CHUNKS, 0x01, 0x02}; + EXPECT_EQ(assembler.feed(past_end), ChunkResult::CHUNK_RESULT_INVALID_ID); + const std::vector garbage{0xFF, 0x01, 0x02}; + EXPECT_EQ(assembler.feed(garbage), ChunkResult::CHUNK_RESULT_INVALID_ID); + EXPECT_EQ(assembler.feed(chunks[2]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[1]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[3]), ChunkResult::CHUNK_RESULT_COMPLETE); + EXPECT_EQ(to_vector(assembler.message()), msg); +} + +TEST(DaikinMadokaAssembler, ShortChunkBeforeTheLast) { + // Chunk 0 carries only 5 bytes, so chunk 1 does not start at its slot's offset in the message + MessageAssembler assembler; + auto msg = make_message(0x0020, {0x20, 0x01, 0x01, 0x21, 0x01, 0x02, 0x22, 0x01, 0x03}); + std::vector first{0x00}; + first.insert(first.end(), msg.begin(), msg.begin() + 5); + std::vector second{0x01}; + second.insert(second.end(), msg.begin() + 5, msg.end()); + EXPECT_EQ(assembler.feed(second), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(first), ChunkResult::CHUNK_RESULT_COMPLETE); + EXPECT_EQ(to_vector(assembler.message()), msg); +} + +TEST(DaikinMadokaAssembler, NewMessageReplacesUnfinishedOne) { + MessageAssembler assembler; + auto chunks = make_chunks(make_wide_setpoint_message()); + ASSERT_GT(chunks.size(), 2u); + EXPECT_EQ(assembler.feed(chunks[0]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(chunks[1]), ChunkResult::CHUNK_RESULT_PARTIAL); + // A single chunk message takes over slot 0 and completes on its own + auto msg = make_message(0x0020, {0x20, 0x01, 0x01}); + EXPECT_EQ(assembler.feed(make_chunks(msg)[0]), ChunkResult::CHUNK_RESULT_COMPLETE); + EXPECT_EQ(to_vector(assembler.message()), msg); + // The leftover chunks were dropped with it + EXPECT_EQ(assembler.feed(chunks[2]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_TRUE(assembler.message().empty()); +} + +TEST(DaikinMadokaAssembler, ResetDropsLeftoverChunks) { + // A 38 byte reply whose chunk 1 is a full 19 bytes, like the leftover chunk 1 of a lost reply + std::vector args; + for (uint8_t id = 0x40; id < 0x40 + 10; id++) + args.insert(args.end(), {id, 0x01, id}); + args.insert(args.end(), {0x50, 0x02, 0x01, 0x02}); + auto msg = make_message(0x0110, args); + ASSERT_EQ(msg.size(), 2u * MAX_CHUNK_PAYLOAD); + auto lost = make_chunks(make_four_chunk_message()); + auto next = make_chunks(msg); + ASSERT_EQ(next.size(), 2u); + + // Without a reset the leftover chunk 1 completes the next reply with the wrong bytes + MessageAssembler assembler; + EXPECT_EQ(assembler.feed(lost[1]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_EQ(assembler.feed(next[0]), ChunkResult::CHUNK_RESULT_COMPLETE); + EXPECT_NE(to_vector(assembler.message()), msg); + + // The reset at the start of the next poll batch drops it, so the reply waits for its own chunk 1 + EXPECT_EQ(assembler.feed(lost[1]), ChunkResult::CHUNK_RESULT_PARTIAL); + assembler.reset(); + EXPECT_EQ(assembler.feed(next[0]), ChunkResult::CHUNK_RESULT_PARTIAL); + EXPECT_TRUE(assembler.message().empty()); + EXPECT_EQ(assembler.feed(next[1]), ChunkResult::CHUNK_RESULT_COMPLETE); + EXPECT_EQ(to_vector(assembler.message()), msg); +} + +TEST(DaikinMadokaAssembler, RejectsShortAndOverlongChunks) { + MessageAssembler assembler; + const std::vector id_only{0x00}; + EXPECT_EQ(assembler.feed(id_only), ChunkResult::CHUNK_RESULT_INVALID_LENGTH); + // Length byte says 3 but the chunk carries 5 bytes + const std::vector overlong{0x00, 0x03, 0x00, 0x00, 0x20, 0x00}; + EXPECT_EQ(assembler.feed(overlong), ChunkResult::CHUNK_RESULT_TOO_LONG); + EXPECT_TRUE(assembler.message().empty()); +} + +TEST(DaikinMadokaFindArg, FindsArgumentsInWideMessage) { + auto msg = make_wide_setpoint_message(); + ASSERT_TRUE(is_valid_message(msg)); + EXPECT_EQ(message_function_id(msg), 0x0040); + auto high = find_arg(msg, 0x20); + ASSERT_EQ(high.size(), 2u); + EXPECT_FLOAT_EQ(decode_setpoint(high), 22.0f); + auto low = find_arg(msg, 0x21); + ASSERT_EQ(low.size(), 2u); + EXPECT_FLOAT_EQ(decode_setpoint(low), 21.0f); + auto padding = find_arg(msg, 0x30 + 41); + ASSERT_EQ(padding.size(), 1u); + EXPECT_EQ(padding[0], 0x30 + 41); + EXPECT_TRUE(find_arg(msg, 0x7F).empty()); +} + +TEST(DaikinMadokaFindArg, TruncatedArgumentIsNotReturned) { + // Argument 0x21 claims 4 bytes but only 2 remain + auto msg = make_message(0x0040, {0x20, 0x01, 0x05, 0x21, 0x04, 0x01, 0x02}); + EXPECT_EQ(find_arg(msg, 0x20).size(), 1u); + EXPECT_TRUE(find_arg(msg, 0x21).empty()); + // A dangling id byte with no length byte is ignored + auto dangling = make_message(0x0040, {0x20, 0x01, 0x05, 0x21}); + EXPECT_TRUE(find_arg(dangling, 0x21).empty()); +} + +TEST(DaikinMadokaFindArg, SkipsEmptyRecords) { + // Argument 0x40 has no value (length 0xFF) and must not stop the search for 0x41 + auto msg = make_message(0x0110, {0x40, 0xFF, 0x41, 0x01, 0x17}); + auto value = find_arg(msg, 0x41); + ASSERT_EQ(value.size(), 1u); + EXPECT_EQ(value[0], 0x17); + EXPECT_TRUE(find_arg(msg, 0x40).empty()); + // An empty record at the very end is fine too + auto trailing = make_message(0x0110, {0x41, 0x01, 0x17, 0x40, 0xFF}); + EXPECT_EQ(find_arg(trailing, 0x41).size(), 1u); + EXPECT_TRUE(find_arg(trailing, 0x40).empty()); + EXPECT_TRUE(find_arg(trailing, 0x42).empty()); +} + +TEST(DaikinMadokaFindArg, MessageValidation) { + const std::vector too_short{0x03, 0x00, 0x00}; + EXPECT_FALSE(is_valid_message(too_short)); + auto msg = make_message(0x0020, {0x20, 0x01, 0x01}); + msg[0]++; + EXPECT_FALSE(is_valid_message(msg)); +} + +static std::span as_span(const QueryArgs &args) { return {args.begin(), args.size()}; } + +TEST(DaikinMadokaMergeArgs, ReplacesAndAppendsRecords) { + // A call that only sets the low setpoint joins a queued high one, and a newer high replaces it + QueryArgs args; + append_setpoint(args, 0x20, 24.0f); + QueryArgs low; + append_setpoint(low, 0x21, 20.0f); + EXPECT_TRUE(merge_args(args, as_span(low))); + EXPECT_EQ(args.size(), 8u); + QueryArgs high; + append_setpoint(high, 0x20, 26.0f); + EXPECT_TRUE(merge_args(args, as_span(high))); + EXPECT_EQ(args.size(), 8u); + EXPECT_FLOAT_EQ(26.0f, decode_setpoint(as_span(args).subspan(2, 2))); + EXPECT_FLOAT_EQ(20.0f, decode_setpoint(as_span(args).subspan(6, 2))); +} + +TEST(DaikinMadokaMergeArgs, LeavesArgsAloneWhenARecordDoesNotFit) { + QueryArgs args; + append_setpoint(args, 0x20, 24.0f); + append_setpoint(args, 0x21, 20.0f); + const std::array extra{0x22, 0x01, 0x00}; + EXPECT_FALSE(merge_args(args, extra)); + EXPECT_EQ(args.size(), 8u); + // A record of another length for the same id is not merged either + const std::array short_high{0x20, 0x01, 0x00}; + EXPECT_FALSE(merge_args(args, short_high)); + EXPECT_EQ(args[1], 2u); +} + +TEST(DaikinMadokaSetpoint, RoundTrip) { + StaticVector args; + append_setpoint(args, 0x20, 22.5f); + append_setpoint(args, 0x21, 40.0f); // clamped to MAX_TEMP + ASSERT_EQ(args.size(), 8u); + EXPECT_EQ(args[0], 0x20); + EXPECT_EQ(args[1], 0x02); + EXPECT_EQ(args[2], 0x0B); + EXPECT_EQ(args[3], 0x40); + EXPECT_FLOAT_EQ(decode_setpoint(std::span(args.data() + 2, 2)), 22.5f); + EXPECT_EQ(args[4], 0x21); + EXPECT_FLOAT_EQ(decode_setpoint(std::span(args.data() + 6, 2)), MAX_TEMP); + + StaticVector low; + append_setpoint(low, 0x21, 5.0f); // clamped to MIN_TEMP + EXPECT_FLOAT_EQ(decode_setpoint(std::span(low.data() + 2, 2)), MIN_TEMP); +} + +} // namespace esphome::daikin_madoka::testing diff --git a/tests/components/daikin_madoka/test.esp32-idf.yaml b/tests/components/daikin_madoka/test.esp32-idf.yaml new file mode 100644 index 00000000000..31b60c00786 --- /dev/null +++ b/tests/components/daikin_madoka/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + daikin_madoka: !include common.yaml From cad297866f62e454e3abf43cf941c10437acfcec Mon Sep 17 00:00:00 2001 From: andytrofimov Date: Tue, 3 Nov 2026 22:04:36 +0300 Subject: [PATCH 134/170] [speaker] Reduce startup buffering for announcements (#21091) Co-authored-by: andytrofimov <26643796+andytrofimov@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../speaker/media_player/audio_pipeline.cpp | 13 +++- .../speaker/media_player/audio_pipeline.h | 12 ++-- .../speaker/media_player/playback_start.h | 36 +++++++++++ .../media_player/speaker_media_player.cpp | 8 +-- tests/components/speaker/__init__.py | 9 +++ .../speaker/test_playback_start.cpp | 60 +++++++++++++++++++ 6 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 esphome/components/speaker/media_player/playback_start.h create mode 100644 tests/components/speaker/__init__.py create mode 100644 tests/components/speaker/test_playback_start.cpp diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index eb29b28e306..d3174b93c69 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -1,4 +1,5 @@ #include "audio_pipeline.h" +#include "playback_start.h" #ifdef USE_ESP32 @@ -58,19 +59,21 @@ AudioPipeline::AudioPipeline(speaker::Speaker *speaker, size_t buffer_size, bool this->transfer_buffer_size_ = std::min(buffer_size_ / 4, DEFAULT_TRANSFER_BUFFER_SIZE); } -void AudioPipeline::start_url(const std::string &uri) { +void AudioPipeline::start_url(const std::string &uri, bool is_announcement) { if (this->is_playing_) { xEventGroupSetBits(this->event_group_, PIPELINE_COMMAND_STOP); } this->current_uri_ = uri; + this->pending_is_announcement_ = is_announcement; this->pending_url_ = true; } -void AudioPipeline::start_file(const audio::AudioFile *audio_file) { +void AudioPipeline::start_file(const audio::AudioFile *audio_file, bool is_announcement) { if (this->is_playing_) { xEventGroupSetBits(this->event_group_, PIPELINE_COMMAND_STOP); } this->current_audio_file_ = audio_file; + this->pending_is_announcement_ = is_announcement; this->pending_file_ = true; } @@ -163,6 +166,7 @@ AudioPipelineState AudioPipeline::process_state() { this->start_tasks_(); } + this->current_is_announcement_ = this->pending_is_announcement_; if (this->pending_url_) { xEventGroupSetBits(this->event_group_, EventGroupBits::READER_COMMAND_INIT_HTTP); this->playback_ms_ = 0; @@ -398,6 +402,7 @@ void AudioPipeline::decode_task(void *params) { EventGroupBits::DECODER_MESSAGE_FINISHED | EventGroupBits::READER_MESSAGE_LOADED_MEDIA_TYPE); if (!(event_bits & EventGroupBits::PIPELINE_COMMAND_STOP)) { + const bool is_announcement = this_pipeline->current_is_announcement_; InfoErrorEvent event; event.source = InfoErrorSource::DECODER; @@ -421,6 +426,7 @@ void AudioPipeline::decode_task(void *params) { bool has_stream_info = false; bool started_playback = false; + PlaybackStartGate start_gate; size_t initial_bytes_to_buffer = 0; @@ -514,7 +520,8 @@ void AudioPipeline::decode_task(void *params) { if (!started_playback && has_stream_info) { // Verify enough data is available before starting playback std::shared_ptr temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); - if (temp_ring_buffer != nullptr && temp_ring_buffer->available() >= initial_bytes_to_buffer) { + if (temp_ring_buffer != nullptr && + start_gate.ready(is_announcement, temp_ring_buffer->available(), initial_bytes_to_buffer, millis())) { started_playback = true; } } diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index d8a3ae774c7..f38a4ce8289 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -68,13 +68,15 @@ class AudioPipeline final { /// @brief Starts an audio pipeline given a media url /// @param uri media file url - /// @return ESP_OK if successful or an appropriate error if not - void start_url(const std::string &uri); + /// @param is_announcement True for an announcement, which also starts when its stream pauses before the + /// start threshold + void start_url(const std::string &uri, bool is_announcement = false); /// @brief Starts an audio pipeline given a AudioFile pointer /// @param audio_file pointer to an AudioFile object - /// @return ESP_OK if successful or an appropriate error if not - void start_file(const audio::AudioFile *audio_file); + /// @param is_announcement True for an announcement, which also starts when its stream pauses before the + /// start threshold + void start_file(const audio::AudioFile *audio_file, bool is_announcement = false); /// @brief Stops the pipeline. Sends a stop signal to each task (if running) and clears the ring buffers. /// @return ESP_OK if successful or ESP_ERR_TIMEOUT if the tasks did not indicate they stopped @@ -117,6 +119,8 @@ class AudioPipeline final { // Pending file start state used to ensure the pipeline fully stops before attempting to start the next file bool pending_url_{false}; bool pending_file_{false}; + bool pending_is_announcement_{false}; + bool current_is_announcement_{false}; speaker::Speaker *speaker_{nullptr}; diff --git a/esphome/components/speaker/media_player/playback_start.h b/esphome/components/speaker/media_player/playback_start.h new file mode 100644 index 00000000000..d07fc663ac8 --- /dev/null +++ b/esphome/components/speaker/media_player/playback_start.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +namespace esphome::speaker { + +/// How long an announcement's raw buffer must stay unchanged below the start threshold before playback starts. +/// Longer than ordinary network gaps, which a stream that is barely faster than real time cannot recover from once +/// it starts with a small reserve; a paused speech reply stays silent for seconds. +static constexpr uint32_t ANNOUNCEMENT_STALL_START_MS = 1500; + +/// Decides when buffered audio starts playing. Everything waits for the full start threshold while data keeps +/// arriving; an announcement whose stream pauses below it (a spoken intro before a long reply) plays what it has. +/// Called only once the decoder has stream info, so data has arrived even when the decoder holds all of it and the +/// raw buffer reads zero. +class PlaybackStartGate { + public: + bool ready(bool announcement, size_t raw_bytes, size_t initial_bytes, uint32_t now_ms) { + if (raw_bytes >= initial_bytes) + return true; + if (raw_bytes != this->last_raw_bytes_) { + this->last_raw_bytes_ = raw_bytes; + this->last_change_ms_ = now_ms; + return false; + } + return announcement && now_ms - this->last_change_ms_ >= ANNOUNCEMENT_STALL_START_MS; + } + + protected: + // No level matches at first, so the first call starts the stall timer + size_t last_raw_bytes_{SIZE_MAX}; + uint32_t last_change_ms_{0}; +}; + +} // namespace esphome::speaker diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 281c0f558f8..4099de590c5 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -167,9 +167,9 @@ void SpeakerMediaPlayer::watch_media_commands_() { this->announcement_playlist_.clear(); this->announcement_item_failed_ = false; if (media_command.file.has_value()) { - this->announcement_pipeline_->start_file(playlist_item.file.value()); + this->announcement_pipeline_->start_file(playlist_item.file.value(), playlist_item.is_announcement); } else if (media_command.url.has_value()) { - this->announcement_pipeline_->start_url(playlist_item.url.value()); + this->announcement_pipeline_->start_url(playlist_item.url.value(), playlist_item.is_announcement); } this->announcement_pipeline_->set_pause_state(false); } @@ -412,9 +412,9 @@ void SpeakerMediaPlayer::loop() { this->media_pipeline_state_ == AudioPipelineState::PLAYING, this->volume, this->speaker_volume_applier_()); if (playlist_item.url.has_value()) { - this->announcement_pipeline_->start_url(playlist_item.url.value()); + this->announcement_pipeline_->start_url(playlist_item.url.value(), playlist_item.is_announcement); } else if (playlist_item.file.has_value()) { - this->announcement_pipeline_->start_file(playlist_item.file.value()); + this->announcement_pipeline_->start_file(playlist_item.file.value(), playlist_item.is_announcement); } if (timeout_ms > 0) { diff --git a/tests/components/speaker/__init__.py b/tests/components/speaker/__init__.py new file mode 100644 index 00000000000..db3dafc7231 --- /dev/null +++ b/tests/components/speaker/__init__.py @@ -0,0 +1,9 @@ +from esphome.loader import FileResource +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + """Copy the startup helper without loading the ESP32 media player.""" + manifest.add_resources( + FileResource("esphome.components.speaker", "media_player/playback_start.h") + ) diff --git a/tests/components/speaker/test_playback_start.cpp b/tests/components/speaker/test_playback_start.cpp new file mode 100644 index 00000000000..40d9cbc0c54 --- /dev/null +++ b/tests/components/speaker/test_playback_start.cpp @@ -0,0 +1,60 @@ +#include + +#include "esphome/components/speaker/media_player/playback_start.h" + +namespace esphome::speaker::testing { + +static constexpr size_t INITIAL_BYTES = 48000; + +TEST(PlaybackStart, FullThresholdStartsAnyStream) { + PlaybackStartGate media; + EXPECT_FALSE(media.ready(false, 47999, INITIAL_BYTES, 0)); + EXPECT_TRUE(media.ready(false, 48000, INITIAL_BYTES, 1)); + PlaybackStartGate announcement; + EXPECT_TRUE(announcement.ready(true, 48000, INITIAL_BYTES, 0)); +} + +TEST(PlaybackStart, StalledAnnouncementStartsBelowThreshold) { + PlaybackStartGate gate; + EXPECT_FALSE(gate.ready(true, 42799, INITIAL_BYTES, 1000)); + EXPECT_FALSE(gate.ready(true, 42799, INITIAL_BYTES, 1000 + ANNOUNCEMENT_STALL_START_MS - 1)); + EXPECT_TRUE(gate.ready(true, 42799, INITIAL_BYTES, 1000 + ANNOUNCEMENT_STALL_START_MS)); +} + +TEST(PlaybackStart, ArrivingDataKeepsTheFullReserve) { + PlaybackStartGate gate; + uint32_t now = 0; + for (size_t bytes = 1000; bytes < INITIAL_BYTES; bytes += 1000) { + EXPECT_FALSE(gate.ready(true, bytes, INITIAL_BYTES, now)); + now += ANNOUNCEMENT_STALL_START_MS - 1; + } +} + +TEST(PlaybackStart, NetworkGapDoesNotStartAnAnnouncement) { + // A 900 ms gap in a stream barely faster than real time; starting there plays choppy + PlaybackStartGate gate; + EXPECT_FALSE(gate.ready(true, 20000, INITIAL_BYTES, 0)); + EXPECT_FALSE(gate.ready(true, 20000, INITIAL_BYTES, 900)); + EXPECT_FALSE(gate.ready(true, 21000, INITIAL_BYTES, 950)); +} + +TEST(PlaybackStart, StalledMediaWaitsForTheThreshold) { + PlaybackStartGate gate; + EXPECT_FALSE(gate.ready(false, 42799, INITIAL_BYTES, 0)); + EXPECT_FALSE(gate.ready(false, 42799, INITIAL_BYTES, 10000)); +} + +TEST(PlaybackStart, IntroHeldByTheDecoderStartsAfterTheStall) { + // The decoder can take a whole short intro, leaving the raw buffer empty + PlaybackStartGate gate; + EXPECT_FALSE(gate.ready(true, 0, INITIAL_BYTES, 5000)); + EXPECT_FALSE(gate.ready(true, 0, INITIAL_BYTES, 5000 + ANNOUNCEMENT_STALL_START_MS - 1)); + EXPECT_TRUE(gate.ready(true, 0, INITIAL_BYTES, 5000 + ANNOUNCEMENT_STALL_START_MS)); +} + +TEST(PlaybackStart, FirstCallStartsTheStallTimer) { + PlaybackStartGate gate; + EXPECT_FALSE(gate.ready(true, 0, INITIAL_BYTES, 100000)); +} + +} // namespace esphome::speaker::testing From 8a30af4ad10d188f34548f2fd6c8b3cd2a06ee62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:47:41 -0600 Subject: [PATCH 135/170] Bump platformdirs from 4.14.0 to 4.14.2 (#22225) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ff467008ac3..259d7e3c4c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.2.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.4 -platformdirs==4.14.0 # native esp-idf toolchain global cache dir +platformdirs==4.14.2 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.2.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 1a260e4c5756a5a167172dc14bf5b073479e3836 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:47:48 -0600 Subject: [PATCH 136/170] Bump zeroconf from 0.151.5 to 0.152.0 (#22226) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 259d7e3c4c2..ed4d10f92a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ esptool==5.6.0 click==8.5.0 aioesphomeapi==46.25.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi -zeroconf==0.151.5 +zeroconf==0.152.0 puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import From 8092a235535f55612d66c28e5cefd4f516d45670 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:47:57 -0600 Subject: [PATCH 137/170] Bump pytest-codspeed from 5.0.3 to 5.0.4 (#22227) Signed-off-by: dependabot[bot] --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 81d4ce3cd4d..9a8e9e9ac34 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ hypothesis==6.92.1 # CodSpeed benchmarks under tests/benchmarks/python/ # (skipped via pytest.importorskip when missing -- only required for the # benchmarks job in .github/workflows/ci.yml) -pytest-codspeed==5.0.3 +pytest-codspeed==5.0.4 # Used by the import-time regression check (.github/workflows/ci.yml → import-time job) importtime-waterfall==1.0.0 From a7a4473b325443578b359433f3de1b13e0a3fad2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:48:06 -0600 Subject: [PATCH 138/170] Bump filelock from 4.2.4 to 4.2.6 (#22228) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ed4d10f92a0..19c52a9aeb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.4 platformdirs==4.14.2 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.2.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.2.6 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 6f284d171a5eee55dba8add6cf8dc7a246c30053 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:48:16 -0600 Subject: [PATCH 139/170] Bump docker/setup-buildx-action from 4.4.0 to 4.4.1 in the docker-actions group (#22229) Signed-off-by: dependabot[bot] --- .github/workflows/actions.lock | 10 +++++----- .github/workflows/ci-docker.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index a698da1b5d7..9bc36ffc511 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -19,7 +19,7 @@ workflows: - 'actions/setup-python@v7.1.0' - 'actions/upload-artifact@v7.0.2' - 'docker/login-action@v4.7.0' - - 'docker/setup-buildx-action@v4.4.0' + - 'docker/setup-buildx-action@v4.4.1' '.github/workflows/ci-github-scripts.yml': - 'actions/checkout@v7.0.1' '.github/workflows/ci-memory-impact-comment.yml': @@ -72,7 +72,7 @@ workflows: - 'actions/upload-artifact@v7.0.2' - 'docker/build-push-action@v7.4.0' - 'docker/login-action@v4.7.0' - - 'docker/setup-buildx-action@v4.4.0' + - 'docker/setup-buildx-action@v4.4.1' - 'pypa/gh-action-pypi-publish@v1.14.3' '.github/workflows/stale.yml': [] '.github/workflows/status-check-labels.yml': @@ -198,9 +198,9 @@ dependencies: commit: 'sha1-81c6f93d4bc991e510b342a00b419b3bb4efc5c6' owner_id: 5429470 repo_id: 287743349 - 'docker/setup-buildx-action@v4.4.0': - ref: 'v4.4.0' - commit: 'sha1-594f3bf4285d9ea8dc53c9a0c9c4092420091003' + 'docker/setup-buildx-action@v4.4.1': + ref: 'v4.4.1' + commit: 'sha1-f87e5991a6d7451dcb8d9637bfbc97413f497069' owner_id: 5429470 repo_id: 288485773 'github/codeql-action@v4.39.1': diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index c8ad5dcd01a..30b51a212f6 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -66,7 +66,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.4.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Determine tag and whether to push id: tag @@ -158,7 +158,7 @@ jobs: with: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.4.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to the GitHub container registry uses: docker/login-action@v4.7.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7c0371daa6..4bff7da55df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,7 +123,7 @@ jobs: python-version: "3.12" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.4.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to docker hub uses: docker/login-action@v4.7.0 @@ -202,7 +202,7 @@ jobs: merge-multiple: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4.4.0 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to docker hub if: matrix.registry == 'dockerhub' From 940f87056d692a6ee87c2aa3d526087842e20ee0 Mon Sep 17 00:00:00 2001 From: Adam Matthews <52178922+adamsthws@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:00:28 +0000 Subject: [PATCH 140/170] [cse7766] Log the clamped power factor and reactive power at verbose level (#22224) --- esphome/components/cse7766/cse7766.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index bb9d65c9763..54374ee6052 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -189,7 +189,8 @@ void CSE7766Component::parse_data_() { if (have_power && this->reactive_power_sensor_ != nullptr) { const float q_squared = apparent_power * apparent_power - power * power; if (q_squared < 0.0f) { - ESP_LOGD(TAG, "Impossible reactive power: S^2-P^2 is negative (%.4f)", q_squared); + // From the same noise as a power factor above 1 (below) + ESP_LOGV(TAG, "Impossible reactive power: S^2-P^2 is negative (%.4f)", q_squared); this->reactive_power_sensor_->publish_state(0.0f); } else { this->reactive_power_sensor_->publish_state(std::sqrt(q_squared)); @@ -200,8 +201,9 @@ void CSE7766Component::parse_data_() { if (apparent_power > 0) { pf = power / apparent_power; if (pf > 1) { - // Noise near the chip's resolution limit, seen during inrush, pushes the ratio past unity - ESP_LOGD(TAG, "Power factor %.4f above 1, clamped", pf); + // Noise near the chip's resolution limit pushes the ratio past unity: during inrush, and on + // nearly every frame of a steady resistive load, so it is logged only at verbose level + ESP_LOGV(TAG, "Power factor %.4f above 1, clamped", pf); pf = 1.0f; } } else if (apparent_power == 0 && power == 0) { From 09d3b645a3a4178efe6301cf39e98320a3262565 Mon Sep 17 00:00:00 2001 From: Nikolas Kachmaryk Date: Tue, 3 Nov 2026 23:45:41 +0200 Subject: [PATCH 141/170] [i2s_audio] Fix sample-rate-dependent microphone DC correction (#22130) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../i2s_audio/microphone/dc_offset_filter.h | 69 +++++++ .../microphone/i2s_audio_microphone.cpp | 59 +----- .../microphone/i2s_audio_microphone.h | 9 +- tests/components/i2s_audio/__init__.py | 3 +- .../i2s_audio/test_dc_offset_filter.cpp | 174 ++++++++++++++++++ 5 files changed, 251 insertions(+), 63 deletions(-) create mode 100644 esphome/components/i2s_audio/microphone/dc_offset_filter.h create mode 100644 tests/components/i2s_audio/test_dc_offset_filter.cpp diff --git a/esphome/components/i2s_audio/microphone/dc_offset_filter.h b/esphome/components/i2s_audio/microphone/dc_offset_filter.h new file mode 100644 index 00000000000..7bb59cf3dbd --- /dev/null +++ b/esphome/components/i2s_audio/microphone/dc_offset_filter.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/components/audio/audio.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace esphome::i2s_audio { + +/// Removes DC from mono or stereo PCM, retaining separate channel histories between buffers. +class DCOffsetFilter { + public: + void reset(uint32_t sample_rate) { + // A 20 Hz pole gives the same settling time at every sample rate. Normalize the + // feedforward coefficient to keep unity gain at Nyquist: b = (1 + a) / 2. + // Single precision keeps double-precision exp out of the build; expm1 gives 1 - a accurately with a near 1. + const float half_one_minus_pole = + -0.5f * std::expm1(-2.0f * std::numbers::pi_v * 20.0f / static_cast(sample_rate)); + // Round to nearest in integers: half of (twice the value, truncated, plus one) + const int64_t twice = static_cast(half_one_minus_pole * (2 * COEFFICIENT_SCALE)); + this->feedforward_ = static_cast(COEFFICIENT_SCALE - ((twice + 1) >> 1)); + this->feedback_ = static_cast(2 * static_cast(this->feedforward_) - COEFFICIENT_SCALE); + this->states_ = {}; + } + + // The I2S driver supplies complete mono or stereo frames with the sample rate used by reset(). + void process(uint8_t *data, size_t size, const audio::AudioStreamInfo &stream_info) { + const size_t bytes_per_sample = stream_info.samples_to_bytes(1); + const size_t channels = stream_info.get_channels(); + // Only mono and stereo streams have channel histories; leave other formats unchanged. + if (channels == 0 || channels > this->states_.size()) { + return; + } + const size_t frames = stream_info.bytes_to_frames(size); + for (size_t frame = 0; frame < frames; ++frame) { + for (size_t channel = 0; channel < channels; ++channel) { + auto &state = this->states_[channel]; + const int32_t input = audio::unpack_audio_sample_to_q31(data, bytes_per_sample); + // Q30 coefficients leave room for full-scale steps in the 64-bit products. + // Keep the unclipped history so clipping a transient does not change the filter's decay. + const int64_t difference = static_cast(input) - state.input; + const int64_t output = ((this->feedforward_ * difference) >> 30) + ((this->feedback_ * state.output) >> 30); + state.input = input; + state.output = output; + const int32_t sample = static_cast( + std::clamp(output, std::numeric_limits::min(), std::numeric_limits::max())); + audio::pack_q31_as_audio_sample(sample, data, bytes_per_sample); + data += bytes_per_sample; + } + } + } + + protected: + static constexpr int64_t COEFFICIENT_SCALE = int64_t{1} << 30; + struct ChannelState { + int32_t input{0}; + int64_t output{0}; + }; + std::array states_{}; + int32_t feedforward_{0}; + int32_t feedback_{0}; +}; + +} // namespace esphome::i2s_audio diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index b6b532d98ee..f6d5c73ce8d 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -320,6 +320,9 @@ void I2SAudioMicrophone::mic_task(void *params) { // Copied so the format cannot change while the task runs const audio::AudioStreamInfo stream_info = this_microphone->audio_stream_info_; + if (this_microphone->correct_dc_offset_) { + this_microphone->dc_offset_filter_.reset(stream_info.get_sample_rate()); + } // Read whole DMA buffers. When its queue is nearly full, the driver discards the unread rest of a buffer it is // partway through without reporting it, which would throw off the capture time count. @@ -358,7 +361,7 @@ void I2SAudioMicrophone::mic_task(void *params) { samples.resize(bytes_read); if (this_microphone->correct_dc_offset_) { - this_microphone->fix_dc_offset_(samples); + this_microphone->dc_offset_filter_.process(samples.data(), samples.size(), stream_info); } this_microphone->publish_audio_(samples, stream_info, capture_time_us); } else { @@ -393,60 +396,6 @@ bool IRAM_ATTR I2SAudioMicrophone::i2s_on_recv_q_ovf_cb(i2s_chan_handle_t handle return false; } -void I2SAudioMicrophone::fix_dc_offset_(std::vector &data) { - /** - * From https://www.musicdsp.org/en/latest/Filters/135-dc-filter.html: - * - * y(n) = x(n) - x(n-1) + R * y(n-1) - * R = 1 - (pi * 2 * frequency / samplerate) - * - * From https://en.wikipedia.org/wiki/Hearing_range: - * The human range is commonly given as 20Hz up. - * - * From https://en.wikipedia.org/wiki/High-resolution_audio: - * A reasonable upper bound for sample rate seems to be 96kHz. - * - * Calculate R value for 20Hz on a 96kHz sample rate: - * R = 1 - (pi * 2 * 20 / 96000) - * R = 0.9986910031 - * - * Transform floating point to bit-shifting approximation: - * output = input - prev_input + R * prev_output - * output = input - prev_input + (prev_output - (prev_output >> S)) - * - * Approximate bit-shift value S from R: - * R = 1 - (1 >> S) - * R = 1 - (1 / 2^S) - * R = 1 - 2^-S - * 0.9986910031 = 1 - 2^-S - * S = 9.57732 ~= 10 - * - * Actual R from S: - * R = 1 - 2^-10 = 0.9990234375 - * - * Confirm this has effect outside human hearing on 96000kHz sample: - * 0.9990234375 = 1 - (pi * 2 * f / 96000) - * f = 14.9208Hz - * - * Confirm this has effect outside human hearing on PDM 16kHz sample: - * 0.9990234375 = 1 - (pi * 2 * f / 16000) - * f = 2.4868Hz - * - */ - const uint8_t dc_filter_shift = 10; - const size_t bytes_per_sample = this->audio_stream_info_.samples_to_bytes(1); - const uint32_t total_samples = this->audio_stream_info_.bytes_to_samples(data.size()); - for (uint32_t sample_index = 0; sample_index < total_samples; ++sample_index) { - const uint32_t byte_index = sample_index * bytes_per_sample; - int32_t input = audio::unpack_audio_sample_to_q31(&data[byte_index], bytes_per_sample); - int32_t output = input - this->dc_offset_prev_input_ + - (this->dc_offset_prev_output_ - (this->dc_offset_prev_output_ >> dc_filter_shift)); - this->dc_offset_prev_input_ = input; - this->dc_offset_prev_output_ = output; - audio::pack_q31_as_audio_sample(output, &data[byte_index], bytes_per_sample); - } -} - size_t I2SAudioMicrophone::read_(uint8_t *buf, size_t len, uint32_t timeout_ms) { size_t bytes_read = 0; esp_err_t err = i2s_channel_read(this->rx_handle_, buf, len, &bytes_read, timeout_ms); diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 947a3082206..6fe8cf4f83c 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -3,6 +3,7 @@ #ifdef USE_ESP32 #include "../i2s_audio.h" +#include "dc_offset_filter.h" #include "esphome/components/microphone/microphone.h" #include "esphome/core/component.h" @@ -59,11 +60,6 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon /// @return True if successful, false otherwise bool read_dma_buffer_size_(); - /// @brief Attempts to correct a microphone DC offset; e.g., a microphones silent level is offset from 0. Applies a - /// correction offset that is updated using an exponential moving average for all samples away from 0. - /// @param data - void fix_dc_offset_(std::vector &data); - size_t read_(uint8_t *buf, size_t len, uint32_t timeout_ms); /// @brief Sets the Microphone ``audio_stream_info_`` member variable to the configured I2S settings. @@ -107,8 +103,7 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon bool pdm_{false}; bool correct_dc_offset_; bool locked_driver_{false}; - int32_t dc_offset_prev_input_{0}; - int32_t dc_offset_prev_output_{0}; + DCOffsetFilter dc_offset_filter_; }; } // namespace esphome::i2s_audio diff --git a/tests/components/i2s_audio/__init__.py b/tests/components/i2s_audio/__init__.py index af00bbaa332..96397ad5f23 100644 --- a/tests/components/i2s_audio/__init__.py +++ b/tests/components/i2s_audio/__init__.py @@ -3,9 +3,10 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # Only the capture time helper is under test; the microphone platform is not in this build + # Test the microphone helpers without the ESP32-specific driver. manifest.add_resources( FileResource("esphome.components.i2s_audio", "microphone/capture_time.h"), + FileResource("esphome.components.i2s_audio", "microphone/dc_offset_filter.h"), ) # For AudioStreamInfo, which the microphone platform would otherwise bring in manifest.dependencies = manifest.dependencies + ["audio"] diff --git a/tests/components/i2s_audio/test_dc_offset_filter.cpp b/tests/components/i2s_audio/test_dc_offset_filter.cpp new file mode 100644 index 00000000000..e826854dc8f --- /dev/null +++ b/tests/components/i2s_audio/test_dc_offset_filter.cpp @@ -0,0 +1,174 @@ +#include + +#include "esphome/components/i2s_audio/microphone/dc_offset_filter.h" + +#include +#include +#include +#include +#include +#include + +namespace esphome::i2s_audio::testing { + +static constexpr int32_t AMPLITUDE = 1 << 28; + +static std::vector constant_pcm(const audio::AudioStreamInfo &info, size_t frames, int32_t value) { + std::vector data(info.frames_to_bytes(frames)); + const size_t width = info.samples_to_bytes(1); + for (size_t offset = 0; offset < data.size(); offset += width) { + audio::pack_q31_as_audio_sample(value, data.data() + offset, width); + } + return data; +} + +TEST(DCOffsetFilter, SettlingTimeDoesNotDependOnSampleRate) { + for (uint32_t rate : {1600, 8000, 16000, 44100, 48000, 96000}) { + SCOPED_TRACE(rate); + audio::AudioStreamInfo info(32, 1, rate); + auto data = constant_pcm(info, rate / 20, AMPLITUDE); + DCOffsetFilter filter; + filter.reset(rate); + filter.process(data.data(), data.size(), info); + const double residual = audio::unpack_audio_sample_to_q31(data.data() + data.size() - 4, 4) / double(AMPLITUDE); + EXPECT_NEAR(residual, std::exp(-2.0 * M_PI * 20.0 * 0.05), 0.0001); + } +} + +TEST(DCOffsetFilter, PreservesPassbandGain) { + for (uint32_t rate : {1600, 16000, 48000, 96000}) { + SCOPED_TRACE(rate); + audio::AudioStreamInfo info(32, 1, rate); + auto data = constant_pcm(info, rate, 0); + const double frequency = rate / 4.0; + for (size_t frame = 0; frame < rate; ++frame) { + const int32_t input = static_cast(AMPLITUDE * std::sin(2.0 * M_PI * frequency * frame / rate)); + audio::pack_q31_as_audio_sample(input, data.data() + 4 * frame, 4); + } + DCOffsetFilter filter; + filter.reset(rate); + filter.process(data.data(), data.size(), info); + double energy = 0; + for (size_t frame = rate / 2; frame < rate; ++frame) { + const double output = audio::unpack_audio_sample_to_q31(data.data() + 4 * frame, 4) / double(AMPLITUDE); + energy += output * output; + } + EXPECT_NEAR(std::sqrt(energy / (rate / 2.0) * 2.0), 1.0, 0.001); + } +} + +TEST(DCOffsetFilter, StereoChannelsMatchIndependentMonoFilters) { + audio::AudioStreamInfo stereo_info(32, 2, 16000); + audio::AudioStreamInfo mono_info(32, 1, 16000); + auto left = constant_pcm(mono_info, 1600, AMPLITUDE); + auto right = constant_pcm(mono_info, 1600, -AMPLITUDE / 2); + auto stereo = constant_pcm(stereo_info, 1600, 0); + for (size_t frame = 0; frame < 1600; ++frame) { + std::copy_n(left.data() + 4 * frame, 4, stereo.data() + 8 * frame); + std::copy_n(right.data() + 4 * frame, 4, stereo.data() + 8 * frame + 4); + } + DCOffsetFilter stereo_filter, left_filter, right_filter; + for (auto *filter : {&stereo_filter, &left_filter, &right_filter}) { + filter->reset(16000); + } + stereo_filter.process(stereo.data(), stereo.size(), stereo_info); + left_filter.process(left.data(), left.size(), mono_info); + right_filter.process(right.data(), right.size(), mono_info); + for (size_t frame = 0; frame < 1600; ++frame) { + EXPECT_EQ(audio::unpack_audio_sample_to_q31(stereo.data() + 8 * frame, 4), + audio::unpack_audio_sample_to_q31(left.data() + 4 * frame, 4)); + EXPECT_EQ(audio::unpack_audio_sample_to_q31(stereo.data() + 8 * frame + 4, 4), + audio::unpack_audio_sample_to_q31(right.data() + 4 * frame, 4)); + } +} + +TEST(DCOffsetFilter, UnsupportedChannelsLeaveDataAndHistoryUnchanged) { + for (uint8_t channels : {0, 3, 255}) { + SCOPED_TRACE(channels); + audio::AudioStreamInfo mono_info(32, 1, 16000); + audio::AudioStreamInfo unsupported_info(32, channels, 16000); + DCOffsetFilter filter, reference; + filter.reset(16000); + reference.reset(16000); + auto initial = constant_pcm(mono_info, 100, AMPLITUDE); + auto reference_initial = initial; + filter.process(initial.data(), initial.size(), mono_info); + reference.process(reference_initial.data(), reference_initial.size(), mono_info); + + auto unsupported = constant_pcm(mono_info, 255, -AMPLITUDE); + const auto original = unsupported; + filter.process(unsupported.data(), unsupported.size(), unsupported_info); + EXPECT_EQ(unsupported, original); + + auto continuation = constant_pcm(mono_info, 100, AMPLITUDE); + auto expected = continuation; + filter.process(continuation.data(), continuation.size(), mono_info); + reference.process(expected.data(), expected.size(), mono_info); + EXPECT_EQ(continuation, expected); + } +} + +TEST(DCOffsetFilter, NyquistGainIsUnity) { + audio::AudioStreamInfo info(32, 1, 16000); + auto data = constant_pcm(info, 16000, AMPLITUDE); + for (size_t frame = 1; frame < 16000; frame += 2) { + audio::pack_q31_as_audio_sample(-AMPLITUDE, data.data() + 4 * frame, 4); + } + DCOffsetFilter filter; + filter.reset(16000); + filter.process(data.data(), data.size(), info); + EXPECT_NEAR(audio::unpack_audio_sample_to_q31(data.data() + data.size() - 4, 4) / double(-AMPLITUDE), 1.0, 0.000001); +} + +TEST(DCOffsetFilter, BufferBoundariesDoNotChangeTheResult) { + for (uint8_t bits : {8, 16, 24, 32}) { + SCOPED_TRACE(bits); + audio::AudioStreamInfo info(bits, 2, 16000); + auto whole = constant_pcm(info, 1600, AMPLITUDE); + auto split = whole; + DCOffsetFilter whole_filter, split_filter; + whole_filter.reset(16000); + split_filter.reset(16000); + whole_filter.process(whole.data(), whole.size(), info); + const size_t boundary = info.frames_to_bytes(137); + split_filter.process(split.data(), boundary, info); + split_filter.process(nullptr, 0, info); + split_filter.process(split.data() + boundary, split.size() - boundary, info); + EXPECT_EQ(whole, split); + EXPECT_LE(std::abs(audio::unpack_audio_sample_to_q31(whole.data() + whole.size() - bits / 8, bits / 8)), + std::max(2000, int64_t{1} << (32 - bits))); + } +} + +TEST(DCOffsetFilter, ClipsFullScaleStepsWithoutOverflowOrWrapping) { + audio::AudioStreamInfo info(32, 1, 16000); + DCOffsetFilter filter; + for (int32_t initial : {std::numeric_limits::min(), std::numeric_limits::max()}) { + filter.reset(16000); + auto data = constant_pcm(info, 1600, initial); + filter.process(data.data(), data.size(), info); + const int32_t opposite = initial < 0 ? std::numeric_limits::max() : std::numeric_limits::min(); + data = constant_pcm(info, 1600, opposite); + filter.process(data.data(), data.size(), info); + EXPECT_EQ(audio::unpack_audio_sample_to_q31(data.data(), 4), opposite); + EXPECT_LT(std::abs(int64_t{audio::unpack_audio_sample_to_q31(data.data() + data.size() - 4, 4)}), 20000); + } +} + +TEST(DCOffsetFilter, ResetDiscardsPreviousCaptureHistory) { + audio::AudioStreamInfo info(32, 2, 16000); + auto original = constant_pcm(info, 100, AMPLITUDE); + DCOffsetFilter filter; + filter.reset(96000); + auto previous = original; + filter.process(previous.data(), previous.size(), info); + filter.reset(16000); + auto restarted = original; + filter.process(restarted.data(), restarted.size(), info); + DCOffsetFilter fresh; + fresh.reset(16000); + fresh.process(original.data(), original.size(), info); + EXPECT_EQ(restarted, original); +} + +} // namespace esphome::i2s_audio::testing From 94d0113f668f919bc1d603aa39a7839c53f1dd25 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:02:02 -0600 Subject: [PATCH 142/170] [core] Add put_be and put_le helpers to write integers into byte buffers (#22211) --- .agents/skills/code-review/SKILL.md | 1 + AGENTS.md | 6 ++- esphome/core/helpers.h | 33 +++++++++++++ tests/components/core/test_put_bytes.cpp | 60 ++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/components/core/test_put_bytes.cpp diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 191a7fd19bd..7a80215b18a 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -83,6 +83,7 @@ helper; `AGENTS.md` section 4 and the `component-codegen` skill have the detail. | hand-written `{"a": MyEnum.MY_ENUM_A, ...}` dict | `MyEnum.options("a", "b", prefix="MY_ENUM_")` | | loops on `available()` / `read_byte()` | UART `read_available(on_byte)`, `discard_available()`, `LineReader`, `FrameReader` | | hand-rolled CRC, checksum or bit loop | `crc8`, `crc16`, `xor_bytes`, `sum16_bytes`, `encode_uint16`, ... (`esphome/core/helpers.h`) | +| integer split into buffer bytes by hand (`buf[0] = v >> 8; buf[1] = v;`) | `put_be16`/`24`/`32`, `put_le16`, `put_le32` (`esphome/core/helpers.h`) | | bare `mark_failed()`, or `ESP_LOGE` then `mark_failed()` | `this->mark_failed(LOG_STR("reason"))` or `LOG_STR(ESP_LOG_MSG_COMM_FAIL)` | | button/switch/number/select C++ class that only calls the hub | `ApplyButton`, `ApplySwitch`, `ApplyNumber`, `ApplySelect` with `apply=` | | action or condition class that only forwards to the parent | `register_apply_action` / `register_apply_condition` | diff --git a/AGENTS.md b/AGENTS.md index 188bc329584..5082a6fe017 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,8 +227,10 @@ file does, and it is the authority when they disagree. The most useful starting A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`. * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `xor_bytes`, `sum16_bytes`, `reverse_bits`, `encode_uint16`, `encode_value_le`, `sign_extend`, `bcd_to_uint8`, `assign_bits`); pick - the helper's arguments instead of writing a loop. 7-byte BCD RTC calendars use `time::decode_bcd_calendar` - and `encode_bcd_calendar` with `EPSON_BCD_CALENDAR`, `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`. + the helper's arguments instead of writing a loop. `put_be16`/`24`/`32`, `put_le16` and `put_le32` write an + integer into a byte buffer and return the pointer past it. 7-byte BCD RTC calendars use + `time::decode_bcd_calendar` and `encode_bcd_calendar` with `EPSON_BCD_CALENDAR`, + `DS_BCD_CALENDAR` or `PCF_BCD_CALENDAR`. * **Bus helpers:** UART drains with `read_available(on_byte)` and `discard_available()`, text lines with `LineReader`, fixed-length binary frames with `FrameReader`; I2C has `update_register`, `write_registers` (PROGMEM `I2CRegValue` table), `check_chip_id` and `read_bytes_16_le`. diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 35973b1c794..3adf38ce011 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1106,6 +1106,39 @@ constexpr std::array decode_value(T val) { return ret; } +/// Write the low 16, 24 or 32 bits of \p value to \p dst, most significant byte first (be) or least +/// significant byte first (le). Returns the pointer past the last byte written. +constexpr uint8_t *put_be16(uint8_t *dst, uint16_t value) { + dst[0] = static_cast(value >> 8); + dst[1] = static_cast(value); + return dst + 2; +} +constexpr uint8_t *put_be24(uint8_t *dst, uint32_t value) { + dst[0] = static_cast(value >> 16); + dst[1] = static_cast(value >> 8); + dst[2] = static_cast(value); + return dst + 3; +} +constexpr uint8_t *put_be32(uint8_t *dst, uint32_t value) { + dst[0] = static_cast(value >> 24); + dst[1] = static_cast(value >> 16); + dst[2] = static_cast(value >> 8); + dst[3] = static_cast(value); + return dst + 4; +} +constexpr uint8_t *put_le16(uint8_t *dst, uint16_t value) { + dst[0] = static_cast(value); + dst[1] = static_cast(value >> 8); + return dst + 2; +} +constexpr uint8_t *put_le32(uint8_t *dst, uint32_t value) { + dst[0] = static_cast(value); + dst[1] = static_cast(value >> 8); + dst[2] = static_cast(value >> 16); + dst[3] = static_cast(value >> 24); + return dst + 4; +} + /// Decode a two-digit packed BCD byte (tens in the high nibble) to its value, e.g. 0x59 to 59. constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } /// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59. diff --git a/tests/components/core/test_put_bytes.cpp b/tests/components/core/test_put_bytes.cpp new file mode 100644 index 00000000000..98e462eac79 --- /dev/null +++ b/tests/components/core/test_put_bytes.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +using Bytes = std::array; + +// Writes at offset 1 into a marker-filled buffer so writes past the width show up. +template constexpr Bytes put_at_1(uint8_t *(*put)(uint8_t *, T), V value) { + Bytes buf{0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA}; + put(buf.data() + 1, value); + return buf; +} + +// Usable in constant expressions. +static_assert(put_at_1(put_le32, 0x12345678) == Bytes{0xAA, 0x78, 0x56, 0x34, 0x12, 0xAA}); + +TEST(PutBytes, BigEndian) { + EXPECT_EQ(put_at_1(put_be16, 0x1234), (Bytes{0xAA, 0x12, 0x34, 0xAA, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_be24, 0x123456), (Bytes{0xAA, 0x12, 0x34, 0x56, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_be32, 0x12345678), (Bytes{0xAA, 0x12, 0x34, 0x56, 0x78, 0xAA})); +} + +TEST(PutBytes, LittleEndian) { + EXPECT_EQ(put_at_1(put_le16, 0x1234), (Bytes{0xAA, 0x34, 0x12, 0xAA, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_le32, 0x12345678), (Bytes{0xAA, 0x78, 0x56, 0x34, 0x12, 0xAA})); +} + +TEST(PutBytes, ReturnsEndOfWrite) { + uint8_t buf[9]{}; + uint8_t *p = put_be16(buf, 0x0102); + p = put_be24(p, 0x030405); + p = put_le32(p, 0x09080706); + EXPECT_EQ(p, buf + 9); + for (uint8_t i = 0; i < 9; i++) { + EXPECT_EQ(buf[i], i + 1); + } + EXPECT_EQ(put_le16(buf, 0), buf + 2); + EXPECT_EQ(put_be32(buf, 0), buf + 4); +} + +TEST(PutBytes, HighBitsAboveWidthAreDropped) { + EXPECT_EQ(put_at_1(put_be24, 0xFF123456), (Bytes{0xAA, 0x12, 0x34, 0x56, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_be16, 0x12345), (Bytes{0xAA, 0x23, 0x45, 0xAA, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_le16, 0x12345), (Bytes{0xAA, 0x45, 0x23, 0xAA, 0xAA, 0xAA})); +} + +TEST(PutBytes, SignedValuesUseTwosComplement) { + EXPECT_EQ(put_at_1(put_be16, int16_t{-2}), (Bytes{0xAA, 0xFF, 0xFE, 0xAA, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_le16, int16_t{-2}), (Bytes{0xAA, 0xFE, 0xFF, 0xAA, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_be24, -2), (Bytes{0xAA, 0xFF, 0xFF, 0xFE, 0xAA, 0xAA})); + EXPECT_EQ(put_at_1(put_be32, int32_t{-256}), (Bytes{0xAA, 0xFF, 0xFF, 0xFF, 0x00, 0xAA})); + EXPECT_EQ(put_at_1(put_le32, int32_t{-256}), (Bytes{0xAA, 0x00, 0xFF, 0xFF, 0xFF, 0xAA})); +} + +} // namespace esphome::core::testing From 07a175692ab66d34772e28d863ef814c64bca7d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:14:32 -0600 Subject: [PATCH 143/170] [components] Replace local byte-order write helpers with put_be and put_le (#22212) --- .../esphome/ota/ota_esphome_udp.cpp | 11 ++---- esphome/components/ld600x/ld600x.cpp | 7 ++-- esphome/components/ld600x/ld600x_frame.cpp | 9 ++--- esphome/components/ld600x/ld600x_frame.h | 9 +---- esphome/components/mipi_spi/mipi_spi.h | 13 +++---- .../packet_interface/xxtea_encryption.cpp | 13 ++----- .../seeed_mr60fda2/seeed_mr60fda2.cpp | 17 +-------- esphome/components/usb_uart/pl2303.cpp | 11 ++---- esphome/components/vnc/vnc_client.cpp | 35 +++++++------------ tests/components/ld600x/ld600x_frame_test.cpp | 9 ----- 10 files changed, 34 insertions(+), 100 deletions(-) diff --git a/esphome/components/esphome/ota/ota_esphome_udp.cpp b/esphome/components/esphome/ota/ota_esphome_udp.cpp index bc2838c768d..d207896515d 100644 --- a/esphome/components/esphome/ota/ota_esphome_udp.cpp +++ b/esphome/components/esphome/ota/ota_esphome_udp.cpp @@ -79,11 +79,6 @@ static uint16_t slot_copy(OtaUdpDatagram *p, void *dst, uint16_t len, uint16_t o static void slot_free(OtaUdpDatagram *p) { delete p; } #endif -static void put_u16(uint8_t *at, uint16_t value) { - at[0] = value >> 8; - at[1] = value & 0xFF; -} - ESPHomeOTAComponent::UdpLink::~UdpLink() { this->release(); } void ESPHomeOTAComponent::UdpLink::release() { @@ -114,9 +109,9 @@ void ESPHomeOTAComponent::udp_send_ack(UdpLink &link, uint16_t prompted_by) { #endif ack[0] = UDP_MSG_ACK; memcpy(ack + 1, link.reply, 4); - put_u16(ack + 5, link.received); - put_u16(ack + 7, prompted_by); - put_u16(ack + 9, link.out_base); + put_be16(ack + 5, link.received); + put_be16(ack + 7, prompted_by); + put_be16(ack + 9, link.out_base); uint8_t window = (UDP_WINDOW - static_cast(link.received - link.consumed)) << 4; for (uint8_t k = 1; k < UDP_WINDOW; k++) { if (link.queue[(link.received + k) % UDP_WINDOW] != nullptr) diff --git a/esphome/components/ld600x/ld600x.cpp b/esphome/components/ld600x/ld600x.cpp index 19575623926..e025f0dc50c 100644 --- a/esphome/components/ld600x/ld600x.cpp +++ b/esphome/components/ld600x/ld600x.cpp @@ -1,6 +1,7 @@ #include "ld600x.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #include @@ -941,7 +942,7 @@ void LD600XComponent::write_frame_(uint16_t type, const uint8_t *data, uint8_t l bool LD600XComponent::send_control_command_(uint32_t command) { uint8_t data[4]; - write_u32_le(data, command); + put_le32(data, command); return this->queue_command_(TYPE_CONTROL, data, sizeof(data)); } @@ -971,7 +972,7 @@ bool LD600XComponent::queue_u32_setting_(uint16_t type, float value) { return false; } uint8_t data[4]; - write_u32_le(data, static_cast(value)); + put_le32(data, static_cast(value)); return this->queue_command_(type, data, sizeof(data)); } #endif @@ -1143,7 +1144,7 @@ bool LD600XComponent::queue_area_config_(uint8_t area_id, const AreaConfig &desi return false; } uint8_t data[AREA_CONFIG_LEN]; - write_u32_le(data, static_cast(area_id)); + put_le32(data, static_cast(area_id)); write_f32_le(data + 4, desired.x_min); write_f32_le(data + 8, desired.x_max); write_f32_le(data + 12, desired.y_min); diff --git a/esphome/components/ld600x/ld600x_frame.cpp b/esphome/components/ld600x/ld600x_frame.cpp index bf619abb8e1..68dba77ff76 100644 --- a/esphome/components/ld600x/ld600x_frame.cpp +++ b/esphome/components/ld600x/ld600x_frame.cpp @@ -84,12 +84,9 @@ bool FrameParser::feed(uint8_t byte) { size_t encode_frame(uint16_t frame_id, uint16_t type, const uint8_t *data, uint16_t len, uint8_t *out) { out[0] = TF_SOF; - out[1] = frame_id >> 8; - out[2] = frame_id & 0xFF; - out[3] = len >> 8; - out[4] = len & 0xFF; - out[5] = type >> 8; - out[6] = type & 0xFF; + put_be16(out + 1, frame_id); + put_be16(out + 3, len); + put_be16(out + 5, type); // TinyFrame checksums are the inverted XOR of the bytes they cover, SOF included for the header. out[TF_HEADER_LEN + 1] = static_cast(~xor_bytes(out, TF_HEADER_LEN + 1)); size_t pos = TF_PAYLOAD_OFFSET; diff --git a/esphome/components/ld600x/ld600x_frame.h b/esphome/components/ld600x/ld600x_frame.h index 185113bb461..a06280d2d2e 100644 --- a/esphome/components/ld600x/ld600x_frame.h +++ b/esphome/components/ld600x/ld600x_frame.h @@ -17,14 +17,7 @@ static constexpr uint8_t TF_PAYLOAD_OFFSET = TF_HEADER_LEN + 2; inline float read_f32_le(const uint8_t *data) { return bit_cast(encode_value_le(data)); } -inline void write_u32_le(uint8_t *data, uint32_t value) { - data[0] = value & 0xFF; - data[1] = (value >> 8) & 0xFF; - data[2] = (value >> 16) & 0xFF; - data[3] = (value >> 24) & 0xFF; -} - -inline void write_f32_le(uint8_t *data, float value) { write_u32_le(data, bit_cast(value)); } +inline void write_f32_le(uint8_t *data, float value) { put_le32(data, bit_cast(value)); } class FrameParser final { public: diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index abffbe40a9d..f29a2a42a47 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -49,11 +49,6 @@ static constexpr uint8_t MADCTL_YFLIP = 0x01; // Mirror the display verti static constexpr uint16_t MADCTL_FLIP_FLAG = 0x100; // controller uses axis flip bits static constexpr uint8_t DELAY_FLAG = 0xFF; -// store a 16 bit value in a buffer, big endian. -static inline void put16_be(uint8_t *buf, uint16_t value) { - buf[0] = value >> 8; - buf[1] = value; -} // Buffer mode, conveniently also the number of bytes in a pixel enum PixelMode { @@ -360,11 +355,11 @@ class MipiSpi : public display::Display, x2 += get_offset_width_(); y1 += get_offset_height_(); y2 += get_offset_height_(); - put16_be(buf, y1); - put16_be(buf + 2, y2); + put_be16(buf, y1); + put_be16(buf + 2, y2); this->write_command_(RASET, buf, sizeof buf); - put16_be(buf, x1); - put16_be(buf + 2, x2); + put_be16(buf, x1); + put_be16(buf + 2, x2); this->write_command_(CASET, buf, sizeof buf); if constexpr (BUS_TYPE != BUS_TYPE_QUAD) { this->write_command_(WDATA); diff --git a/esphome/components/packet_interface/xxtea_encryption.cpp b/esphome/components/packet_interface/xxtea_encryption.cpp index 90b284f9922..62afdad5a28 100644 --- a/esphome/components/packet_interface/xxtea_encryption.cpp +++ b/esphome/components/packet_interface/xxtea_encryption.cpp @@ -21,13 +21,6 @@ static constexpr size_t ROLLING_SIZE = 12; static constexpr size_t MIN_LENGTH = 8; static constexpr size_t MAX_PADDING = 4; -static void put_uint32(uint8_t *buf, uint32_t value) { - buf[0] = value; - buf[1] = value >> 8; - buf[2] = value >> 16; - buf[3] = value >> 24; -} - static uint32_t get_uint32(const uint8_t *buf) { return encode_uint32(buf[3], buf[2], buf[1], buf[0]); } static constexpr size_t trailer_size(bool rolling_code) { @@ -106,9 +99,9 @@ bool XxteaEncryption::send_to_interface(const PacketBuffer &data, const PacketMe memset(buf + size, 0, padding); uint8_t *check = buf + length - CHECK_SIZE; if (this->rolling_code_) { - put_uint32(check - ROLLING_SIZE, this->boot_); - put_uint32(check - ROLLING_SIZE + 4, this->counter_); - put_uint32(check - ROLLING_SIZE + 8, this->echo_boot_); + put_le32(check - ROLLING_SIZE, this->boot_); + put_le32(check - ROLLING_SIZE + 4, this->counter_); + put_le32(check - ROLLING_SIZE + 8, this->echo_boot_); memcpy(check, ROLLING_CHECK_BYTES, sizeof(ROLLING_CHECK_BYTES)); if (++this->counter_ == 0) this->next_boot_(); diff --git a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 42af337535d..0ce3d6165e0 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -88,21 +88,6 @@ alignas(4) static const float INSTALL_HEIGHT[7] PROGMEM = {2.4f, 2.5f, 2.6f, 2.7 alignas(4) static const float HEIGHT_THRESHOLD[7] PROGMEM = {0.0f, 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f}; alignas(4) static const float SENSITIVITY[3] PROGMEM = {3, 15, 30}; -/** - * @brief Convert a 32-bit unsigned integer to a byte array. - * - * This function converts a 32-bit unsigned integer to a byte array. - * - * @param value The 32-bit unsigned integer to convert. - * @param bytes The byte array to store the converted value. - */ -static void int_to_bytes(uint32_t value, unsigned char *bytes) { - bytes[0] = value & 0xFF; - bytes[1] = (value >> 8) & 0xFF; - bytes[2] = (value >> 16) & 0xFF; - bytes[3] = (value >> 24) & 0xFF; -} - void MR60FDA2Component::split_frame_(uint8_t buffer) { switch (this->current_frame_locate_) { case LOCATE_FRAME_HEADER: // starting buffer @@ -319,7 +304,7 @@ void MR60FDA2Component::set_sensitivity(uint8_t index) { return; uint8_t send_data[13] = {0x01, 0x00, 0x00, 0x00, 0x04, 0x0E, 0x0A, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00}; - int_to_bytes(SENSITIVITY[index], &send_data[8]); + put_le32(send_data + 8, static_cast(SENSITIVITY[index])); send_data[12] = calculate_checksum(send_data + 8, 4); this->write_array(send_data, 13); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index d5f7523d9dd..6a2306be01f 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -2,6 +2,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "usb_uart.h" #include "usb/usb_host.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -104,14 +105,6 @@ static uint32_t nearest_supported_baud(uint32_t baud) { return SUPPORTED_BAUD_RATES[n - 1]; } -// Direct encoding: little-endian 32-bit baud rate value -static void encode_baud_direct(uint8_t buf[4], uint32_t baud) { - buf[0] = baud & 0xFF; - buf[1] = (baud >> 8) & 0xFF; - buf[2] = (baud >> 16) & 0xFF; - buf[3] = (baud >> 24) & 0xFF; -} - // Divisor encoding for TYPE_HX, TYPE_HXD: baudrate = 12M*32 / (mantissa * 4^exponent) static void encode_baud_divisor(uint8_t buf[4], uint32_t baud) { static constexpr uint32_t BASELINE = 12000000 * 32; @@ -299,7 +292,7 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b // Choose baud encoding based on chip type uint32_t nearest = nearest_supported_baud(baud); if (baud == nearest || this->chip_type_ == PL2303_TYPE_HXN) { - encode_baud_direct(line_coding, baud); + put_le32(line_coding, baud); } else if (this->chip_type_ == PL2303_TYPE_TA || this->chip_type_ == PL2303_TYPE_TB) { encode_baud_divisor_alt(line_coding, baud); } else { diff --git a/esphome/components/vnc/vnc_client.cpp b/esphome/components/vnc/vnc_client.cpp index 3123e9bd38c..14da37adc77 100644 --- a/esphome/components/vnc/vnc_client.cpp +++ b/esphome/components/vnc/vnc_client.cpp @@ -4,6 +4,7 @@ #include "esphome/components/unicode/unicode.h" #include "esphome/core/application.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" #ifdef USE_VNC_AUTH @@ -72,16 +73,6 @@ static constexpr uint32_t KEYSYM_UNICODE = 0x01000000; static inline uint16_t read_be16(const uint8_t *p) { return encode_uint16(p[0], p[1]); } static inline uint32_t read_be32(const uint8_t *p) { return encode_uint32(p[0], p[1], p[2], p[3]); } -static inline void write_be16(uint8_t *p, uint16_t v) { - p[0] = v >> 8; - p[1] = v; -} -static inline void write_be32(uint8_t *p, uint32_t v) { - p[0] = v >> 24; - p[1] = v >> 16; - p[2] = v >> 8; - p[3] = v; -} static int parse_three_digits(const uint8_t *p) { int value = 0; @@ -1141,9 +1132,9 @@ bool VncClient::start_session_(uint32_t now) { pixel_format[5] = rgb565 ? 16 : 8; pixel_format[6] = this->big_endian_; pixel_format[7] = 1; - write_be16(pixel_format + 8, rgb565 ? 31 : 7); - write_be16(pixel_format + 10, rgb565 ? 63 : 7); - write_be16(pixel_format + 12, rgb565 ? 31 : 3); + put_be16(pixel_format + 8, rgb565 ? 31 : 7); + put_be16(pixel_format + 10, rgb565 ? 63 : 7); + put_be16(pixel_format + 12, rgb565 ? 31 : 3); pixel_format[14] = rgb565 ? 11 : 5; pixel_format[15] = rgb565 ? 5 : 2; if (!this->send_(pixel_format, sizeof(pixel_format))) { @@ -1152,9 +1143,9 @@ bool VncClient::start_session_(uint32_t now) { // SetEncodings uint8_t encodings[4 + 4 * std::size(ENCODINGS)] = {2}; - write_be16(encodings + 2, std::size(ENCODINGS)); - for (size_t i = 0; i < std::size(ENCODINGS); i++) { - write_be32(encodings + 4 + 4 * i, static_cast(ENCODINGS[i])); + uint8_t *p = put_be16(encodings + 2, std::size(ENCODINGS)); + for (auto encoding : ENCODINGS) { + p = put_be32(p, static_cast(encoding)); } if (!this->send_(encodings, sizeof(encodings)) || !this->send_update_request_(false, now)) { return false; @@ -1200,8 +1191,8 @@ bool VncClient::send_(const uint8_t *data, size_t len) { bool VncClient::send_update_request_(bool incremental, uint32_t now) { uint8_t msg[10] = {3, static_cast(incremental ? 1 : 0)}; - write_be16(msg + 6, this->vis_w_); - write_be16(msg + 8, this->vis_h_); + put_be16(msg + 6, this->vis_w_); + put_be16(msg + 8, this->vis_h_); if (!this->send_(msg, sizeof(msg))) { return false; } @@ -1215,8 +1206,8 @@ bool VncClient::send_key_event_(uint32_t keysym, VncKeyAction action) { // A press is the down and the up event, sent whole or not at all. uint8_t msg[2 * KEY_EVENT_SIZE] = {MSG_KEY_EVENT, static_cast(action != VncKeyAction::VNC_KEY_ACTION_UP)}; msg[KEY_EVENT_SIZE] = MSG_KEY_EVENT; - write_be32(msg + 4, keysym); - write_be32(msg + KEY_EVENT_SIZE + 4, keysym); + put_be32(msg + 4, keysym); + put_be32(msg + KEY_EVENT_SIZE + 4, keysym); return this->send_input_(msg, action == VncKeyAction::VNC_KEY_ACTION_PRESS ? sizeof(msg) : KEY_EVENT_SIZE, action == VncKeyAction::VNC_KEY_ACTION_UP); } @@ -1261,8 +1252,8 @@ bool VncClient::send_pointer(uint16_t x, uint16_t y, uint8_t buttons) { x = std::min(x, this->fb_w_ - 1); y = std::min(y, this->fb_h_ - 1); uint8_t msg[POINTER_EVENT_SIZE] = {MSG_POINTER_EVENT, buttons}; - write_be16(msg + 2, x); - write_be16(msg + 4, y); + put_be16(msg + 2, x); + put_be16(msg + 4, y); const bool release = buttons != this->ptr_buttons_ && (buttons & ~this->ptr_buttons_) == 0; if (!this->send_input_(msg, sizeof(msg), release)) { ESP_LOGW(TAG, "Send buffer full, pointer event dropped"); diff --git a/tests/components/ld600x/ld600x_frame_test.cpp b/tests/components/ld600x/ld600x_frame_test.cpp index 7720ccfa312..1d91e3eb8b5 100644 --- a/tests/components/ld600x/ld600x_frame_test.cpp +++ b/tests/components/ld600x/ld600x_frame_test.cpp @@ -237,15 +237,6 @@ TEST(Ld600xFrameId, SetsTopBitAndWrapsLowBits) { EXPECT_EQ(counter, 0); } -TEST(Ld600xByteHelpers, UnsignedInt32) { - const std::array bytes = {0x12, 0x34, 0x56, 0xF8}; - std::array written{}; - write_u32_le(written.data(), 0xF8563412u); - EXPECT_EQ(written, bytes); - write_u32_le(written.data(), static_cast(-2)); - EXPECT_EQ(written, (std::array{0xFE, 0xFF, 0xFF, 0xFF})); -} - TEST(Ld600xByteHelpers, FloatLayoutAndRoundTrip) { const std::array bytes = {0x00, 0x00, 0x48, 0xC1}; EXPECT_FLOAT_EQ(read_f32_le(bytes.data()), -12.5f); From 408bd8820a7da889bdf8274cbf46678a30227a3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:45:32 -0600 Subject: [PATCH 144/170] [cap1188] Use update_register and check the touch status read (#22220) --- esphome/components/cap1188/cap1188.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/esphome/components/cap1188/cap1188.cpp b/esphome/components/cap1188/cap1188.cpp index 88ebdf44d33..2cb5e6fd2ec 100644 --- a/esphome/components/cap1188/cap1188.cpp +++ b/esphome/components/cap1188/cap1188.cpp @@ -72,15 +72,12 @@ void CAP1188Component::dump_config() { void CAP1188Component::loop() { uint8_t touched = 0; - this->read_register(CAP1188_SENSOR_INPUT_STATUS, &touched, 1); + // On a failed read the channels keep their state instead of reporting a release + if (!this->status_comm_result(this->read_byte(CAP1188_SENSOR_INPUT_STATUS, &touched))) + return; - if (touched) { - uint8_t data = 0; - this->read_register(CAP1188_MAIN, &data, 1); - data = data & ~CAP1188_MAIN_INT; - - this->write_register(CAP1188_MAIN, &data, 1); - } + if (touched) + this->update_register(CAP1188_MAIN, CAP1188_MAIN_INT, 0); for (auto *channel : this->channels_) { channel->process(touched); From 9591e347223efb5100209458aef7639e711379cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:46:18 -0600 Subject: [PATCH 145/170] [lps22] Use update_register for the one-shot trigger (#22219) --- esphome/components/lps22/lps22.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 47b00057c69..2c90dc283b9 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -42,10 +42,8 @@ void LPS22Component::dump_config() { static constexpr uint32_t INTERVAL_READ = 0; void LPS22Component::update() { - uint8_t value = 0x00; - this->read_register(CTRL_REG2, &value, 1); - value |= CTRL_REG2_ONE_SHOT_MASK; - this->write_register(CTRL_REG2, &value, 1); + if (!this->status_comm_result(this->update_register(CTRL_REG2, CTRL_REG2_ONE_SHOT_MASK, CTRL_REG2_ONE_SHOT_MASK))) + return; this->read_attempts_remaining_ = READ_ATTEMPTS; this->set_interval(INTERVAL_READ, READ_INTERVAL, [this]() { this->try_read_(); }); } From 82a375907d14bfac4c37cef90029fa54795d0597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:47:13 -0600 Subject: [PATCH 146/170] [xl9535] Use update_register and status_comm_result for pin access (#22217) --- esphome/components/xl9535/xl9535.cpp | 90 +++++----------------------- 1 file changed, 16 insertions(+), 74 deletions(-) diff --git a/esphome/components/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index d26641c3afa..e200db8eb59 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -19,88 +19,30 @@ void XL9535Component::dump_config() { LOG_I2C_DEVICE(this); } +// Pins 0-7 are port 0 and pins 10-17 are port 1; each register pair holds port 0 then port 1 +static uint8_t port_register(uint8_t port_0_register, uint8_t pin) { return port_0_register + pin / 10; } +static uint8_t pin_mask(uint8_t pin) { return 1 << (pin % 10); } + bool XL9535Component::digital_read(uint8_t pin) { - bool state = false; uint8_t port = 0; - - if (pin > 7) { - if (this->read_register(XL9535_INPUT_PORT_1_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return state; - } - - state = (port & (1 << (pin - 10))) != 0; - } else { - if (this->read_register(XL9535_INPUT_PORT_0_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return state; - } - - state = (port & (1 << pin)) != 0; - } - - this->status_clear_warning(); - return state; + if (!this->status_comm_result(this->read_byte(port_register(XL9535_INPUT_PORT_0_REGISTER, pin), &port))) + return false; + return (port & pin_mask(pin)) != 0; } void XL9535Component::digital_write(uint8_t pin, bool value) { - uint8_t port = 0; - uint8_t register_data = 0; - - if (pin > 7) { - if (this->read_register(XL9535_OUTPUT_PORT_1_REGISTER, ®ister_data, 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - register_data = register_data & (~(1 << (pin - 10))); - port = register_data | value << (pin - 10); - - if (this->write_register(XL9535_OUTPUT_PORT_1_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - } else { - if (this->read_register(XL9535_OUTPUT_PORT_0_REGISTER, ®ister_data, 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - register_data = register_data & (~(1 << pin)); - port = register_data | value << pin; - - if (this->write_register(XL9535_OUTPUT_PORT_0_REGISTER, &port, 1) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - } - - this->status_clear_warning(); + const uint8_t mask = pin_mask(pin); + this->status_comm_result( + this->update_register(port_register(XL9535_OUTPUT_PORT_0_REGISTER, pin), mask, value ? mask : 0)); } void XL9535Component::pin_mode(uint8_t pin, gpio::Flags mode) { - uint8_t port = 0; - - if (pin > 7) { - this->read_register(XL9535_CONFIG_PORT_1_REGISTER, &port, 1); - - if (mode == gpio::FLAG_INPUT) { - port = port | (1 << (pin - 10)); - } else if (mode == gpio::FLAG_OUTPUT) { - port = port & (~(1 << (pin - 10))); - } - - this->write_register(XL9535_CONFIG_PORT_1_REGISTER, &port, 1); - } else { - this->read_register(XL9535_CONFIG_PORT_0_REGISTER, &port, 1); - - if (mode == gpio::FLAG_INPUT) { - port = port | (1 << pin); - } else if (mode == gpio::FLAG_OUTPUT) { - port = port & (~(1 << pin)); - } - - this->write_register(XL9535_CONFIG_PORT_0_REGISTER, &port, 1); - } + if (mode != gpio::FLAG_INPUT && mode != gpio::FLAG_OUTPUT) + return; + // A set config bit makes the pin an input + const uint8_t mask = pin_mask(pin); + this->status_comm_result(this->update_register(port_register(XL9535_CONFIG_PORT_0_REGISTER, pin), mask, + mode == gpio::FLAG_INPUT ? mask : 0)); } } // namespace esphome::xl9535 From 7813ea1c7f0962353fa01967492d662916658622 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:48:31 -0600 Subject: [PATCH 147/170] [sim800l] Add integration test for the on_* automation arguments (#22202) --- .../fixtures/uart_mock_sim800l_callbacks.yaml | 70 +++++++++++++++++++ .../test_uart_mock_sim800l_callbacks.py | 50 +++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tests/integration/fixtures/uart_mock_sim800l_callbacks.yaml create mode 100644 tests/integration/test_uart_mock_sim800l_callbacks.py diff --git a/tests/integration/fixtures/uart_mock_sim800l_callbacks.yaml b/tests/integration/fixtures/uart_mock_sim800l_callbacks.yaml new file mode 100644 index 00000000000..39151e8d3a6 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_sim800l_callbacks.yaml @@ -0,0 +1,70 @@ +esphome: + name: uart-mock-sim800l-callbacks + on_boot: + priority: -100 + then: + - delay: 200ms + # One write, so every line is parsed in a single loop with no poll in between. + # RING resets the state machine from any state but receiving an SMS. + - uart_mock.inject_rx: + id: mock_uart + data: "RING\r\n+CLIP: \"+15551234567\",145,\"\",0,\"\",0\r\n+DTMF: 7,100\r\nNO CARRIER\r\n+CMTI: \"SM\",1\r\n+CMGL: 1,\"REC UNREAD\",\"+15557654321\",\"\",\"26/11/03,10:00:00+00\"\r\nHello there\r\nOK\r\nRING\r\n+CLIP: \"+15550000000\",145,\"\",0,\"\",0\r\n+CMTI: \"SM\",2\r\nOK\r\n+CLCC: 1,1,0,0,0,\"+15550000000\",145\r\nOK\r\n+CMTI: \"SM\",3\r\nOK\r\nOK\r\n+CUSD: 0, \"Balance 5\", 15\r\n+CUSD: 0, \"Balance 5\", 15\r\n" + +host: +api: +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy sim800l's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + id: mock_uart + baud_rate: 9600 + rx_buffer_size: 1024 + +sim800l: + id: sim + uart_id: mock_uart + # No polling, so only the injected lines move the state machine + update_interval: never + # Two automations, so two listeners each get the strings + on_sms_received: + - then: + - logger.log: + format: "on_sms_received A sender=%s message=%s" + args: [sender.c_str(), message.c_str()] + - then: + - logger.log: + format: "on_sms_received B sender=%s message=%s" + args: [sender.c_str(), message.c_str()] + on_incoming_call: + - logger.log: + format: "on_incoming_call caller_id=%s" + args: [caller_id.c_str()] + on_call_connected: + - logger.log: "on_call_connected" + on_call_disconnected: + - logger.log: "on_call_disconnected" + on_dtmf_received: + - logger.log: + format: "on_dtmf_received key=%s" + args: [key.c_str()] + on_ussd_received: + - logger.log: + format: "on_ussd_received before=%s" + args: [ussd.c_str()] + # Sending a code from the automation must not change the received text it holds + - sim800l.send_ussd: + ussd: "*999#" + - logger.log: + format: "on_ussd_received after=%s" + args: [ussd.c_str()] diff --git a/tests/integration/test_uart_mock_sim800l_callbacks.py b/tests/integration/test_uart_mock_sim800l_callbacks.py new file mode 100644 index 00000000000..5fe10059e37 --- /dev/null +++ b/tests/integration/test_uart_mock_sim800l_callbacks.py @@ -0,0 +1,50 @@ +"""sim800l on_* automations receive the values parsed from the modem's lines. + +The fixture injects a call, a DTMF key, an SMS, a connected call and a USSD reply in one +write; each automation logs the arguments it receives. +""" + +from __future__ import annotations + +import re + +import pytest + +from .log_utils import LineWaiter +from .types import RunCompiledFunction + +ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m") +EVENT = re.compile(r"\]: (on_\w+.*)$") + +EXPECTED = [ + "on_incoming_call caller_id=+15551234567", + "on_dtmf_received key=7", + "on_call_disconnected", + "on_sms_received A sender=+15557654321 message=Hello there", + "on_sms_received B sender=+15557654321 message=Hello there", + "on_incoming_call caller_id=+15550000000", + "on_call_connected", + "on_call_disconnected", + "on_ussd_received before=Balance 5", + "on_ussd_received after=Balance 5", +] + + +@pytest.mark.asyncio +async def test_uart_mock_sim800l_callbacks( + yaml_config: str, + run_compiled: RunCompiledFunction, +) -> None: + """Every trigger fires once per event, in order, with the parsed arguments.""" + waiter = LineWaiter() + async with run_compiled(yaml_config, line_callback=waiter.callback): + await waiter.wait_count( + "on_ussd_received after=", 1, 10, "USSD reply not received" + ) + + seen = [ + m.group(1) + for line in waiter.lines + if (m := EVENT.search(ANSI_PATTERN.sub("", line).rstrip())) + ] + assert seen == EXPECTED From a21268f64187d01d2758d6e06f6fb4c02d938289 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:49:13 -0600 Subject: [PATCH 148/170] [bme680_bsec] Add unit tests for publishing readings (#22190) --- tests/components/bme680_bsec/__init__.py | 36 ++++ tests/components/bme680_bsec/__sdk/bsec.h | 175 ++++++++++++++++ tests/components/bme680_bsec/test_publish.cpp | 190 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 tests/components/bme680_bsec/__init__.py create mode 100644 tests/components/bme680_bsec/__sdk/bsec.h create mode 100644 tests/components/bme680_bsec/test_publish.cpp diff --git a/tests/components/bme680_bsec/__init__.py b/tests/components/bme680_bsec/__init__.py new file mode 100644 index 00000000000..71e5a2504b2 --- /dev/null +++ b/tests/components/bme680_bsec/__init__.py @@ -0,0 +1,36 @@ +"""Build the BSEC component on the host against the BSEC/BME680 double in __sdk.""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.helpers import write_file_if_changed +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + +# set_config_() includes one of these library blobs; the double only needs them to exist. +_CONFIG_BLOBS = ( + "generic_33v_300s_28d", + "generic_18v_300s_28d", + "generic_33v_3s_28d", + "generic_18v_3s_28d", +) + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = ["i2c", "sensor", "text_sensor"] + manifest.multi_conf = False + manifest.config_schema = cv.Schema({}) + + async def to_code_testing(config: ConfigType) -> None: + blobs = CORE.relative_build_path("bsec_config") + for name in _CONFIG_BLOBS: + write_file_if_changed(blobs / "config" / name / "bsec_iaq.txt", "0\n") + cg.add_build_flag(f"-I{blobs}") + cg.add_build_flag(f"-I{CORE.relative_src_path('bme680_bsec', '__sdk')}") + cg.add_define("USE_BSEC") + cg.add_define("USE_SENSOR") + cg.add_define("ESPHOME_ENTITY_SENSOR_COUNT", 0) + cg.add_define("USE_TEXT_SENSOR") + cg.add_define("ESPHOME_ENTITY_TEXT_SENSOR_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/bme680_bsec/__sdk/bsec.h b/tests/components/bme680_bsec/__sdk/bsec.h new file mode 100644 index 00000000000..3d917e225a4 --- /dev/null +++ b/tests/components/bme680_bsec/__sdk/bsec.h @@ -0,0 +1,175 @@ +#pragma once + +// Host double for the BSEC 1.x and BME680 libraries: just the types, constants and calls the component uses. +// A test arms one reading with bsec_double::arm(); the next sensor run hands those outputs to the component. +#include +#include + +static constexpr int8_t BME680_OK = 0; +static constexpr uint8_t BME680_I2C_INTF = 1; +static constexpr uint8_t BME680_SLEEP_MODE = 0; +static constexpr uint8_t BME680_FORCED_MODE = 1; +static constexpr uint16_t BME680_OST_SEL = 1; +static constexpr uint16_t BME680_OSP_SEL = 2; +static constexpr uint16_t BME680_OSH_SEL = 4; +static constexpr uint16_t BME680_GAS_SENSOR_SEL = 8 | 64 | 128; +static constexpr uint8_t BME680_NEW_DATA_MSK = 0x80; +static constexpr uint8_t BME680_GASM_VALID_MSK = 0x20; + +static constexpr uint32_t BSEC_MAX_PROPERTY_BLOB_SIZE = 1; +static constexpr uint32_t BSEC_MAX_STATE_BLOB_SIZE = 16; +static constexpr uint32_t BSEC_MAX_WORKBUFFER_SIZE = 16; +static constexpr uint8_t BSEC_NUMBER_OUTPUTS = 14; +static constexpr uint8_t BSEC_MAX_PHYSICAL_SENSOR = 8; +#define BSEC_SAMPLE_RATE_LP 0.33333f +#define BSEC_SAMPLE_RATE_ULP 0.0033333f + +enum { + BSEC_INPUT_PRESSURE = 1, + BSEC_INPUT_HUMIDITY = 2, + BSEC_INPUT_TEMPERATURE = 3, + BSEC_INPUT_GASRESISTOR = 4, + BSEC_INPUT_HEATSOURCE = 14, +}; +static constexpr uint32_t BSEC_PROCESS_PRESSURE = 1 << (BSEC_INPUT_PRESSURE - 1); +static constexpr uint32_t BSEC_PROCESS_TEMPERATURE = 1 << (BSEC_INPUT_TEMPERATURE - 1); +static constexpr uint32_t BSEC_PROCESS_HUMIDITY = 1 << (BSEC_INPUT_HUMIDITY - 1); +static constexpr uint32_t BSEC_PROCESS_GAS = 1 << (BSEC_INPUT_GASRESISTOR - 1); + +enum { + BSEC_OUTPUT_IAQ = 1, + BSEC_OUTPUT_STATIC_IAQ = 2, + BSEC_OUTPUT_CO2_EQUIVALENT = 3, + BSEC_OUTPUT_BREATH_VOC_EQUIVALENT = 4, + BSEC_OUTPUT_RAW_PRESSURE = 7, + BSEC_OUTPUT_RAW_GAS = 9, + BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE = 14, + BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY = 15, +}; + +enum bsec_library_return_t { + BSEC_OK = 0, +}; + +struct bsec_version_t { + uint8_t major, minor, major_bugfix, minor_bugfix; +}; + +struct bsec_input_t { + int64_t time_stamp; + float signal; + uint8_t signal_dimensions; + uint8_t sensor_id; +}; + +struct bsec_output_t { + int64_t time_stamp; + float signal; + uint8_t signal_dimensions; + uint8_t sensor_id; + uint8_t accuracy; +}; + +struct bsec_sensor_configuration_t { + float sample_rate; + uint8_t sensor_id; +}; + +struct bsec_bme_settings_t { + int64_t next_call; + uint32_t process_data; + uint16_t heater_temperature; + uint16_t heating_duration; + uint8_t run_gas; + uint8_t pressure_oversampling; + uint8_t temperature_oversampling; + uint8_t humidity_oversampling; + uint8_t trigger_measurement; +}; + +struct bme680_tph_sett { + uint8_t os_hum, os_temp, os_pres; +}; +struct bme680_gas_sett { + uint8_t run_gas; + uint16_t heatr_temp; + uint16_t heatr_dur; +}; +struct bme680_dev { + uint8_t dev_id; + uint8_t intf; + int8_t amb_temp; + bme680_tph_sett tph_sett; + bme680_gas_sett gas_sett; + uint8_t power_mode; + int8_t (*read)(uint8_t, uint8_t, uint8_t *, uint16_t); + int8_t (*write)(uint8_t, uint8_t, uint8_t *, uint16_t); + void (*delay_ms)(uint32_t); +}; +struct bme680_field_data { + uint8_t status; + int16_t temperature; + uint32_t pressure; + uint32_t humidity; + uint32_t gas_resistance; +}; + +namespace bsec_double { +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +inline bsec_output_t outputs[BSEC_NUMBER_OUTPUTS]; +inline uint8_t num_outputs = 0; +inline bool armed = false; +// Counts bsec_get_state calls, which the component makes only when it saves its state. +inline int get_state_calls = 0; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +inline void arm(const bsec_output_t *reading, uint8_t count) { + std::memcpy(outputs, reading, count * sizeof(bsec_output_t)); + num_outputs = count; + armed = true; +} +} // namespace bsec_double + +inline bsec_library_return_t bsec_init() { return BSEC_OK; } +inline bsec_library_return_t bsec_get_version(bsec_version_t *version) { + *version = {}; + return BSEC_OK; +} +inline bsec_library_return_t bsec_set_configuration(const uint8_t *, uint32_t, uint8_t *, uint32_t) { return BSEC_OK; } +inline bsec_library_return_t bsec_update_subscription(const bsec_sensor_configuration_t *, uint8_t, + bsec_sensor_configuration_t *, uint8_t *) { + return BSEC_OK; +} +// Asks for data processing, without a measurement, only while a reading is armed. trigger_measurement stays 0, so +// read_() runs in the same loop() pass; on hardware it runs later from the measurement timeout. +inline bsec_library_return_t bsec_sensor_control(int64_t, bsec_bme_settings_t *settings) { + *settings = {}; + if (bsec_double::armed) + settings->process_data = BSEC_PROCESS_TEMPERATURE; + return BSEC_OK; +} +inline bsec_library_return_t bsec_do_steps(const bsec_input_t *, uint8_t, bsec_output_t *outputs, + uint8_t *num_outputs) { + std::memcpy(outputs, bsec_double::outputs, bsec_double::num_outputs * sizeof(bsec_output_t)); + *num_outputs = bsec_double::num_outputs; + bsec_double::armed = false; + return BSEC_OK; +} +inline bsec_library_return_t bsec_set_state(const uint8_t *, uint32_t, uint8_t *, uint32_t) { return BSEC_OK; } +inline bsec_library_return_t bsec_get_state(uint8_t, uint8_t *, uint32_t, uint8_t *, uint32_t, + uint32_t *num_serialized_state) { + bsec_double::get_state_calls++; + *num_serialized_state = 0; + return BSEC_OK; +} + +inline int8_t bme680_init(bme680_dev *) { return BME680_OK; } +inline int8_t bme680_set_sensor_settings(uint16_t, bme680_dev *) { return BME680_OK; } +inline int8_t bme680_set_sensor_mode(bme680_dev *) { return BME680_OK; } +inline int8_t bme680_get_sensor_mode(bme680_dev *) { return BME680_OK; } +inline void bme680_get_profile_dur(uint16_t *duration, const bme680_dev *) { *duration = 0; } +inline int8_t bme680_get_sensor_data(bme680_field_data *data, bme680_dev *) { + *data = {}; + data->status = BME680_NEW_DATA_MSK; + return BME680_OK; +} diff --git a/tests/components/bme680_bsec/test_publish.cpp b/tests/components/bme680_bsec/test_publish.cpp new file mode 100644 index 00000000000..bb0a7872fec --- /dev/null +++ b/tests/components/bme680_bsec/test_publish.cpp @@ -0,0 +1,190 @@ +#include + +#include +#include +#include + +#include "esphome/components/bme680_bsec/bme680_bsec.h" + +// The unit test build defines USE_BSEC and builds against the library double in __sdk; clang-tidy does not. +#ifdef USE_BSEC + +namespace esphome::bme680_bsec::testing { +namespace { + +bsec_output_t output(uint8_t sensor_id, float signal, uint8_t accuracy = 0) { + bsec_output_t out{}; + out.sensor_id = sensor_id; + out.signal = signal; + out.accuracy = accuracy; + return out; +} + +using Passes = std::vector>; + +class BSECPublishTest : public ::testing::Test { + protected: + void SetUp() override { + bsec_double::armed = false; + bsec_double::get_state_calls = 0; + this->add_(this->temperature, "temperature"); + this->add_(this->pressure, "pressure"); + this->add_(this->humidity, "humidity"); + this->add_(this->gas_resistance, "gas_resistance"); + this->add_(this->iaq, "iaq"); + this->add_(this->iaq_accuracy, "iaq_accuracy"); + this->add_(this->co2_equivalent, "co2_equivalent"); + this->add_(this->breath_voc_equivalent, "breath_voc_equivalent"); + this->iaq_accuracy_text.add_on_state_callback( + [this](const std::string &state) { this->events.push_back("iaq_accuracy_text=" + state); }); + } + + void attach_all() { + this->component.set_temperature_sensor(&this->temperature); + this->component.set_pressure_sensor(&this->pressure); + this->component.set_humidity_sensor(&this->humidity); + this->component.set_gas_resistance_sensor(&this->gas_resistance); + this->component.set_iaq_sensor(&this->iaq); + this->component.set_iaq_accuracy_sensor(&this->iaq_accuracy); + this->component.set_co2_equivalent_sensor(&this->co2_equivalent); + this->component.set_breath_voc_equivalent_sensor(&this->breath_voc_equivalent); + this->component.set_iaq_accuracy_text_sensor(&this->iaq_accuracy_text); + } + + // The next loop() pass reads these outputs from BSEC. + void arm(const std::vector &outputs) { + ASSERT_LE(outputs.size(), BSEC_NUMBER_OUTPUTS); + bsec_double::arm(outputs.data(), outputs.size()); + } + + // Runs loop() once and returns what that pass published. + std::vector pass() { + size_t before = this->events.size(); + this->component.loop(); + return {this->events.begin() + before, this->events.end()}; + } + + Passes passes(size_t count) { + Passes result; + for (size_t i = 0; i < count; i++) + result.push_back(this->pass()); + return result; + } + + BME680BSECComponent component; + sensor::Sensor temperature, pressure, humidity, gas_resistance, iaq, iaq_accuracy, co2_equivalent, + breath_voc_equivalent; + text_sensor::TextSensor iaq_accuracy_text; + std::vector events; + + private: + void add_(sensor::Sensor &sensor, const char *name) { + sensor.add_on_state_callback([this, name](float value) { + char buf[64]; + snprintf(buf, sizeof(buf), "%s=%g", name, value); + this->events.push_back(buf); + }); + } +}; + +TEST_F(BSECPublishTest, PublishesOneStatePerLoopPassInOutputOrder) { + this->attach_all(); + this->arm({ + output(BSEC_OUTPUT_STATIC_IAQ, 51.5f, 2), + output(BSEC_OUTPUT_CO2_EQUIVALENT, 612.5f), + output(BSEC_OUTPUT_BREATH_VOC_EQUIVALENT, 0.75f), + output(BSEC_OUTPUT_RAW_PRESSURE, 101325.0f), + output(BSEC_OUTPUT_RAW_GAS, 125000.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.5f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 44.5f), + }); + // The pass that reads the outputs also publishes the first state. + EXPECT_EQ(this->passes(12), (Passes{ + {"iaq=51.5"}, + {"iaq_accuracy_text=Calibrating"}, + {"iaq_accuracy=2"}, + {}, // state save opportunity + {"co2_equivalent=612.5"}, + {"breath_voc_equivalent=0.75"}, + {"pressure=1013.25"}, + {"gas_resistance=125000"}, + {"temperature=21.5"}, + {"humidity=44.5"}, + {}, + {}, + })); + EXPECT_EQ(bsec_double::get_state_calls, 0); +} + +TEST_F(BSECPublishTest, KeepsTheOrderBsecReturnsOutputsIn) { + this->attach_all(); + this->arm({ + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 44.5f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.5f), + output(BSEC_OUTPUT_IAQ, 30.0f, 1), + output(BSEC_OUTPUT_RAW_GAS, 125000.0f), + }); + EXPECT_EQ(this->passes(8), (Passes{ + {"humidity=44.5"}, + {"temperature=21.5"}, + {"iaq=30"}, + {"iaq_accuracy_text=Uncertain"}, + {"iaq_accuracy=1"}, + {}, + {"gas_resistance=125000"}, + {}, + })); +} + +TEST_F(BSECPublishTest, UnconfiguredSensorStillTakesAPass) { + this->component.set_temperature_sensor(&this->temperature); + this->arm({ + output(BSEC_OUTPUT_RAW_PRESSURE, 100000.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.5f), + }); + EXPECT_EQ(this->passes(3), (Passes{{}, {"temperature=21.5"}, {}})); +} + +TEST_F(BSECPublishTest, AccuracyIsClampedAndSavesStateOnceCalibrated) { + this->attach_all(); + this->component.set_state_save_interval(0); + this->arm({output(BSEC_OUTPUT_IAQ, 30.0f, 7)}); + EXPECT_EQ(this->pass(), (std::vector{"iaq=30"})); + EXPECT_EQ(this->pass(), (std::vector{"iaq_accuracy_text=Calibrated"})); + EXPECT_EQ(this->pass(), (std::vector{"iaq_accuracy=3"})); + EXPECT_EQ(bsec_double::get_state_calls, 0); + EXPECT_TRUE(this->pass().empty()); + EXPECT_EQ(bsec_double::get_state_calls, 1); + EXPECT_TRUE(this->pass().empty()); + EXPECT_EQ(bsec_double::get_state_calls, 1); +} + +TEST_F(BSECPublishTest, UnchangedAccuracyIsNotRepublished) { + this->attach_all(); + this->arm({output(BSEC_OUTPUT_IAQ, 30.0f, 1)}); + this->passes(5); + this->events.clear(); + this->arm({output(BSEC_OUTPUT_IAQ, 31.0f, 1)}); + EXPECT_EQ(this->passes(5), (Passes{{"iaq=31"}, {}, {}, {}, {}})); + this->arm({output(BSEC_OUTPUT_IAQ, 32.0f, 2)}); + EXPECT_EQ(this->passes(5), (Passes{{"iaq=32"}, {"iaq_accuracy_text=Calibrating"}, {"iaq_accuracy=2"}, {}, {}})); +} + +TEST_F(BSECPublishTest, NewReadingBeforeTheLastWentOut) { + this->attach_all(); + this->arm({ + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 20.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 40.0f), + }); + EXPECT_EQ(this->pass(), (std::vector{"temperature=20"})); + this->arm({ + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 41.0f), + }); + // Both readings go out in full, in arrival order. + EXPECT_EQ(this->passes(4), (Passes{{"humidity=40"}, {"temperature=21"}, {"humidity=41"}, {}})); +} + +} // namespace +} // namespace esphome::bme680_bsec::testing +#endif // USE_BSEC From a01b213ab4d459eb758bb0754b2d786dd3a1d7ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:49:42 -0600 Subject: [PATCH 149/170] [bme68x_bsec2] Add unit tests for publishing readings (#22189) --- tests/components/bme68x_bsec2/__init__.py | 22 ++ tests/components/bme68x_bsec2/__sdk/bsec2.h | 163 +++++++++++++ .../components/bme68x_bsec2/test_publish.cpp | 225 ++++++++++++++++++ 3 files changed, 410 insertions(+) create mode 100644 tests/components/bme68x_bsec2/__init__.py create mode 100644 tests/components/bme68x_bsec2/__sdk/bsec2.h create mode 100644 tests/components/bme68x_bsec2/test_publish.cpp diff --git a/tests/components/bme68x_bsec2/__init__.py b/tests/components/bme68x_bsec2/__init__.py new file mode 100644 index 00000000000..1b93dd955d0 --- /dev/null +++ b/tests/components/bme68x_bsec2/__init__.py @@ -0,0 +1,22 @@ +"""Build the BSEC2 component on the host against the BSEC2/BME68x double in __sdk.""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.types import ConfigType +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = ["sensor", "text_sensor"] + manifest.config_schema = cv.Schema({}) + + async def to_code_testing(config: ConfigType) -> None: + cg.add_build_flag(f"-I{CORE.relative_src_path('bme68x_bsec2', '__sdk')}") + cg.add_define("USE_BSEC2") + cg.add_define("USE_SENSOR") + cg.add_define("ESPHOME_ENTITY_SENSOR_COUNT", 0) + cg.add_define("USE_TEXT_SENSOR") + cg.add_define("ESPHOME_ENTITY_TEXT_SENSOR_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/bme68x_bsec2/__sdk/bsec2.h b/tests/components/bme68x_bsec2/__sdk/bsec2.h new file mode 100644 index 00000000000..51e54726cf7 --- /dev/null +++ b/tests/components/bme68x_bsec2/__sdk/bsec2.h @@ -0,0 +1,163 @@ +#pragma once + +// Host double for the BSEC2 and BME68x libraries: just the types, constants and calls the component uses. +// Every call succeeds; the tests drive the publish path directly. +#include + +static constexpr int8_t BME68X_OK = 0; +static constexpr int8_t BME68X_W_NO_NEW_DATA = 2; +static constexpr uint8_t BME68X_ENABLE = 1; +static constexpr uint8_t BME68X_SLEEP_MODE = 0; +static constexpr uint8_t BME68X_FORCED_MODE = 1; +static constexpr uint8_t BME68X_PARALLEL_MODE = 2; +static constexpr uint8_t BME68X_GASM_VALID_MSK = 0x20; + +static constexpr uint32_t BSEC_INSTANCE_SIZE = 8; +static constexpr uint32_t BSEC_MAX_PROPERTY_BLOB_SIZE = 16; +static constexpr uint32_t BSEC_MAX_STATE_BLOB_SIZE = 16; +static constexpr uint32_t BSEC_MAX_WORKBUFFER_SIZE = 16; +static constexpr uint8_t BSEC_NUMBER_OUTPUTS = 20; +static constexpr uint8_t BSEC_MAX_PHYSICAL_SENSOR = 8; +static constexpr uint16_t BSEC_TOTAL_HEAT_DUR = 140; +#define BSEC_SAMPLE_RATE_LP 0.33333f +#define BSEC_SAMPLE_RATE_ULP 0.0033333f +#define BSEC_CHECK_INPUT(x, shift) ((x) & (1 << ((shift) - 1))) + +enum { + BSEC_INPUT_PRESSURE = 1, + BSEC_INPUT_HUMIDITY = 2, + BSEC_INPUT_TEMPERATURE = 3, + BSEC_INPUT_GASRESISTOR = 4, + BSEC_INPUT_HEATSOURCE = 14, + BSEC_INPUT_PROFILE_PART = 24, +}; + +enum { + BSEC_OUTPUT_IAQ = 1, + BSEC_OUTPUT_STATIC_IAQ = 2, + BSEC_OUTPUT_CO2_EQUIVALENT = 3, + BSEC_OUTPUT_BREATH_VOC_EQUIVALENT = 4, + BSEC_OUTPUT_RAW_PRESSURE = 7, + BSEC_OUTPUT_RAW_GAS = 9, + BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE = 14, + BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY = 15, +}; + +enum bsec_library_return_t { + BSEC_OK = 0, + BSEC_I_SU_SUBSCRIBEDOUTPUTGATES = 12, +}; + +struct bsec_version_t { + uint8_t major, minor, major_bugfix, minor_bugfix; +}; + +struct bsec_input_t { + int64_t time_stamp; + float signal; + uint8_t signal_dimensions; + uint8_t sensor_id; +}; + +struct bsec_output_t { + int64_t time_stamp; + float signal; + uint8_t signal_dimensions; + uint8_t sensor_id; + uint8_t accuracy; +}; + +struct bsec_sensor_configuration_t { + float sample_rate; + uint8_t sensor_id; +}; + +struct bsec_bme_settings_t { + int64_t next_call; + uint32_t process_data; + uint16_t heater_temperature; + uint16_t heater_duration; + uint16_t heater_temperature_profile[10]; + uint16_t heater_duration_profile[10]; + uint8_t heater_profile_len; + uint8_t run_gas; + uint8_t pressure_oversampling; + uint8_t temperature_oversampling; + uint8_t humidity_oversampling; + uint8_t trigger_measurement; + uint8_t op_mode; +}; + +struct bme68x_dev { + int dummy; +}; +struct bme68x_conf { + uint8_t os_hum, os_temp, os_pres; +}; +struct bme68x_heatr_conf { + uint8_t enable; + uint16_t heatr_temp; + uint16_t heatr_dur; + uint16_t *heatr_temp_prof; + uint16_t *heatr_dur_prof; + uint8_t profile_len; + uint16_t shared_heatr_dur; +}; +struct bme68x_data { + uint8_t status; + uint8_t gas_index; + float temperature; + float pressure; + float humidity; + float gas_resistance; +}; + +namespace bsec2_double { +// Counts bsec_get_state_m calls, which the component makes only when it saves its state. +inline int get_state_calls = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +} // namespace bsec2_double + +inline bsec_library_return_t bsec_init_m(void *) { return BSEC_OK; } +inline bsec_library_return_t bsec_get_version_m(void *, bsec_version_t *version) { + *version = {}; + return BSEC_OK; +} +inline bsec_library_return_t bsec_set_configuration_m(void *, const uint8_t *, uint32_t, uint8_t *, uint32_t) { + return BSEC_OK; +} +inline bsec_library_return_t bsec_update_subscription_m(void *, const bsec_sensor_configuration_t *, uint8_t, + bsec_sensor_configuration_t *, uint8_t *) { + return BSEC_OK; +} +inline bsec_library_return_t bsec_sensor_control_m(void *, int64_t, bsec_bme_settings_t *settings) { + *settings = {}; + return BSEC_OK; +} +inline bsec_library_return_t bsec_do_steps_m(void *, const bsec_input_t *, uint8_t, bsec_output_t *, + uint8_t *num_outputs) { + *num_outputs = 0; + return BSEC_OK; +} +inline bsec_library_return_t bsec_set_state_m(void *, const uint8_t *, uint32_t, uint8_t *, uint32_t) { + return BSEC_OK; +} +inline bsec_library_return_t bsec_get_state_m(void *, uint8_t, uint8_t *, uint32_t, uint8_t *, uint32_t, + uint32_t *num_serialized_state) { + bsec2_double::get_state_calls++; + *num_serialized_state = 0; + return BSEC_OK; +} + +inline int8_t bme68x_init(bme68x_dev *) { return BME68X_OK; } +inline int8_t bme68x_get_conf(bme68x_conf *conf, bme68x_dev *) { + *conf = {}; + return BME68X_OK; +} +inline int8_t bme68x_set_conf(bme68x_conf *, bme68x_dev *) { return BME68X_OK; } +inline int8_t bme68x_set_heatr_conf(uint8_t, const bme68x_heatr_conf *, bme68x_dev *) { return BME68X_OK; } +inline int8_t bme68x_set_op_mode(uint8_t, bme68x_dev *) { return BME68X_OK; } +inline uint32_t bme68x_get_meas_dur(uint8_t, bme68x_conf *, bme68x_dev *) { return 0; } +inline int8_t bme68x_get_data(uint8_t, bme68x_data *, uint8_t *n_data, bme68x_dev *) { + *n_data = 0; + return BME68X_W_NO_NEW_DATA; +} diff --git a/tests/components/bme68x_bsec2/test_publish.cpp b/tests/components/bme68x_bsec2/test_publish.cpp new file mode 100644 index 00000000000..3669b2323bb --- /dev/null +++ b/tests/components/bme68x_bsec2/test_publish.cpp @@ -0,0 +1,225 @@ +#include + +#include +#include +#include + +#include "esphome/components/bme68x_bsec2/bme68x_bsec2.h" + +// The unit test build defines USE_BSEC2 and builds against the library double in __sdk; clang-tidy does not. +#ifdef USE_BSEC2 + +namespace esphome::bme68x_bsec2::testing { +namespace { + +class TestBSEC2 : public BME68xBSEC2Component { + public: + uint32_t get_hash() override { return 0; } + using BME68xBSEC2Component::publish_; + // setup() leaves the loop disabled until the first reading. + void start_idle() { this->disable_loop(); } +}; + +bsec_output_t output(uint8_t sensor_id, float signal, uint8_t accuracy = 0) { + bsec_output_t out{}; + out.sensor_id = sensor_id; + out.signal = signal; + out.accuracy = accuracy; + return out; +} + +class BSEC2PublishTest : public ::testing::Test { + protected: + void SetUp() override { + bsec2_double::get_state_calls = 0; + this->add_(this->temperature, "temperature"); + this->add_(this->pressure, "pressure"); + this->add_(this->humidity, "humidity"); + this->add_(this->gas_resistance, "gas_resistance"); + this->add_(this->iaq, "iaq"); + this->add_(this->iaq_static, "iaq_static"); + this->add_(this->iaq_accuracy, "iaq_accuracy"); + this->add_(this->co2_equivalent, "co2_equivalent"); + this->add_(this->breath_voc_equivalent, "breath_voc_equivalent"); + this->iaq_accuracy_text.add_on_state_callback( + [this](const std::string &state) { this->events.push_back("iaq_accuracy_text=" + state); }); + this->component.start_idle(); + } + + void attach_all() { + this->component.set_temperature_sensor(&this->temperature); + this->component.set_pressure_sensor(&this->pressure); + this->component.set_humidity_sensor(&this->humidity); + this->component.set_gas_resistance_sensor(&this->gas_resistance); + this->component.set_iaq_sensor(&this->iaq); + this->component.set_iaq_static_sensor(&this->iaq_static); + this->component.set_iaq_accuracy_sensor(&this->iaq_accuracy); + this->component.set_co2_equivalent_sensor(&this->co2_equivalent); + this->component.set_breath_voc_equivalent_sensor(&this->breath_voc_equivalent); + this->component.set_iaq_accuracy_text_sensor(&this->iaq_accuracy_text); + } + + void publish(const std::vector &outputs) { this->component.publish_(outputs.data(), outputs.size()); } + + // Runs loop() once and returns what that pass published. + std::vector pass() { + size_t before = this->events.size(); + this->component.loop(); + return {this->events.begin() + before, this->events.end()}; + } + + // Runs loop() until it disables itself; returns the publishes, one entry per pass. + std::vector> drain() { + std::vector> passes; + while (this->component.is_in_loop_state()) { + passes.push_back(this->pass()); + if (passes.size() > 100) { + ADD_FAILURE() << "loop never disabled itself"; + break; + } + } + return passes; + } + + TestBSEC2 component; + sensor::Sensor temperature, pressure, humidity, gas_resistance, iaq, iaq_static, iaq_accuracy, co2_equivalent, + breath_voc_equivalent; + text_sensor::TextSensor iaq_accuracy_text; + std::vector events; + + private: + void add_(sensor::Sensor &sensor, const char *name) { + sensor.add_on_state_callback([this, name](float value) { + char buf[64]; + snprintf(buf, sizeof(buf), "%s=%g", name, value); + this->events.push_back(buf); + }); + } +}; + +using Passes = std::vector>; + +TEST_F(BSEC2PublishTest, PublishesOneStatePerLoopPassInOutputOrder) { + this->attach_all(); + this->publish({ + output(BSEC_OUTPUT_IAQ, 51.5f, 1), + output(BSEC_OUTPUT_STATIC_IAQ, 48.25f, 2), + output(BSEC_OUTPUT_CO2_EQUIVALENT, 612.5f), + output(BSEC_OUTPUT_BREATH_VOC_EQUIVALENT, 0.75f), + output(BSEC_OUTPUT_RAW_PRESSURE, 101325.0f), + output(BSEC_OUTPUT_RAW_GAS, 125000.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.5f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 44.5f), + }); + // Nothing goes out until loop() runs. + EXPECT_TRUE(this->events.empty()); + EXPECT_TRUE(this->component.is_in_loop_state()); + + EXPECT_EQ(this->drain(), (Passes{ + {"iaq=51.5"}, + {"iaq_static=48.25"}, + {"co2_equivalent=612.5"}, + {"breath_voc_equivalent=0.75"}, + {"pressure=1013.25"}, + {"gas_resistance=125000"}, + {"temperature=21.5"}, + {"humidity=44.5"}, + {"iaq_accuracy=2"}, + {"iaq_accuracy_text=Calibrating"}, + {}, // state save opportunity + {}, // finds nothing left and disables the loop + })); + EXPECT_EQ(bsec2_double::get_state_calls, 0); +} + +TEST_F(BSEC2PublishTest, KeepsTheOrderBsecReturnsOutputsIn) { + this->attach_all(); + this->publish({ + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 44.5f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.5f), + output(BSEC_OUTPUT_IAQ, 51.5f, 3), + output(BSEC_OUTPUT_RAW_GAS, 125000.0f), + }); + EXPECT_EQ(this->drain(), (Passes{ + {"humidity=44.5"}, + {"temperature=21.5"}, + {"iaq=51.5"}, + {"gas_resistance=125000"}, + {"iaq_accuracy=3"}, + {"iaq_accuracy_text=Calibrated"}, + {}, + {}, + })); + // Calibrated, but inside the default 6 h state save interval. + EXPECT_EQ(bsec2_double::get_state_calls, 0); +} + +TEST_F(BSEC2PublishTest, NoAccuracyWithoutAnIaqOutput) { + this->attach_all(); + this->publish({ + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.5f), + output(BSEC_OUTPUT_RAW_PRESSURE, 100000.0f), + }); + EXPECT_EQ(this->drain(), (Passes{{"temperature=21.5"}, {"pressure=1000"}, {}})); +} + +TEST_F(BSEC2PublishTest, UnconfiguredSensorStillTakesAPass) { + this->component.set_temperature_sensor(&this->temperature); + this->publish({ + output(BSEC_OUTPUT_RAW_PRESSURE, 100000.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.5f), + }); + EXPECT_EQ(this->drain(), (Passes{{}, {"temperature=21.5"}, {}})); +} + +TEST_F(BSEC2PublishTest, EmptyReadingEnablesLoopUntilTheNextPass) { + this->attach_all(); + this->publish({}); + EXPECT_TRUE(this->component.is_in_loop_state()); + EXPECT_EQ(this->drain(), (Passes{{}})); +} + +TEST_F(BSEC2PublishTest, AccuracyIsClampedAndSavesStateOnceCalibrated) { + this->attach_all(); + this->component.set_state_save_interval(0); + this->publish({output(BSEC_OUTPUT_IAQ, 30.0f, 7)}); + EXPECT_EQ(this->pass(), (std::vector{"iaq=30"})); + EXPECT_EQ(this->pass(), (std::vector{"iaq_accuracy=3"})); + EXPECT_EQ(this->pass(), (std::vector{"iaq_accuracy_text=Calibrated"})); + EXPECT_EQ(bsec2_double::get_state_calls, 0); + EXPECT_TRUE(this->pass().empty()); + EXPECT_EQ(bsec2_double::get_state_calls, 1); + EXPECT_TRUE(this->component.is_in_loop_state()); + EXPECT_TRUE(this->pass().empty()); + EXPECT_FALSE(this->component.is_in_loop_state()); +} + +TEST_F(BSEC2PublishTest, UnchangedAccuracyIsNotRepublished) { + this->attach_all(); + this->publish({output(BSEC_OUTPUT_IAQ, 30.0f, 1)}); + this->drain(); + this->events.clear(); + this->publish({output(BSEC_OUTPUT_IAQ, 31.0f, 1)}); + EXPECT_EQ(this->drain(), (Passes{{"iaq=31"}, {}, {}, {}, {}})); + this->publish({output(BSEC_OUTPUT_IAQ, 32.0f, 2)}); + EXPECT_EQ(this->drain(), (Passes{{"iaq=32"}, {"iaq_accuracy=2"}, {"iaq_accuracy_text=Calibrating"}, {}, {}})); +} + +TEST_F(BSEC2PublishTest, NewReadingBeforeTheLastWentOut) { + this->attach_all(); + this->publish({ + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 20.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 40.0f), + }); + EXPECT_EQ(this->pass(), (std::vector{"temperature=20"})); + this->publish({ + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE, 21.0f), + output(BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY, 41.0f), + }); + // Both readings go out in full, in arrival order. + EXPECT_EQ(this->drain(), (Passes{{"humidity=40"}, {"temperature=21"}, {"humidity=41"}, {}})); +} + +} // namespace +} // namespace esphome::bme68x_bsec2::testing +#endif // USE_BSEC2 From 891ebdaaaa0f942222d5013ced7d0642ed5b6d4a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 16:50:38 -0600 Subject: [PATCH 150/170] [espota2] Make the probe deadline test deterministic (#22234) --- tests/unit_tests/test_espota2_noise.py | 27 ++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/unit_tests/test_espota2_noise.py b/tests/unit_tests/test_espota2_noise.py index a49eecf3719..3afa6197bb1 100644 --- a/tests/unit_tests/test_espota2_noise.py +++ b/tests/unit_tests/test_espota2_noise.py @@ -91,6 +91,7 @@ class FakeEncryptedDevice(threading.Thread): self.prologue_features_override = prologue_features_override self.received: bytes | None = None self.probes = 0 # clients that left right after the handshake + self.served = 0 self.error: Exception | None = None self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.listener.bind(("127.0.0.1", 0)) @@ -104,8 +105,7 @@ class FakeEncryptedDevice(threading.Thread): def run(self) -> None: try: - served = 0 - while served < self.connections and not self._client_done.is_set(): + while self.served < self.connections and not self._client_done.is_set(): try: sock, _ = self.listener.accept() except TimeoutError: @@ -113,7 +113,7 @@ class FakeEncryptedDevice(threading.Thread): sock.settimeout(10) with sock: self._serve(sock) - served += 1 + self.served += 1 except Exception as err: # noqa: BLE001 - surfaced via join_and_check self.error = err finally: @@ -820,14 +820,29 @@ def test_probe_ota_key_gives_up_at_the_deadline( device_kwargs: dict[str, Any], caplog: pytest.LogCaptureFixture ) -> None: pytest.importorskip("aioesphomeapi.noise") - device = FakeEncryptedDevice(connections=2, **device_kwargs) + # One spare connection, so a third attempt would be served and counted + device = FakeEncryptedDevice(connections=3, **device_kwargs) device.start() - with patch("time.sleep"), caplog.at_level(logging.WARNING): + # Only the retry pauses move the clock, so a slow runner cannot reach + # the deadline in the middle of a handshake + clock = [0.0] + + def sleep(seconds: float) -> None: + clock[0] += seconds + + with ( + patch("time.sleep", side_effect=sleep), + patch("time.monotonic", side_effect=lambda: clock[0]), + caplog.at_level(logging.WARNING), + ): assert ( - espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=0.5) + espota2.probe_ota_key("127.0.0.1", device.port, PSK, timeout=5) is not ProbeResult.ACCEPTED ) device.join_and_check() + # Tried at 0 and 3 s; the last pause stops at the deadline + assert device.served == 2 + assert clock[0] == 5 assert any("Checking the key failed" in r.message for r in caplog.records) assert device.received is None From 7637cdc1627047c4c0a50339250bf8cff6458124 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 17:58:34 -0600 Subject: [PATCH 151/170] [web_server] Keep a custom js_url/css_url on AP only WiFi (#21879) --- esphome/components/web_server/__init__.py | 86 ++++++++++---- .../unit_tests/components/test_web_server.py | 112 ++++++++++++++++++ 2 files changed, 176 insertions(+), 22 deletions(-) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 4443d4c09b9..f2b9b945857 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -93,23 +93,22 @@ WebServer = web_server_ns.class_("WebServer", cg.Component) sorting_groups = {} +# (css_url, js_url) filled in for each version when the config leaves them out. +DEFAULT_URLS = { + 1: ( + "https://oi.esphome.io/v1/webserver-v1.min.css", + "https://oi.esphome.io/v1/webserver-v1.min.js", + ), + 2: ("", "https://oi.esphome.io/v2/www.js"), + 3: ("", "https://oi.esphome.io/v3/www.js"), +} + + def default_url(config: ConfigType) -> ConfigType: config = config.copy() - if config[CONF_VERSION] == 1: - if CONF_CSS_URL not in config: - config[CONF_CSS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.css" - if CONF_JS_URL not in config: - config[CONF_JS_URL] = "https://oi.esphome.io/v1/webserver-v1.min.js" - if config[CONF_VERSION] == 2: - if CONF_CSS_URL not in config: - config[CONF_CSS_URL] = "" - if CONF_JS_URL not in config: - config[CONF_JS_URL] = "https://oi.esphome.io/v2/www.js" - if config[CONF_VERSION] == 3: - if CONF_CSS_URL not in config: - config[CONF_CSS_URL] = "" - if CONF_JS_URL not in config: - config[CONF_JS_URL] = "https://oi.esphome.io/v3/www.js" + css_url, js_url = DEFAULT_URLS[config[CONF_VERSION]] + config.setdefault(CONF_CSS_URL, css_url) + config.setdefault(CONF_JS_URL, js_url) return config @@ -336,12 +335,37 @@ def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool: ) +def custom_frontend(config: ConfigType) -> bool: + """The config loads its own interface: a URL other than the default. Includes alone + still depend on the default hosted script, so they do not count.""" + css_url, js_url = DEFAULT_URLS[config[CONF_VERSION]] + return ( + config.get(CONF_CSS_URL, css_url) != css_url + or config.get(CONF_JS_URL, js_url) != js_url + ) + + +def loads_remote_assets(config: ConfigType) -> bool: + """The hosted page fetches a URL from another host; empty, relative and data URLs + are served by the device or inline.""" + css_url, js_url = DEFAULT_URLS[config[CONF_VERSION]] + return any( + url.lower().startswith(("http://", "https://", "//")) + for url in (config.get(CONF_CSS_URL, css_url), config.get(CONF_JS_URL, js_url)) + ) + + def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool: - """Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet - for the hosted page. Version 1 has no local mode.""" + """Embed the interface for AP only WiFi, which has no internet for the hosted page, + unless ``local:`` is set or the config brings its own interface. Version 1 has no + local mode.""" if (local := config.get(CONF_LOCAL)) is not None: return local - return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config) + return ( + config[CONF_VERSION] != 1 + and wifi_is_ap_only(wifi_config) + and not custom_frontend(config) + ) def _captive_portal_configured(full_config: ConfigType) -> bool: @@ -408,14 +432,32 @@ def _final_validate_ap_mode(config: ConfigType) -> None: ap_ip, config[CONF_PORT], ) - if ap_only and not local: + if ap_only and not local and not loads_remote_assets(config): + if CONF_LOCAL not in config and custom_frontend(config): + _LOGGER.info( + "WiFi is AP only but a custom js_url/css_url is set, so the built-in web " + "interface is not embedded; the device serves the custom one. Set " + "'local: true' to embed the built-in interface instead." + ) + elif ap_only and not local: + # Only local: true embeds once custom URLs are set, whatever else is changed. + local_true = ( + "'local: true' (which replaces the custom js_url/css_url with the " + "built-in interface)" + ) + if config[CONF_VERSION] == 1: + hint = "Migrate to version 2 or 3" + if custom_frontend(config): + hint += f" and set {local_true}" + elif custom_frontend(config): + hint = f"Set {local_true}" + else: + hint = "Remove 'local: false'" _LOGGER.warning( "WiFi is AP only and the web_server interface is loaded from the internet, " "which browsers on the access point usually cannot reach; the page stays " "blank. %s so the interface is embedded in the firmware.", - "Remove 'local: false'" - if config.get(CONF_LOCAL) is False - else "Migrate to version 2 or 3", + hint, ) diff --git a/tests/unit_tests/components/test_web_server.py b/tests/unit_tests/components/test_web_server.py index 3015527c18c..2b616533f89 100644 --- a/tests/unit_tests/components/test_web_server.py +++ b/tests/unit_tests/components/test_web_server.py @@ -6,11 +6,15 @@ import pytest from esphome.components.web_server import ( _final_validate_ap_mode, + default_url, serve_captive, serve_local, ) from esphome.const import ( CONF_AP, + CONF_CSS_URL, + CONF_JS_INCLUDE, + CONF_JS_URL, CONF_LOCAL, CONF_NETWORKS, CONF_PORT, @@ -23,6 +27,8 @@ import esphome.final_validate as fv AP_ONLY = {CONF_AP: {}} AP_FALLBACK = {CONF_AP: {}, CONF_NETWORKS: [{CONF_SSID: "x"}]} STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]} +CUSTOM_JS = "https://example.com/www.js" +CUSTOM_CSS = "https://example.com/www.css" @pytest.mark.parametrize( @@ -39,6 +45,14 @@ STA_ONLY = {CONF_NETWORKS: [{CONF_SSID: "x"}]} ({CONF_VERSION: 2}, STA_ONLY, False), ({CONF_VERSION: 2}, None, False), ({CONF_VERSION: 1}, AP_ONLY, False), + # The default URLs filled in by validation still embed. + (default_url({CONF_VERSION: 3}), AP_ONLY, True), + # A custom interface keeps the hosted page unless local: true asks for the embedded one. + ({CONF_VERSION: 2, CONF_JS_URL: CUSTOM_JS}, AP_ONLY, False), + (default_url({CONF_VERSION: 3, CONF_CSS_URL: CUSTOM_CSS}), AP_ONLY, False), + # An include alone still loads the default hosted script, so embed. + ({CONF_VERSION: 2, CONF_JS_INCLUDE: "www.js"}, AP_ONLY, True), + ({CONF_VERSION: 2, CONF_JS_URL: CUSTOM_JS, CONF_LOCAL: True}, AP_ONLY, True), ], ) def test_serve_local( @@ -60,6 +74,8 @@ def test_serve_local( ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: AP_FALLBACK}, True), # captive_portal owns the role when configured. ({CONF_VERSION: 2}, {CONF_WIFI: AP_ONLY, "captive_portal": {}}, False), + # A custom interface is not embedded, so it is not captive either. + ({CONF_VERSION: 2, CONF_JS_URL: CUSTOM_JS}, {CONF_WIFI: AP_ONLY}, False), # No AP, no wifi, hosted page, or version 1: never captive. ({CONF_VERSION: 2, CONF_LOCAL: True}, {CONF_WIFI: STA_ONLY}, False), ({CONF_VERSION: 2, CONF_LOCAL: True}, {}, False), @@ -79,8 +95,32 @@ def test_serve_captive( [ # Explicit local: false on an AP only device: the hosted page will stay blank. ({CONF_VERSION: 2, CONF_PORT: 80, CONF_LOCAL: False}, True), + # A custom interface is loaded from the internet too. + ({CONF_VERSION: 2, CONF_PORT: 80, CONF_JS_URL: CUSTOM_JS}, True), # Default: embedded and captive, nothing to warn about. ({CONF_VERSION: 2, CONF_PORT: 80}, False), + # Includes served by the device with no URLs: nothing loads from the internet. + ( + { + CONF_VERSION: 2, + CONF_PORT: 80, + CONF_JS_URL: "", + CONF_CSS_URL: "", + CONF_JS_INCLUDE: "www.js", + }, + False, + ), + # A relative URL is served by the device too. + ( + { + CONF_VERSION: 2, + CONF_PORT: 80, + CONF_JS_URL: "", + CONF_CSS_URL: "/0.css", + CONF_JS_INCLUDE: "www.js", + }, + False, + ), ], ) def test_final_validate_ap_mode_warns_for_hosted_page( @@ -95,6 +135,78 @@ def test_final_validate_ap_mode_warns_for_hosted_page( assert ("stays blank" in caplog.text) is expect_warning +def test_final_validate_ap_mode_warns_for_uppercase_scheme( + caplog: pytest.LogCaptureFixture, +) -> None: + """The URL scheme check ignores case.""" + web_server_config = { + CONF_VERSION: 2, + CONF_PORT: 80, + CONF_JS_URL: "HTTPS://example.com/www.js", + } + token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY}) + try: + with caplog.at_level(logging.WARNING): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + assert "stays blank" in caplog.text + + +@pytest.mark.parametrize( + ("web_server_config", "expect_info"), + [ + # A relative custom URL turns off embedding; say so. + ({CONF_VERSION: 2, CONF_JS_URL: "/www.js"}, True), + ({CONF_VERSION: 2, CONF_JS_URL: "", CONF_JS_INCLUDE: "www.js"}, True), + # An explicit local: false is the user's choice. + ({CONF_VERSION: 2, CONF_JS_URL: "/www.js", CONF_LOCAL: False}, False), + # The default interface is embedded, nothing to explain. + ({CONF_VERSION: 2}, False), + ], +) +def test_final_validate_ap_mode_informs_custom_local_frontend( + web_server_config: dict, expect_info: bool, caplog: pytest.LogCaptureFixture +) -> None: + web_server_config = {**web_server_config, CONF_PORT: 80} + token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY}) + try: + with caplog.at_level(logging.INFO): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + assert ("built-in web interface is not embedded" in caplog.text) is expect_info + assert "stays blank" not in caplog.text + + +@pytest.mark.parametrize( + ("web_server_config", "expected"), + [ + ({CONF_VERSION: 2}, "Set 'local: true' (which replaces the custom"), + # Removing local: false would not embed while the custom URL stays. + ({CONF_VERSION: 2, CONF_LOCAL: False}, "Set 'local: true' (which replaces"), + # Migrating alone would not embed either. + ( + {CONF_VERSION: 1}, + "Migrate to version 2 or 3 and set 'local: true' (which replaces", + ), + ], +) +def test_final_validate_ap_mode_custom_interface_hint( + web_server_config: dict, expected: str, caplog: pytest.LogCaptureFixture +) -> None: + """A custom interface on AP only WiFi is not embedded; every hint says local: true.""" + web_server_config = {**web_server_config, CONF_PORT: 80, CONF_JS_URL: CUSTOM_JS} + token = fv.full_config.set({"web_server": web_server_config, CONF_WIFI: AP_ONLY}) + try: + with caplog.at_level(logging.INFO): + _final_validate_ap_mode(web_server_config) + finally: + fv.full_config.reset(token) + assert expected in caplog.text + assert "embedding the web interface" not in caplog.text + + @pytest.mark.parametrize( ("web_server_config", "wifi_config", "expected"), [ From 75b9f8de1c42af887f2e31973dea9ead488cb6a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 17:59:16 -0600 Subject: [PATCH 152/170] [esp32_ble_client] Ignore a disconnect event from the previous link during a new connection (#21918) --- .../esp32_ble_client/ble_client_base.cpp | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index e627d58fc81..51259998065 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -363,17 +363,11 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } - const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN; - // Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT, - // so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption - // about how the ESP BT stack works, so log it. - const bool expected_failure = - open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID; - if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) { + if (this->state() != espbt::ClientState::CONNECTING) { ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state()), param->open.status); } - if (open_failed) { + if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); // Connection was never established so CLOSE_EVT may not follow this->set_idle_(); @@ -430,11 +424,10 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_, param->disconnect.reason); } - // For active disconnects (esp_ble_gattc_close), CLOSE_EVT arrives before - // DISCONNECT_EVT. If CLOSE_EVT already transitioned us to IDLE, don't go - // backwards to DISCONNECTING — the connection is already fully cleaned up. - if (this->state() == espbt::ClientState::IDLE) { - this->log_event_("DISCONNECT_EVT after CLOSE_EVT, already IDLE"); + // IDLE: CLOSE_EVT (active close) already cleaned up. No conn_id: no CONNECT_EVT for this attempt yet, + // so the event is left over from the previous link or precedes a failed open that OPEN_EVT handles. + if (this->state() == espbt::ClientState::IDLE || this->conn_id_ == UNSET_CONN_ID) { + this->log_event_("DISCONNECT_EVT with no live link, ignoring"); break; } // For passive disconnects (remote device disconnected or link lost), From c50a5a08791a920466ed225b0b54329f8d8a211b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 18:02:32 -0600 Subject: [PATCH 153/170] [core] Give apply action lambdas their arguments by value again (#22011) --- esphome/automation.py | 169 +++++++------ esphome/cpp_generator.py | 1 + .../esp32_ble_server/test_esp32_ble_server.py | 4 +- .../test_nec_lambda_args.py | 10 +- tests/components/light/common.yaml | 9 + .../remote_transmitter/common-buttons.yaml | 10 + tests/unit_tests/test_automation.py | 232 +++++++++++------- 7 files changed, 266 insertions(+), 169 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 6805b97b148..e1cea9281d8 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -3,6 +3,7 @@ from contextvars import ContextVar from dataclasses import dataclass, field import logging import math +import re import string from typing import Any @@ -623,34 +624,34 @@ async def _apply_parent(config: ConfigType, id_key: str = CONF_ID) -> str: return str(parent_ref(await cg.get_variable(config[id_key]))) -def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: - # The generated function's parameters: a reference keeps its spelling, so a mutable - # one stays mutable; a value is taken by const reference, so a std::string is never copied. - return [ - ( - ( - t - if str(cg.safe_exp(t)).rstrip().endswith("&") - else cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &") - ), - arg, - ) - for t, arg in args - ] +def _is_reference(type_: SafeExpType) -> bool: + return str(cg.safe_exp(type_)).rstrip().endswith("&") + + +def _apply_param_type(type_: SafeExpType) -> SafeExpType: + # A reference keeps its spelling; a value is taken by const reference, so it is never copied. + if _is_reference(type_): + return type_ + return cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(type_)}> &") + + +def _apply_params(args: TemplateArgsType) -> TemplateArgsType: + """The generated function's parameters.""" + return [(_apply_param_type(t), arg) for t, arg in args] def _apply_function( id_: ID, return_type: SafeExpType, template_arg: cg.TemplateArguments, - lambda_args: TemplateArgsType, + args: TemplateArgsType, statements: list[str], ) -> MockObj: """Emit the generated function and declare ``id_`` as the ``ApplyAction`` or ``ApplyCondition`` templated on it, so ``play()`` calls it directly.""" def emit() -> MockObj: - fn = apply_static_function(id_, return_type, lambda_args, statements) + fn = apply_static_function(id_, return_type, _apply_params(args), statements) return cg.new_Pvariable(id_, cg.TemplateArguments(fn, *template_arg)) return _COLLECTED_ACTION if _collect_action(id_, statements, emit) else emit() @@ -667,9 +668,7 @@ def apply_statements_action( Use it when the statements do not fit ``register_apply_action``. Generated names should start with ``esphome__`` so a trigger argument cannot shadow them. """ - return _apply_function( - action_id, cg.void, template_arg, _apply_lambda_args(args), statements - ) + return _apply_function(action_id, cg.void, template_arg, args, statements) def apply_static_function( @@ -682,19 +681,78 @@ def apply_static_function( return cg.static_function(f"esphome__{id_.id}__fn", return_type, params, statements) -async def _render_values( +# Comments, raw strings, strings and char literals; a ' after a digit is a digit separator. +_CPP_NOISE = re.compile( + r"//[^\n]*|/\*.*?\*/" + r'|\b(?:u8|u|U|L)?R"([^()\\\s]{0,16})\(.*?\)\1"' + r'|"(?:\\.|[^"\\\n])*"' + r"|(? str: + return _CPP_NOISE.sub(" ", text) + + +def _names_arg(code: str, name: str) -> bool: + """Whether ``code`` (with noise removed) names ``name`` other than as a member.""" + return re.search(rf"(?)(? bool: + depth = 0 + for ch in code: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == "," and depth == 0: + return True + return False + + +async def _call_user_lambda( + value: Lambda, args: TemplateArgsType, return_type: SafeExpType +) -> Expression: + """Call a config lambda in place; a value arg its body names is a copy it may modify. + + The other args are the generated function's const references, so a body that names + none of them is reduced to its expression, as ``call_lambda`` does. When unsure the + name check counts a use: a needless copy is only a cost, a missed one fails to compile. + """ + code = _cpp_code_only(value.value) + by_value = { + name for t, name in args if not _is_reference(t) and _names_arg(code, name) + } + params = [ + (t if name in by_value else _apply_param_type(t), name) for t, name in args + ] + inner = await cg.process_lambda(value, params, return_type=return_type) + if by_value: + return immediate_lambda_call(inner) + expr = call_lambda(inner) + # An uncast `a, b` would split into two arguments or end an initializer early + if isinstance(expr, cg.RawExpression) and _has_top_level_comma( + _cpp_code_only(str(expr)) + ): + return cg.RawExpression(f"({expr})") + return expr + + +async def _render_call( name: str, target: str, members: list[tuple[Any, Any, Any]], values: list[Any], config: ConfigType, parent: str | None, - lambda_args: TemplateArgsType, + args: TemplateArgsType, compare: bool = False, -) -> list[str]: - """Render the argument text of one statement; every key must be present. +) -> str: + """Render ``target`` with its config values filled in; every key must be present. - ``compare``: an inlined lambda expression is parenthesized so it binds as a whole + ``compare``: a reduced lambda expression is parenthesized so it binds as a whole beside an operator. """ if any(value is None for value in values): @@ -705,8 +763,7 @@ async def _render_values( if isinstance(value, Lambda): if isinstance(type_, str): type_ = cg.RawExpression(type_.format(parent=parent)) - inner = await cg.process_lambda(value, lambda_args, return_type=type_) - expr = call_lambda(inner) + expr = await _call_user_lambda(value, args, type_) bare = compare and isinstance(expr, cg.RawExpression) exprs.append(f"({expr})" if bare else str(expr)) elif isinstance(value, ID): @@ -716,7 +773,7 @@ async def _render_values( exprs.append(const_fn(config, value)) else: exprs.append(str(cg.safe_exp(value))) - return exprs + return target.format(*exprs, parent=parent) def _apply_values(config: ConfigType, members: list[tuple[Any, Any, Any]]) -> list[Any]: @@ -780,7 +837,6 @@ def apply_action_builder( args: TemplateArgsType, ) -> MockObj: parent = await _apply_parent(config, id_key) - lambda_args = _apply_lambda_args(args) receiver = "apply_call." if call else f"{parent}->" statements: list[str] = [] for spec in statements_spec: @@ -795,19 +851,17 @@ def apply_action_builder( values = _apply_values(config, members) if not _apply_call_active(members, values): continue - exprs = await _render_values( - name, target, members, values, config, parent, lambda_args + rendered = await _render_call( + name, target, members, values, config, parent, args ) - statements.append(f"{receiver}{target.format(*exprs, parent=parent)};") + statements.append(f"{receiver}{rendered};") if call: statements = [ f"auto apply_call = {parent}->{call}();", *statements, "apply_call.perform();", ] - return _apply_function( - action_id, cg.void, template_arg, lambda_args, statements - ) + return _apply_function(action_id, cg.void, template_arg, args, statements) return builder @@ -820,11 +874,7 @@ async def _render_bytes( args: TemplateArgsType, table_name: str, ) -> list[str]: - """The statements for one ``ApplyBytes``: what ``TemplatableBytes::visit`` does, inlined. - - The payload lambda keeps the automation's own argument types, as ``TemplatableBytes`` - gave it, and is always called, so a body that modifies an argument still compiles. - """ + """The statements for one ``ApplyBytes``: what ``TemplatableBytes::visit`` does, inlined.""" value = _config_lookup(config, spec.conf_key) if value is None: return [] @@ -834,11 +884,7 @@ async def _render_bytes( return f"{receiver}{target.format(data, parent=parent)};" if isinstance(value, Lambda): - inner = await cg.process_lambda( - value, args, return_type=cg.std_vector.template(cg.uint8) - ) - # Never reduced into the outer function, whose own parameters are const - call = CallExpression(inner, *[cg.MockObj(name) for _, name in args]) + call = await _call_user_lambda(value, args, cg.std_vector.template(cg.uint8)) return [ "{", f" const std::vector esphome__bytes = {call};", @@ -873,22 +919,6 @@ def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) - return not members or any(value is not None for value in values) -async def _render_check( - name: str, - target: str, - members: list[tuple[Any, Any, Any]], - values: list[Any], - config: ConfigType, - parent: str | None, - lambda_args: TemplateArgsType, -) -> str: - """Render a boolean check with ``values`` compared against config.""" - exprs = await _render_values( - name, target, members, values, config, parent, lambda_args, compare=True - ) - return target.format(*exprs, parent=parent) - - def register_apply_condition( name: str, schema: cv.Schema, @@ -921,16 +951,15 @@ def register_apply_condition( if defaults: config = {**defaults, **config} parent = await _apply_parent(config, id_key) - lambda_args = _apply_lambda_args(args) values = _apply_values(config, members) - check = await _render_check( - name, call.target, members, values, config, parent, lambda_args + check = await _render_call( + name, call.target, members, values, config, parent, args, compare=True ) return _apply_function( condition_id, cg.bool_, template_arg, - lambda_args, + args, [f"return {parent}->{check};"], ) @@ -1718,7 +1747,7 @@ def _emit_inline_run( cg.void, [ (cg.RawExpression("const int &"), _RUNNING_PARAM), - *_apply_lambda_args(args), + *_apply_params(args), ], statements, ) @@ -1926,7 +1955,7 @@ def _direct_function( return None statements = [line for line in _run_statements(plan[0]) if line != _STOP_CHECK] return apply_static_function( - plan[0][0].id_, cg.void, _apply_lambda_args(args), statements + plan[0][0].id_, cg.void, _apply_params(args), statements ) @@ -1982,27 +2011,27 @@ async def _trigger_callback( ) -> LambdaExpression: if (target := _direct_function(plan, args, config)) is None: target = f"{parent_ref(_automation_from_plan(plan, args, config))}->trigger" - lambda_args = _apply_lambda_args(params) statements: list[str] = [] if call is not None: members = call.members values = _apply_values(config, members) if _apply_call_active(members, values): - check = await _render_check( + check = await _render_call( "trigger filter", call.target, members, values, config, None if parent is None else str(parent_ref(parent)), - lambda_args, + params, + compare=True, ) statements.append(f"if (!({check}))\n return;") if forward is None: forward = [name for _, name in params] statements.append(f"{target}({', '.join(map(str, forward))});") return LambdaExpression( - ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + ["\n".join(statements)], _apply_params(params), capture="", return_type=cg.void ) diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index ae7347e875f..527b95efed2 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1372,6 +1372,7 @@ def call_lambda(lamb: LambdaExpression) -> Expression: Given a lambda, either reduce to a simple expression or call it, possibly with parameters from the surrounding context. This is for use only with value-returning lambdas, used in places where the value of a lambda call is needed. + Use only where the surrounding scope declares the lambda's parameters with the same types. :param lamb: The LambdaExpression to call or reduce :return: An Expression representing the result of calling the lambda or reducing it to a simple expression """ diff --git a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py index 88dc6f5591f..88248377232 100644 --- a/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py +++ b/tests/component_tests/esp32_ble_server/test_esp32_ble_server.py @@ -193,7 +193,9 @@ def test_numeric_comparison_reply_uses_esp32_builder( main_cpp = generate_main(component_config_path("pairing.yaml")) # The esp32 schema and builder are used, not zephyr_ble_server's - assert "numeric_comparison_reply(static_cast(address), true);" in main_cpp + # The lambda names address, so it gets its own copy and is called in place + assert "->numeric_comparison_reply([](uint64_t address," in main_cpp + assert "}(address, passkey), true);" in main_cpp def _permissions(char: dict[str, object]) -> tuple[str, dict[int, str]]: diff --git a/tests/component_tests/remote_transmitter/test_nec_lambda_args.py b/tests/component_tests/remote_transmitter/test_nec_lambda_args.py index 8e1a6d5741e..fe4e935136a 100644 --- a/tests/component_tests/remote_transmitter/test_nec_lambda_args.py +++ b/tests/component_tests/remote_transmitter/test_nec_lambda_args.py @@ -17,10 +17,8 @@ def test_nec_field_lambdas_get_the_script_args( r"static void esphome__\w+__fn\(const std::remove_cvref_t & x\) \{", main_cpp, ) - assert ( - "apply_call.data.address = [](const std::remove_cvref_t & x) -> uint16_t {" - in body - ) + assert "apply_call.data.address = [](int x) -> uint16_t {" in body assert "x *= 2; return x;" in body - assert "}(x);" in body - assert "apply_call.data.command = static_cast(x *= 2);" in body + assert "apply_call.data.command = [](int x) -> uint16_t {" in body + assert "return x *= 2;" in body + assert body.count("}(x);") == 2 diff --git a/tests/components/light/common.yaml b/tests/components/light/common.yaml index 93e4b7b76e4..bbad9e873c1 100644 --- a/tests/components/light/common.yaml +++ b/tests/components/light/common.yaml @@ -291,6 +291,15 @@ script: id: test_monochromatic_light relative_brightness: !lambda "step *= 0.5f; return step;" transition_length: !lambda "return step *= 100.0f;" + # Field lambdas take the script arguments by value, so they may modify them + - id: light_lambda_args + parameters: + x: float + then: + - light.turn_on: + id: test_monochromatic_light + brightness: !lambda "x *= 0.5f; return x;" + transition_length: !lambda "return x *= 1000.0f;" output: - platform: template diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 350d5e3d7c5..af79412ce0b 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -1094,3 +1094,13 @@ script: group: !lambda x *= 1; return std::string("a"); device: !lambda return x *= 1; state: !lambda x *= 1; return true; + # Field lambdas take the script arguments by value, so they may modify them + - id: remote_transmitter_nec_lambda_args + parameters: + x: int + then: + - remote_transmitter.transmit_nec: + address: !lambda "x *= 2; return x;" + command: !lambda "return x *= 2;" + repeat: + times: !lambda "return x *= 2;" diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 4a7e248176d..b9caa00c3bc 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -1158,80 +1158,144 @@ async def test_apply_condition_id_constant_is_the_named_object( ) +def _by_value_call(type_: str, body: str) -> str: + """A config lambda called in place with arg ``x`` by value, whitespace folded.""" + return f"[]({type_} x) -> {type_} {{ {_squash(body)} }}(x)" + + +LAMBDA_BODIES = [ + "return x * 2;", + "if (x) return 1;\nreturn 2;", + "x *= 2;\nreturn x;", + "return x *= 2;", +] + + @pytest.mark.asyncio +@pytest.mark.parametrize("body", LAMBDA_BODIES) async def test_apply_lambdas( - registries: tuple[Registry, Registry], mock_cg: MockCodegen + registries: tuple[Registry, Registry], mock_cg: MockCodegen, body: str ) -> None: - """A single return reduces to a cast, anything longer is called inline with the trigger args.""" - fields = ( - ApplyField("kp", "set_kp", cg.float_), - ApplyField("ki", "set_ki", cg.float_), + """Every lambda is called in place with the trigger arg by value, so it may modify it.""" + fields = (ApplyField("kp", "set_kp", cg.int32),) + await _run_apply_action( + registries, fields, {"kp": Lambda(body)}, args=[(cg.int32, "x")] ) - config = { - "kp": Lambda("return x * 2;"), - "ki": Lambda("if (x) return 1.0f;\nreturn 2.0f;"), - } - await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) - text = _apply_definition(mock_cg) - assert text.startswith( - "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" - ) - # The parent is global-scope qualified, so an arg named like the id cannot shadow it. - assert f"::{PARENT_OBJ}->set_kp(" in text - assert f"::{PARENT_OBJ}->set_kp(static_cast(x * 2));" in text - # Outer apply lambda and inner field lambda spell the trigger arg identically. - assert text.count("const std::remove_cvref_t & x") == 2 - assert ( - f"::{PARENT_OBJ}->set_ki([](const std::remove_cvref_t & x) -> float {{" - in text - ) - assert "}(x));" in text - - -@pytest.mark.asyncio -async def test_apply_lambda_modifying_its_arg( - registries: tuple[Registry, Registry], mock_cg: MockCodegen -) -> None: - """How a field lambda that modifies its trigger arg is rendered.""" - fields = ( - ApplyField("kp", "set_kp", cg.float_), - ApplyField("ki", "set_ki", cg.float_), - ) - config = {"kp": Lambda("x *= 2;\nreturn x;"), "ki": Lambda("return x *= 2;")} - await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) text = _squash(_apply_definition(mock_cg)) assert text.startswith( "static void esphome__obj_1__fn(const std::remove_cvref_t & x) {" ) - assert ( - f"::{PARENT_OBJ}->set_kp([](const std::remove_cvref_t & x) -> float " - "{ x *= 2; return x; }(x));" - ) in text - assert f"::{PARENT_OBJ}->set_ki(static_cast(x *= 2));" in text + # The parent is global-scope qualified, so an arg named like the id cannot shadow it. + assert f"::{PARENT_OBJ}->set_kp({_by_value_call('int32_t', body)});" in text @pytest.mark.asyncio -async def test_apply_condition_lambda_modifying_its_arg( +async def test_apply_lambda_naming_no_arg_is_reduced( registries: tuple[Registry, Registry], mock_cg: MockCodegen ) -> None: - """How a condition lambda that modifies its trigger arg is rendered.""" - check = ApplyCall("state == {}", (("state", cg.float_),)) - await _run_apply_condition( - registries, - check, - {"state": Lambda("x *= 2;\nreturn x;")}, - args=[(cg.float_, "x")], + """A lone return that names no value arg copies nothing, so it is the bare expression.""" + fields = (ApplyField("kp", "set_kp", cg.int32),) + config = {"kp": Lambda("return 42;")} + await _run_apply_action(registries, fields, config, args=[(cg.int32, "x")]) + assert f"::{PARENT_OBJ}->set_kp(static_cast(42));" in _apply_definition( + mock_cg ) + # Beside an operator a reduced class-typed expression is parenthesized. + check = ApplyCall("state == {}", (("state", cg.std_string),)) + config = {"state": Lambda('return "a";')} + await _run_apply_condition(registries, check, config, args=[(cg.int32, "x")]) + assert f'return ::{PARENT_OBJ}->state == ("a");' in _apply_definition(mock_cg) + + +@pytest.mark.asyncio +async def test_apply_lambda_unnamed_arg_is_not_copied( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """An arg the body never names stays a reference; a named one is by value.""" + fields = (ApplyField("index", "set_index", cg.size_t),) + args = [(cg.std_string, "x"), (cg.size_t, "i")] + # `xi` must not count as naming `x` + config = {"index": Lambda("size_t xi = i;\nreturn xi;")} + await _run_apply_action(registries, fields, config, args=args) assert ( - f"return ::{PARENT_OBJ}->state == [](const std::remove_cvref_t & x) " - "-> float { x *= 2; return x; }(x);" - ) in _squash(_apply_definition(mock_cg)) - await _run_apply_condition( - registries, check, {"state": Lambda("return x *= 2;")}, args=[(cg.float_, "x")] + "set_index([](const std::remove_cvref_t & x, size_t i) -> size_t {" + in _apply_definition(mock_cg) ) - assert f"return ::{PARENT_OBJ}->state == static_cast(x *= 2);" in _squash( + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + "return 42; // x", + "return 1 /* x */;", + 'return sizeof("x");', + 'return sizeof("a\\"x");', + 'return sizeof(R"(x)");', + "return 'x';", + "return foo.x;", + "return p->x;", + "return ns::x;", + ], +) +async def test_apply_lambda_arg_in_noise_is_not_copied( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, body: str +) -> None: + """A name only in a comment, a literal or after member access does not copy the arg.""" + fields = (ApplyField("kp", "set_kp", cg.int32),) + await _run_apply_action( + registries, fields, {"kp": Lambda(body)}, args=[(cg.std_string, "x")] + ) + text = _apply_definition(mock_cg) + assert "std::string x" not in text + assert "const std::remove_cvref_t & x" in text + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + "return x.size(); // done", + "/* a */ return x.size();", + 'return sizeof("a") + x.size();', + "return 1'0 + x.size() + 2'0;", + "return '\"' + x.size();", + "return s.x + x.size();", + ], +) +async def test_apply_lambda_arg_beside_noise_is_copied( + registries: tuple[Registry, Registry], mock_cg: MockCodegen, body: str +) -> None: + """A real use next to a comment, literal or digit separator still takes the arg by value.""" + fields = (ApplyField("kp", "set_kp", cg.int32),) + await _run_apply_action( + registries, fields, {"kp": Lambda(body)}, args=[(cg.std_string, "x")] + ) + assert "[](std::string x) -> int32_t {" in _apply_definition(mock_cg) + + +@pytest.mark.asyncio +async def test_apply_reduced_comma_expression_is_parenthesized( + registries: tuple[Registry, Registry], mock_cg: MockCodegen +) -> None: + """A reduced uncast expression with a top-level comma stays one value.""" + fields = (ApplyBytes("data", "send({})"),) + config = {"data": Lambda("return touch(), std::vector{0x01};")} + await _run_apply_action(registries, fields, config) + assert ( + "const std::vector esphome__bytes = " + "(touch(), std::vector{0x01});" + ) in _apply_definition(mock_cg) + fields = (ApplyField("name", "set_name", cg.std_string),) + config = {"name": Lambda('return touch(), std::string("a");')} + await _run_apply_action(registries, fields, config) + assert f'::{PARENT_OBJ}->set_name((touch(), std::string("a")));' in ( _apply_definition(mock_cg) ) + # Commas inside brackets or literals are not top level + config = {"name": Lambda('return f(1, 2) + ",";')} + await _run_apply_action(registries, fields, config) + assert f'::{PARENT_OBJ}->set_name(f(1, 2) + ",");' in _apply_definition(mock_cg) @pytest.mark.asyncio @@ -1439,7 +1503,7 @@ async def test_apply_bytes_lambda_is_a_vector_in_a_block( body: str, ) -> None: """A lambda payload is called once into a scoped vector and passed as data and size; - it takes the trigger args by their own types and is never reduced, so it may modify them.""" + a trigger arg it names is taken by its own type, so it may modify it.""" fields = (ApplyBytes("data", "send({})"),) config = {"data": Lambda(body)} await _run_apply_action( @@ -1662,28 +1726,26 @@ async def test_apply_condition_string_constant( @pytest.mark.asyncio @pytest.mark.parametrize( - ("body", "expected", "called"), + ("type_", "body"), [ - ("return x;", "->state == (x);", False), - ('return x.empty() ? "e" : x;', '->state == (x.empty() ? "e" : x);', False), - ('if (x.empty()) return "e";\nreturn x;', "}(x);", True), + *((cg.int32, body) for body in LAMBDA_BODIES), + (cg.std_string, "return x;"), + (cg.std_string, 'if (x.empty()) return "e";\nreturn x;'), ], ) -async def test_apply_condition_string_lambda_paths( +async def test_apply_condition_lambdas( registries: tuple[Registry, Registry], mock_cg: MockCodegen, + type_: MockObj, body: str, - expected: str, - called: bool, ) -> None: - """A single return is inlined with no copy; a longer body is a called std::string lambda.""" - check = ApplyCall("state == {}", (("state", cg.std_string),)) + """A condition lambda is called in place with the trigger arg by value, a std::string too.""" + check = ApplyCall("state == {}", (("state", type_),)) await _run_apply_condition( - registries, check, {"state": Lambda(body)}, args=[(cg.std_string, "x")] + registries, check, {"state": Lambda(body)}, args=[(type_, "x")] ) - text = _apply_definition(mock_cg) - assert expected in text - assert ("-> std::string {" in text) is called + text = _squash(_apply_definition(mock_cg)) + assert f"->state == {_by_value_call(str(type_), body)};" in text def test_progmem_bytes_shares_equal_payloads() -> None: @@ -1822,33 +1884,19 @@ async def test_trigger_callback_filter_names_parent(mock_cg: MockCodegen) -> Non @pytest.mark.asyncio -@pytest.mark.parametrize( - ("body", "expected"), - [ - ( - "x *= 2;\nreturn x;", - ( - "if (!(x == [](const std::remove_cvref_t & x) -> float " - "{ x *= 2; return x; }(x))) return;" - ), - ), - ("return x *= 2;", "if (!(x == static_cast(x *= 2))) return;"), - ], -) -async def test_trigger_callback_filter_lambda_modifying_its_arg( - mock_cg: MockCodegen, body: str, expected: str -) -> None: - """How a filter lambda that modifies the callback's arg is rendered.""" +@pytest.mark.parametrize("body", LAMBDA_BODIES) +async def test_trigger_callback_filter_lambdas(mock_cg: MockCodegen, body: str) -> None: + """A filter lambda is called in place with the callback's arg by value.""" text = _squash( await build_trigger_callback( - [(cg.float_, "x")], + [(cg.int32, "x")], {**TRIGGER_CONF, "level": Lambda(body)}, - [(cg.float_, "x")], - when=ApplyCall("x == {}", (("level", cg.float_),)), + [(cg.int32, "x")], + when=ApplyCall("x == {}", (("level", cg.int32),)), ) ) - assert text.startswith("[](const std::remove_cvref_t & x) -> void {") - assert expected in text + assert text.startswith("[](const std::remove_cvref_t & x) -> void {") + assert f"if (!(x == {_by_value_call('int32_t', body)})) return;" in text @pytest.mark.asyncio From 14b190ab8fe77ab5f1eed938e7b74f4f77f944a3 Mon Sep 17 00:00:00 2001 From: "Thomas A." Date: Wed, 4 Nov 2026 01:11:19 +0100 Subject: [PATCH 154/170] [esp32_rmt] Check that RMT components fit the chip's RMT channels (#22172) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/esp32_rmt/__init__.py | 347 ++++++++++- .../components/esp32_rmt_led_strip/light.py | 8 + .../components/remote_receiver/__init__.py | 8 + .../components/remote_transmitter/__init__.py | 13 +- tests/component_tests/esp32_rmt/__init__.py | 0 .../fixtures/c3_three_receivers.yaml | 12 + .../fixtures/c3_three_transmitters.yaml | 15 + .../fixtures/c6_large_strip_first.yaml | 24 + .../c6_two_strips_and_receiver_fit.yaml | 28 + .../fixtures/esp32_strip_below_one_block.yaml | 17 + .../fixtures/s2_two_default_strips.yaml | 23 + .../esp32_rmt/fixtures/s31_default_strip.yaml | 15 + .../fixtures/s3_small_strip_first.yaml | 25 + .../fixtures/s3_transmitter_before_strip.yaml | 21 + .../esp32_rmt/test_symbol_budget.py | 143 +++++ .../esp32_rmt_led_strip/common.yaml | 2 + .../test.esp32-c3-idf.yaml | 1 + .../esp32_rmt_led_strip/test.esp32-idf.yaml | 1 + .../test.esp32-s3-idf.yaml | 1 + .../validate-legacy.esp32-idf.yaml | 3 + tests/unit_tests/components/test_esp32_rmt.py | 554 ++++++++++++++++++ 21 files changed, 1258 insertions(+), 3 deletions(-) create mode 100644 tests/component_tests/esp32_rmt/__init__.py create mode 100644 tests/component_tests/esp32_rmt/fixtures/c3_three_receivers.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/c3_three_transmitters.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/c6_large_strip_first.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/c6_two_strips_and_receiver_fit.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/esp32_strip_below_one_block.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/s2_two_default_strips.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/s31_default_strip.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/s3_small_strip_first.yaml create mode 100644 tests/component_tests/esp32_rmt/fixtures/s3_transmitter_before_strip.yaml create mode 100644 tests/component_tests/esp32_rmt/test_symbol_budget.py create mode 100644 tests/unit_tests/components/test_esp32_rmt.py diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 9d3562ed7bc..ac1aa007bb9 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -1,17 +1,360 @@ from collections.abc import Callable, Iterable -from typing import Any +from dataclasses import dataclass, field +import logging +from typing import Any, Literal from esphome.components import esp32 import esphome.config_validation as cv -from esphome.core import CORE +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_RMT_SYMBOLS, + CONF_SETUP_PRIORITY, + CONF_USE_DMA, +) +from esphome.core import CORE, ID +from esphome.coroutine import CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@jesserockz"] DOMAIN = "esp32_rmt" VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} +@dataclass(frozen=True) +class RMTMemory: + """RMT channel memory of a variant. + + From SOC_RMT_MEM_WORDS_PER_CHANNEL in ESP-IDF soc_caps.h and the TX/RX channel counts + (SOC_RMT_*_CANDIDATES_PER_GROUP in soc_caps.h up to 5.5, RMT_LL_* in hal/rmt_ll.h from 6.0). + """ + + symbols_per_block: int + tx_blocks: int + # None when TX and RX channels draw from one pool of tx_blocks (ESP32, ESP32-S2) + rx_blocks: int | None + + @property + def total_blocks(self) -> int: + return self.tx_blocks + (self.rx_blocks or 0) + + +# A variant that is not listed is not checked +RMT_MEMORY: dict[str, RMTMemory] = { + esp32.VARIANT_ESP32: RMTMemory(64, 8, None), + esp32.VARIANT_ESP32S2: RMTMemory(64, 4, None), + esp32.VARIANT_ESP32S3: RMTMemory(48, 4, 4), + esp32.VARIANT_ESP32S31: RMTMemory(48, 4, 4), + esp32.VARIANT_ESP32P4: RMTMemory(48, 4, 4), + esp32.VARIANT_ESP32C3: RMTMemory(48, 2, 2), + esp32.VARIANT_ESP32C5: RMTMemory(48, 2, 2), + esp32.VARIANT_ESP32C6: RMTMemory(48, 2, 2), + esp32.VARIANT_ESP32H2: RMTMemory(48, 2, 2), + esp32.VARIANT_ESP32H4: RMTMemory(48, 2, 2), + esp32.VARIANT_ESP32H21: RMTMemory(48, 2, 2), +} + +Direction = Literal["TX", "RX"] + +# ESP-IDF refuses more DMA symbols on a TX channel ("mem_block_symbols can't exceed 2046" +# or 2047, depending on the version; 2047 is odd and refused anyway) +MAX_TX_DMA_SYMBOLS: int = 2046 + +# ESP-IDF's log line for a channel that cannot be allocated +_IDF_ERRORS: dict[str, str] = { + "TX": "'no free tx channels'", + "RX": "'no free rx channels'", +} + + +@dataclass(eq=False) +class _Channel: + id: ID + name: str | None + direction: Direction + symbols: int + dma: bool + setup_priority: float + # Set in final validation, where the config path of a generated id is known + label: str = "" + + def blocks(self, size: int) -> int: + """Blocks the driver allocates: rmt_symbols rounded up, one for a DMA channel.""" + return 1 if self.dma else -(-self.symbols // size) + + +def _label(channel: _Channel) -> str: + """The name, else the id; an id ESPHome generated is replaced by the config position.""" + if channel.name: + return channel.name + if not channel.id.is_manual: + path = fv.full_config.get().get_path_for_id(channel.id) + if len(path) > 2 and isinstance(path[1], int): + return f"{path[0]}[{path[1]}]" + return str(channel.id) + + +@dataclass +class _Error: + channels: list[_Channel] + message: str + # Config key the error is shown on, None for the component itself + key: str | None = None + + +@dataclass +class ESP32RMTData: + channels: list[_Channel] = field(default_factory=list) + checked: bool = False + errors: list[_Error] = field(default_factory=list) + # IDs of the RMT components in the order code generation registers them + registered: list[ID] = field(default_factory=list) + + +def _get_data() -> ESP32RMTData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = ESP32RMTData() + return CORE.data[DOMAIN] + + +def _get_memory() -> RMTMemory | None: + if not CORE.is_esp32: + return None + return RMT_MEMORY.get(esp32.get_esp32_variant()) + + +def _max_blocks(direction: Direction, memory: RMTMemory) -> int: + """Blocks one channel can use: a TX channel can extend into the RX blocks behind it.""" + if memory.rx_blocks is None: + return memory.tx_blocks + if direction == "TX": + return memory.tx_blocks + memory.rx_blocks + return memory.rx_blocks + + +def request_rmt_channel( + direction: Direction, setup_priority: float +) -> Callable[[ConfigType], ConfigType]: + """Return a CONFIG_SCHEMA validator for a component that creates one RMT channel. + + It checks the entry's rmt_symbols against what ESP-IDF accepts, and records the + channel so that final_validate_rmt_channels() can check all RMT components together. + Add it after the schema, so rmt_symbols has its default, and set + FINAL_VALIDATE_SCHEMA = esp32_rmt.final_validate_rmt_channels in the same component. + The entry needs an id and rmt_symbols on every variant with RMT; use_dma is optional. + setup_priority is the component's get_setup_priority() in C++, which creates the + channel in setup(); the entry's setup_priority overrides it. Call + register_rmt_channel() in to_code. + """ + + def _validator(config: ConfigType) -> ConfigType: + if (memory := _get_memory()) is None: + return config + variant = esp32.get_esp32_variant() + symbols = config[CONF_RMT_SYMBOLS] + dma = bool(config.get(CONF_USE_DMA)) + size = memory.symbols_per_block + if symbols < size or symbols % 2: + raise cv.Invalid( + f"Must be an even number of at least {size} on {variant}, " + "the size of one RMT memory block", + path=[CONF_RMT_SYMBOLS], + ) + if dma and direction == "TX" and symbols > MAX_TX_DMA_SYMBOLS: + raise cv.Invalid( + f"Must be at most {MAX_TX_DMA_SYMBOLS} with '{CONF_USE_DMA}'", + path=[CONF_RMT_SYMBOLS], + ) + if not dma and symbols > (limit := _max_blocks(direction, memory) * size): + raise cv.Invalid( + f"Must be at most {limit} on {variant} without '{CONF_USE_DMA}': one " + f"RMT {direction} channel cannot use more memory", + path=[CONF_RMT_SYMBOLS], + ) + _get_data().channels.append( + _Channel( + config[CONF_ID], + config.get(CONF_NAME), + direction, + symbols, + dma, + config.get(CONF_SETUP_PRIORITY, setup_priority), + ) + ) + return config + + return _validator + + +def _labels(channels: list[_Channel]) -> str: + return ", ".join(f"'{channel.label}'" for channel in channels) + + +def _check_pool(direction: str, channels: list[_Channel], blocks: int) -> _Error | None: + """Return the error if the channels cannot all get a channel. + + direction is "TX", "RX", or "TX/RX" for the shared pool of the ESP32 and ESP32-S2. + """ + variant = esp32.get_esp32_variant() + if len(channels) > blocks: + kind = "shared by TX and RX" if direction == "TX/RX" else f"for {direction}" + return _Error( + channels, + f"{variant} has {blocks} RMT channels {kind}, but these {len(channels)} " + f"components each need one: {_labels(channels)}. Remove some of them or " + "use a chip with more RMT channels", + ) + dma = [channel for channel in channels if channel.dma] + if len(dma) > 1: + return _Error( + dma, + f"Only one RMT {direction} channel on {variant} supports DMA, but " + f"'{CONF_USE_DMA}' is set on {_labels(dma)}. Keep it on one of them", + CONF_USE_DMA, + ) + return None + + +def _allocate(memory: RMTMemory, channels: list[_Channel]) -> list[_Channel]: + """Allocate the channels in this order like ESP-IDF and return the ones that fail. + + The driver gives a channel the first free run of blocks that starts at one of the + channels of its direction, and a DMA channel the last channel of its direction. + """ + size = memory.symbols_per_block + total = memory.total_blocks + starts = { + "TX": range(memory.tx_blocks), + # On the ESP32 and ESP32-S2 the RX channels are the same as the TX channels + "RX": range(total - (memory.rx_blocks or memory.tx_blocks), total), + } + # Blocks past the end of the RMT memory count as taken, like in ESP-IDF + occupied = ~((1 << total) - 1) + failed = [] + for channel in channels: + mask = (1 << channel.blocks(size)) - 1 + candidates = starts[channel.direction] + if channel.dma: + candidates = candidates[-1:] + start = next((j for j in candidates if not occupied & mask << j), None) + if start is None: + failed.append(channel) + else: + occupied |= mask << start + return failed + + +def _pools( + memory: RMTMemory, channels: list[_Channel] +) -> list[tuple[str, list[_Channel], int]]: + """The channel pools of the variant: direction, its channels and its blocks.""" + if memory.rx_blocks is None: + return [("TX/RX", channels, memory.tx_blocks)] + return [ + (direction, [c for c in channels if c.direction == direction], blocks) + for direction, blocks in (("TX", memory.tx_blocks), ("RX", memory.rx_blocks)) + ] + + +def _suggestions( + memory: RMTMemory, pools: list[tuple[str, list[_Channel], int]] +) -> list[str]: + """Suggest an rmt_symbols value for the components of each overrun pool.""" + size = memory.symbols_per_block + suggestions = [] + for _, channels, blocks in pools: + if sum(channel.blocks(size) for channel in channels) <= blocks: + continue + others = [channel for channel in channels if not channel.dma] + fair = (blocks - (len(channels) - len(others))) // len(others) * size + shrink = [channel for channel in others if channel.symbols > fair] + suggestions.append(f"'{CONF_RMT_SYMBOLS}: {fair}' on {_labels(shrink)}") + return suggestions + + +def final_validate_rmt_channels(config: ConfigType) -> None: + """FINAL_VALIDATE_SCHEMA of the components that use request_rmt_channel(). + + Fails when the RMT components cannot all get a channel. The check runs once; its + errors are raised together from the first component named in one of them, since + final validation stops after the first error. Whether the channels fit in the RMT + memory depends on the setup order, which is only known in code generation. + """ + data = _get_data() + if not data.checked: + data.checked = True + for channel in data.channels: + channel.label = _label(channel) + # Skip in testing mode, where component grouping merges the RMT components of many tests + if not CORE.testing_mode and (memory := _get_memory()) is not None: + data.errors = [ + error + for pool in _pools(memory, data.channels) + if (error := _check_pool(*pool)) is not None + ] + own_id = config.get(CONF_ID) + if not any(c.id == own_id for error in data.errors for c in error.channels): + return + # Errors of other components are not shown on this component's key + key = data.errors[0].key if len(data.errors) == 1 else None + raise cv.Invalid( + "\n".join(error.message for error in data.errors), + path=[key] if key else [], + ) + + +def register_rmt_channel(config: ConfigType) -> None: + """Record the setup order of a component that uses request_rmt_channel(). + + Call it in to_code right after cg.register_component(): C++ sets up components of + the same priority in the order they are registered. + """ + data = _get_data() + if not data.registered: + CORE.add_job(_check_memory) + data.registered.append(config[CONF_ID]) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _check_memory() -> None: + """Warn when the RMT channels, allocated in setup order, do not fit in the RMT memory.""" + data = _get_data() + if CORE.testing_mode or (memory := _get_memory()) is None: + return + # Application::setup() sorts the components by priority with a stable sort + rank = {channel_id: i for i, channel_id in enumerate(data.registered)} + channels = sorted(data.channels, key=lambda c: rank.get(c.id, len(rank))) + channels.sort(key=lambda channel: -channel.setup_priority) + if not (failed := _allocate(memory, channels)): + return + fix = "" + size = memory.symbols_per_block + if not _allocate(memory, sorted(channels, key=lambda c: c.blocks(size))): + fix = f", or set '{CONF_SETUP_PRIORITY}' so the smaller ones are set up first" + usage = ", ".join( + f"'{channel.label}' ({'DMA' if channel.dma else channel.symbols})" + for channel in channels + ) + _LOGGER.warning( + "RMT memory on %s holds %d blocks of %d symbols, but in the order ESPHome sets " + "up these components, %s will not fit: %s. Set %s%s. Otherwise ESP-IDF logs %s " + "at boot, and ESPHome logs 'Channel creation failed' or 'out of RMT symbol " + "memory'.", + esp32.get_esp32_variant(), + memory.total_blocks, + size, + _labels(failed), + usage, + " and ".join(_suggestions(memory, _pools(memory, channels))), + fix, + " or ".join(sorted({_IDF_ERRORS[channel.direction] for channel in failed})), + ) + + def validate_rmt_not_supported( rmt_only_keys: Iterable[str], ) -> Callable[[ConfigType], ConfigType]: diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index 9b8bd8f2117..5887fe6acd6 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -78,9 +78,12 @@ CONFIG_SCHEMA = cv.All( esp32_c5=96, esp32_c6=96, esp32_h2=96, + esp32_h4=96, + esp32_h21=96, esp32_p4=192, esp32_s2=192, esp32_s3=192, + esp32_s31=192, ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), @@ -121,8 +124,12 @@ CONFIG_SCHEMA = cv.All( light.migrate_channel_colors( removed_in="2027.3.0", component="esp32_rmt_led_strip" ), + # setup_priority::HARDWARE, from ESP32RMTLEDStripLightOutput::get_setup_priority() + esp32_rmt.request_rmt_channel("TX", 800.0), ) +FINAL_VALIDATE_SCHEMA = esp32_rmt.final_validate_rmt_channels + async def to_code(config: ConfigType) -> None: # Re-enable ESP-IDF's RMT driver (excluded by default to save compile time) @@ -131,6 +138,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) await light.register_light(var, config) await cg.register_component(var, config) + esp32_rmt.register_rmt_channel(config) cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) cg.add(var.set_pin(config[CONF_PIN][CONF_NUMBER])) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 005f33c7b2f..ba34f9f65da 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -149,9 +149,12 @@ CONFIG_SCHEMA = remote_base.validate_triggers( esp32_c6=96, esp32_c61=cv.UNDEFINED, esp32_h2=96, + esp32_h4=96, + esp32_h21=96, esp32_p4=192, esp32_s2=192, esp32_s3=192, + esp32_s31=192, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_FILTER_SYMBOLS): cv.All( cv.only_on_esp32, cv.int_range(min=0) @@ -201,8 +204,12 @@ CONFIG_SCHEMA = remote_base.validate_triggers( ) ) .add_extra(validate_config) + # setup_priority::DATA, the default of Component::get_setup_priority() + .add_extra(esp32_rmt.request_rmt_channel("RX", 600.0)) ) +FINAL_VALIDATE_SCHEMA = esp32_rmt.final_validate_rmt_channels + async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) @@ -229,6 +236,7 @@ async def to_code(config: ConfigType) -> None: for trigger in triggers: remote_base.add_listener(var, trigger) await cg.register_component(var, config) + esp32_rmt.register_rmt_channel(config) cg.add( var.set_tolerance( diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 7f0dcd05db9..ec9378fbc2b 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -84,9 +84,12 @@ CONFIG_SCHEMA = ( esp32_c6=48, esp32_c61=cv.UNDEFINED, esp32_h2=48, + esp32_h4=48, + esp32_h21=48, esp32_p4=48, esp32_s2=64, esp32_s3=48, + esp32_s31=48, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_NON_BLOCKING): _validate_non_blocking_platform, cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True), @@ -105,6 +108,8 @@ CONFIG_SCHEMA = ( ] ) ) + # setup_priority::DATA - 1, from RemoteTransmitterComponent::get_setup_priority() + .add_extra(esp32_rmt.request_rmt_channel("TX", 599.0)) ) @@ -122,7 +127,12 @@ def _validate_non_blocking(config: ConfigType) -> None: config[CONF_NON_BLOCKING] = True -FINAL_VALIDATE_SCHEMA = _validate_non_blocking +def _final_validate(config: ConfigType) -> None: + _validate_non_blocking(config) + esp32_rmt.final_validate_rmt_channels(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate DIGITAL_WRITE_ACTION_SCHEMA = cv.maybe_simple_value( { @@ -165,6 +175,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], pin) cg.add_optional(var.set_non_blocking, config.get(CONF_NON_BLOCKING)) await cg.register_component(var, config) + esp32_rmt.register_rmt_channel(config) cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT])) diff --git a/tests/component_tests/esp32_rmt/__init__.py b/tests/component_tests/esp32_rmt/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/esp32_rmt/fixtures/c3_three_receivers.yaml b/tests/component_tests/esp32_rmt/fixtures/c3_three_receivers.yaml new file mode 100644 index 00000000000..94878fafa24 --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/c3_three_receivers.yaml @@ -0,0 +1,12 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32c3 + framework: + type: esp-idf + +remote_receiver: + - pin: GPIO4 + - pin: GPIO5 + - pin: GPIO6 diff --git a/tests/component_tests/esp32_rmt/fixtures/c3_three_transmitters.yaml b/tests/component_tests/esp32_rmt/fixtures/c3_three_transmitters.yaml new file mode 100644 index 00000000000..fa2f378f792 --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/c3_three_transmitters.yaml @@ -0,0 +1,15 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32c3 + framework: + type: esp-idf + +remote_transmitter: + - pin: GPIO4 + carrier_duty_percent: 50% + - pin: GPIO5 + carrier_duty_percent: 50% + - pin: GPIO6 + carrier_duty_percent: 50% diff --git a/tests/component_tests/esp32_rmt/fixtures/c6_large_strip_first.yaml b/tests/component_tests/esp32_rmt/fixtures/c6_large_strip_first.yaml new file mode 100644 index 00000000000..cf40216b12e --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/c6_large_strip_first.yaml @@ -0,0 +1,24 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +# The first strip takes both TX channels, so the second one has no channel to start at +light: + - platform: esp32_rmt_led_strip + name: Strip 0 + chipset: WS2812 + channel_colors: GRB + pin: GPIO3 + num_leds: 5 + rmt_symbols: 96 + - platform: esp32_rmt_led_strip + name: Strip 1 + chipset: WS2812 + channel_colors: GRB + pin: GPIO2 + num_leds: 5 + rmt_symbols: 48 diff --git a/tests/component_tests/esp32_rmt/fixtures/c6_two_strips_and_receiver_fit.yaml b/tests/component_tests/esp32_rmt/fixtures/c6_two_strips_and_receiver_fit.yaml new file mode 100644 index 00000000000..9460cd4f907 --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/c6_two_strips_and_receiver_fit.yaml @@ -0,0 +1,28 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32c6 + framework: + type: esp-idf + +light: + - platform: esp32_rmt_led_strip + name: Status LEDs + internal: true + chipset: WS2812 + channel_colors: GRB + pin: GPIO3 + num_leds: 5 + rmt_symbols: 48 + - platform: esp32_rmt_led_strip + name: NeoPixel Port + chipset: WS2812 + channel_colors: GRB + pin: GPIO2 + num_leds: 32 + rmt_symbols: 48 + +# Receivers use the RX blocks, so this one does not take TX memory +remote_receiver: + - pin: GPIO4 diff --git a/tests/component_tests/esp32_rmt/fixtures/esp32_strip_below_one_block.yaml b/tests/component_tests/esp32_rmt/fixtures/esp32_strip_below_one_block.yaml new file mode 100644 index 00000000000..205431874e4 --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/esp32_strip_below_one_block.yaml @@ -0,0 +1,17 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32 + framework: + type: esp-idf + +light: + - platform: esp32_rmt_led_strip + chipset: WS2812 + channel_colors: GRB + id: strip + pin: GPIO13 + num_leds: 5 + # The original ESP32 has blocks of 64 symbols + rmt_symbols: 48 diff --git a/tests/component_tests/esp32_rmt/fixtures/s2_two_default_strips.yaml b/tests/component_tests/esp32_rmt/fixtures/s2_two_default_strips.yaml new file mode 100644 index 00000000000..1ef186b6b6d --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/s2_two_default_strips.yaml @@ -0,0 +1,23 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32s2 + framework: + type: esp-idf + +light: + # Internal status LED bus and an external NeoPixel port, both left at the default rmt_symbols + - platform: esp32_rmt_led_strip + name: Status LEDs + internal: true + chipset: WS2812 + channel_colors: GRB + pin: GPIO1 + num_leds: 5 + - platform: esp32_rmt_led_strip + name: NeoPixel Port + chipset: WS2812 + channel_colors: GRB + pin: GPIO42 + num_leds: 32 diff --git a/tests/component_tests/esp32_rmt/fixtures/s31_default_strip.yaml b/tests/component_tests/esp32_rmt/fixtures/s31_default_strip.yaml new file mode 100644 index 00000000000..0b9a3fa95a8 --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/s31_default_strip.yaml @@ -0,0 +1,15 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32s31 + framework: + type: esp-idf + +light: + - platform: esp32_rmt_led_strip + id: strip + chipset: WS2812 + channel_colors: GRB + pin: GPIO4 + num_leds: 5 diff --git a/tests/component_tests/esp32_rmt/fixtures/s3_small_strip_first.yaml b/tests/component_tests/esp32_rmt/fixtures/s3_small_strip_first.yaml new file mode 100644 index 00000000000..4b724d5a82f --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/s3_small_strip_first.yaml @@ -0,0 +1,25 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +# The small strip is registered and set up first, then the large one can extend into the +# free RX blocks +light: + - platform: esp32_rmt_led_strip + id: status_led + chipset: WS2812 + channel_colors: GRB + pin: GPIO4 + num_leds: 1 + rmt_symbols: 48 + - platform: esp32_rmt_led_strip + id: matrix + chipset: WS2812 + channel_colors: GRB + pin: GPIO5 + num_leds: 64 + rmt_symbols: 288 diff --git a/tests/component_tests/esp32_rmt/fixtures/s3_transmitter_before_strip.yaml b/tests/component_tests/esp32_rmt/fixtures/s3_transmitter_before_strip.yaml new file mode 100644 index 00000000000..f8e2cf37338 --- /dev/null +++ b/tests/component_tests/esp32_rmt/fixtures/s3_transmitter_before_strip.yaml @@ -0,0 +1,21 @@ +esphome: + name: rmt-test + +esp32: + variant: esp32s3 + framework: + type: esp-idf + +# Listed first, but set up after the strip, which has a higher setup priority +remote_transmitter: + - id: ir + pin: GPIO4 + carrier_duty_percent: 50% + +light: + - platform: esp32_rmt_led_strip + id: strip + chipset: WS2812 + channel_colors: GRB + pin: GPIO5 + num_leds: 5 diff --git a/tests/component_tests/esp32_rmt/test_symbol_budget.py b/tests/component_tests/esp32_rmt/test_symbol_budget.py new file mode 100644 index 00000000000..e549f3602cd --- /dev/null +++ b/tests/component_tests/esp32_rmt/test_symbol_budget.py @@ -0,0 +1,143 @@ +"""RMT budget check on full configs. + +Channel errors come from every component's FINAL_VALIDATE_SCHEMA, the memory warning +from code generation, which knows the order the components are set up in. +""" + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + +Validate = Callable[[str], tuple[bool, list[str]]] +Generate = Callable[[str], list[str]] + + +def _rmt_lines(lines: list[str]) -> list[str]: + return [line.strip() for line in lines if "RMT" in line] + + +@pytest.fixture +def validate( + component_fixture_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, + capsys: pytest.CaptureFixture[str], +) -> Validate: + """Validate a fixture, return whether it is valid and the RMT warnings and errors. + + Warnings are logged, validation errors are printed with the failed config. + """ + + def run(file_name: str) -> tuple[bool, list[str]]: + CORE.config_path = component_fixture_path(file_name) + with caplog.at_level(logging.INFO): + valid = read_config({}) is not None + return valid, _rmt_lines(caplog.messages + capsys.readouterr().out.splitlines()) + + return run + + +@pytest.fixture +def generate( + component_fixture_path: Callable[[str], Path], + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> Generate: + """Validate a fixture and generate its code, return the RMT warnings of each step.""" + + def run(file_name: str) -> list[str]: + with caplog.at_level(logging.INFO): + generate_main(component_fixture_path(file_name)) + return _rmt_lines(caplog.messages) + + return run + + +def test_validation_does_not_warn(validate: Validate) -> None: + """The warning depends on the setup order, so validation alone does not show it.""" + assert validate("s2_two_default_strips.yaml") == (True, []) + + +def test_default_strips_on_s2_warn(generate: Generate) -> None: + """Two strips at the default 192 symbols need 6 of the 4 shared S2 blocks.""" + messages = generate("s2_two_default_strips.yaml") + assert len(messages) == 1 + assert "'Status LEDs' (192), 'NeoPixel Port' (192)" in messages[0] + assert "Set 'rmt_symbols: 128'" in messages[0] + + +def test_fitting_strips_and_receiver_on_c6_pass(generate: Generate) -> None: + """Two strips at 48 symbols fill the two C6 TX blocks; the receiver uses RX blocks.""" + assert generate("c6_two_strips_and_receiver_fit.yaml") == [] + + +def test_large_strip_first_on_c6_warns(generate: Generate) -> None: + """96 then 48 symbols on C6 fails on hardware: the second strip gets no channel.""" + messages = generate("c6_large_strip_first.yaml") + assert len(messages) == 1 + assert "'Strip 1' will not fit: 'Strip 0' (96), 'Strip 1' (48)" in messages[0] + assert ( + "or set 'setup_priority' so the smaller ones are set up first" in (messages[0]) + ) + + +@pytest.mark.parametrize( + ("file_name", "domain", "direction"), + [ + ("c3_three_transmitters.yaml", "remote_transmitter", "TX"), + ("c3_three_receivers.yaml", "remote_receiver", "RX"), + ], +) +def test_more_components_than_channels_fail( + validate: Validate, + file_name: str, + domain: str, + direction: str, +) -> None: + """Three remote_transmitter or remote_receiver need more than the two C3 channels.""" + valid, messages = validate(file_name) + assert not valid + assert any(f"has 2 RMT channels for {direction}" in line for line in messages) + # Components without a name or id are named by their position in the config + assert any(f"'{domain}[2]'" in line for line in messages) + + +def test_strip_below_one_block_fails( + component_fixture_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + """The error is shown on rmt_symbols, also for a single component.""" + CORE.config_path = component_fixture_path("esp32_strip_below_one_block.yaml") + assert read_config({}) is None + lines = [line.strip() for line in capsys.readouterr().out.splitlines()] + index = next( + i for i, line in enumerate(lines) if "even number of at least 64" in line + ) + # The failed config prints the error right above the key it belongs to + assert lines[index + 1] == "rmt_symbols: 48" + + +def test_new_variant_gets_its_own_default( + component_fixture_path: Callable[[str], Path], +) -> None: + """The S31 uses the S3 defaults, not the ones of the original ESP32.""" + CORE.config_path = component_fixture_path("s31_default_strip.yaml") + config = read_config({}) + assert config is not None + assert config["light"][0]["rmt_symbols"] == 192 + + +def test_strip_is_set_up_before_transmitter(generate: Generate) -> None: + """The order comes from the setup priorities, not from the order in the config.""" + messages = generate("s3_transmitter_before_strip.yaml") + assert len(messages) == 1 + assert "'ir' will not fit: 'strip' (192), 'ir' (48)" in messages[0] + + +def test_large_strip_after_small_one_fits(generate: Generate) -> None: + """Without receivers the second strip can use the RX blocks.""" + assert generate("s3_small_strip_first.yaml") == [] diff --git a/tests/components/esp32_rmt_led_strip/common.yaml b/tests/components/esp32_rmt_led_strip/common.yaml index 7f52d322293..3c5bf29df23 100644 --- a/tests/components/esp32_rmt_led_strip/common.yaml +++ b/tests/components/esp32_rmt_led_strip/common.yaml @@ -1,12 +1,14 @@ light: - platform: esp32_rmt_led_strip id: led_strip1 + rmt_symbols: ${rmt_symbols} pin: ${pin1} num_leds: 60 channel_colors: GRB chipset: ws2812 - platform: esp32_rmt_led_strip id: led_strip2 + rmt_symbols: ${rmt_symbols} pin: ${pin2} num_leds: 60 channel_colors: RWGB diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-c3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-c3-idf.yaml index 6cc0667e772..750e0ce8a56 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-c3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-c3-idf.yaml @@ -1,6 +1,7 @@ substitutions: pin1: GPIO3 pin2: GPIO4 + rmt_symbols: "48" packages: common: !include common.yaml diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-idf.yaml index 0949b676d56..88915a4d1ab 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-idf.yaml @@ -1,6 +1,7 @@ substitutions: pin1: GPIO13 pin2: GPIO14 + rmt_symbols: "64" packages: common: !include common.yaml diff --git a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml index 132966eddf8..7d106547d94 100644 --- a/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/test.esp32-s3-idf.yaml @@ -13,6 +13,7 @@ light: use_dma: "true" - platform: esp32_rmt_led_strip id: led_strip2 + rmt_symbols: 144 pin: ${pin2} num_leds: 60 channel_colors: RGB diff --git a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml index 6dd1bcdad3d..d99a66a5ea0 100644 --- a/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml +++ b/tests/components/esp32_rmt_led_strip/validate-legacy.esp32-idf.yaml @@ -3,12 +3,14 @@ light: - platform: esp32_rmt_led_strip id: legacy_rgb + rmt_symbols: 128 pin: GPIO13 num_leds: 60 chipset: ws2812 rgb_order: GRB # -> GRB - platform: esp32_rmt_led_strip id: legacy_rgbw + rmt_symbols: 128 pin: GPIO14 num_leds: 60 chipset: sk6812 @@ -16,6 +18,7 @@ light: is_rgbw: true # -> GRBW - platform: esp32_rmt_led_strip id: legacy_wrgb + rmt_symbols: 128 pin: GPIO15 num_leds: 60 chipset: sk6812 diff --git a/tests/unit_tests/components/test_esp32_rmt.py b/tests/unit_tests/components/test_esp32_rmt.py new file mode 100644 index 00000000000..429600e0f93 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_rmt.py @@ -0,0 +1,554 @@ +"""Tests for the ESP32 RMT channel and memory budget check. + +Channel errors come from final validation; the memory warning comes from code +generation, where the order the components are set up in is known. +""" + +from collections.abc import Callable +import logging +from typing import Any + +import pytest + +from esphome.components import esp32_rmt +from esphome.components.esp32 import ( + KEY_ESP32, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H4, + VARIANT_ESP32H21, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_RMT_SYMBOLS, + CONF_SETUP_PRIORITY, + CONF_USE_DMA, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE, ID +import esphome.final_validate as fv + +# Entries of RMT components, by direction +Config = dict[str, list[dict[str, Any]]] + +# Setup priority of the test entries; the same for all, so they are set up in the +# order they are registered +PRIORITY = 600.0 + +# Runs the check for a variant against the entries, returns the RMT warnings +CheckRunner = Callable[..., list[str]] + + +def _entry(name: str, symbols: int, **extra: object) -> dict[str, Any]: + return {CONF_ID: ID(name, is_manual=True), CONF_RMT_SYMBOLS: symbols, **extra} + + +def _strips(*symbols: int) -> Config: + return {"TX": [_entry(f"strip{i}", value) for i, value in enumerate(symbols)]} + + +def _set_variant(variant: str, platform: str = PLATFORM_ESP32) -> None: + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + CORE.data.pop(esp32_rmt.DOMAIN, None) + + +def _validate(config: Config) -> None: + """Validate the entries like ESPHome does: all schemas first, then final validation.""" + for direction, entries in config.items(): + for entry in entries: + esp32_rmt.request_rmt_channel(direction, PRIORITY)(entry) + for entries in config.values(): + for entry in entries: + esp32_rmt.final_validate_rmt_channels(entry) + + +def _generate(entries: list[dict[str, Any]]) -> None: + """Register the entries in this order like their to_code does, then run the jobs.""" + for entry in entries: + esp32_rmt.register_rmt_channel(entry) + CORE.flush_tasks() + + +def _rmt_messages(caplog: pytest.LogCaptureFixture) -> list[str]: + return [r.getMessage() for r in caplog.records if "RMT" in r.getMessage()] + + +@pytest.fixture +def run_check(caplog: pytest.LogCaptureFixture) -> CheckRunner: + """Validate the entries, then generate code with them registered in config order.""" + + def run(variant: str, config: Config, platform: str = PLATFORM_ESP32) -> list[str]: + _set_variant(variant, platform) + caplog.clear() + with caplog.at_level(logging.WARNING): + _validate(config) + _generate([entry for entries in config.values() for entry in entries]) + return _rmt_messages(caplog) + + return run + + +@pytest.mark.parametrize( + ("variant", "symbols", "warns"), + [ + # ESP32: 8 shared blocks of 64, so two strips at the default 192 fit and three do not + (VARIANT_ESP32, (192, 192), False), + (VARIANT_ESP32, (192, 192, 192), True), + # ESP32-S2: 4 shared blocks of 64 + (VARIANT_ESP32S2, (192,), False), + (VARIANT_ESP32S2, (192, 192), True), + (VARIANT_ESP32S2, (128, 128), False), + # ESP32-S3 and P4: 4 TX blocks of 48 + (VARIANT_ESP32S3, (192,), False), + (VARIANT_ESP32S3, (192, 192), True), + (VARIANT_ESP32S3, (96, 96), False), + (VARIANT_ESP32P4, (192, 192), True), + (VARIANT_ESP32P4, (48, 48), False), + (VARIANT_ESP32S31, (192, 192), True), + (VARIANT_ESP32S31, (96, 96), False), + # C3, C5, C6, H2, H4, H21: 2 TX blocks of 48, the default is 96 + (VARIANT_ESP32C3, (96,), False), + (VARIANT_ESP32C3, (96, 96), True), + (VARIANT_ESP32C5, (96, 96), True), + (VARIANT_ESP32C6, (96, 96), True), + (VARIANT_ESP32C6, (48, 48), False), + (VARIANT_ESP32H2, (48, 48), False), + (VARIANT_ESP32H4, (96, 96), True), + (VARIANT_ESP32H21, (48, 48), False), + # The driver allocates whole blocks: 50 symbols on S3 take two blocks of 48 + (VARIANT_ESP32S3, (50, 50), False), + (VARIANT_ESP32S3, (50, 50, 48), True), + ], +) +def test_strip_memory( + run_check: CheckRunner, variant: str, symbols: tuple[int, ...], warns: bool +) -> None: + """Strips are counted in whole blocks against the variant's TX memory.""" + assert bool(run_check(variant, _strips(*symbols))) is warns + + +def test_warning_names_components_and_suggests_value(run_check: CheckRunner) -> None: + """Internal status LEDs and an external NeoPixel port at the default 192 on S2.""" + lights = [ + {**_entry("status_leds", 192), CONF_NAME: "Status LEDs"}, + {**_entry("neopixel_port", 192), CONF_NAME: "NeoPixel Port"}, + ] + messages = run_check(VARIANT_ESP32S2, {"TX": lights}) + assert len(messages) == 1 + assert "holds 4 blocks of 64 symbols" in messages[0] + assert ( + "'NeoPixel Port' will not fit: 'Status LEDs' (192), 'NeoPixel Port' (192)" + in (messages[0]) + ) + assert "Set 'rmt_symbols: 128' on 'Status LEDs', 'NeoPixel Port'." in messages[0] + assert "ESP-IDF logs 'no free tx channels' at boot" in messages[0] + + +def test_suggestion_skips_components_that_already_fit(run_check: CheckRunner) -> None: + """A transmitter below the suggested value is not told to grow.""" + config = { + "TX": [*_strips(192, 192)["TX"], _entry("ir_tx", 64)], + "RX": [_entry("ir_rx", 192)], + } + messages = run_check(VARIANT_ESP32, config) + assert len(messages) == 1 + assert "Set 'rmt_symbols: 128' on 'strip0', 'strip1', 'ir_rx'." in messages[0] + + +def test_strip_and_transmitter_share_tx(run_check: CheckRunner) -> None: + """remote_transmitter and esp32_rmt_led_strip share the TX blocks (esphome#17894).""" + ir = _entry("ir", 48, **{CONF_SETUP_PRIORITY: PRIORITY - 1}) + messages = run_check(VARIANT_ESP32S3, {"TX": [*_strips(192)["TX"], ir]}) + assert len(messages) == 1 + assert ( + "in the order ESPHome sets up these components, 'ir' will not fit" + in (messages[0]) + ) + assert "'no free tx channels'" in messages[0] + assert "no free rx channels" not in messages[0] + + +@pytest.mark.parametrize( + ("variant", "symbols"), [(VARIANT_ESP32S3, 384), (VARIANT_ESP32C3, 144)] +) +def test_single_transmitter_may_use_rx_blocks( + run_check: CheckRunner, variant: str, symbols: int +) -> None: + """Without receivers, one TX channel can extend into the RX blocks and always fits.""" + assert not run_check(variant, _strips(symbols)) + + +def test_suggestion_when_blocks_do_not_divide_evenly(run_check: CheckRunner) -> None: + """Three strips share the four S2 blocks: one block each, a whole and even value.""" + messages = run_check(VARIANT_ESP32S2, _strips(192, 192, 192)) + assert "Set 'rmt_symbols: 64' on 'strip0', 'strip1', 'strip2'." in messages[0] + + +def test_suggestion_skips_component_at_suggested_value(run_check: CheckRunner) -> None: + """A component that already uses the suggested value is not listed.""" + messages = run_check(VARIANT_ESP32, _strips(192, 192, 192, 128)) + assert "Set 'rmt_symbols: 128' on 'strip0', 'strip1', 'strip2'." in messages[0] + + +def test_receiver_fails_when_transmitter_takes_rx_blocks( + run_check: CheckRunner, +) -> None: + """A TX channel set up first can take the RX blocks, then the receiver fails.""" + config = {**_strips(384), "RX": [_entry("rx", 48)]} + messages = run_check(VARIANT_ESP32S3, config) + assert len(messages) == 1 + assert "'rx' will not fit" in messages[0] + assert "ESP-IDF logs 'no free rx channels' at boot" in messages[0] + assert "no free tx channels" not in messages[0] + + +@pytest.mark.parametrize( + ("symbols", "fails"), + [ + # The 48 symbol strip is set up first and takes block 0, the other blocks 1 to 6 + ((48, 288), None), + # The other way round, the 288 symbol strip takes blocks 0 to 5 and with them + # every TX channel the 48 symbol strip could start at + ((288, 48), "strip1"), + ], +) +def test_order_of_transmitters_decides( + run_check: CheckRunner, symbols: tuple[int, ...], fails: str | None +) -> None: + """Without receivers TX channels can use the RX blocks; whether they fit depends on order.""" + first, second = _strips(*symbols)["TX"] + first[CONF_SETUP_PRIORITY] = PRIORITY + 1 + messages = run_check(VARIANT_ESP32S3, {"TX": [first, second]}) + if fails is None: + assert not messages + else: + assert f"'{fails}' will not fit" in messages[0] + + +def test_registration_order_decides(caplog: pytest.LogCaptureFixture) -> None: + """Components of the same priority are set up in the order to_code registers them.""" + _set_variant(VARIANT_ESP32S3) + small, large = _strips(48, 288)["TX"] + with caplog.at_level(logging.WARNING): + _validate({"TX": [small, large]}) + # Registered the other way round than in the config + _generate([large, small]) + messages = _rmt_messages(caplog) + assert len(messages) == 1 + assert ( + "in the order ESPHome sets up these components, 'strip0' will not fit: " + "'strip1' (288), 'strip0' (48)" in messages[0] + ) + assert ( + "Set 'rmt_symbols: 96' on 'strip1', or set 'setup_priority' so the smaller " + "ones are set up first." in messages[0] + ) + + +def test_large_strip_first_on_c6_warns(run_check: CheckRunner) -> None: + """96 then 48 symbols on C6: the first strip takes both TX channels (hardware tested).""" + messages = run_check(VARIANT_ESP32C6, _strips(96, 48)) + assert len(messages) == 1 + assert ( + "in the order ESPHome sets up these components, 'strip1' will not fit" + in messages[0] + ) + assert ( + "or set 'setup_priority' so the smaller ones are set up first" in (messages[0]) + ) + + +def test_small_strip_first_on_c6_fits(run_check: CheckRunner) -> None: + """48 then 96 symbols on C6: the second strip extends into the RX blocks.""" + assert not run_check(VARIANT_ESP32C6, _strips(48, 96)) + + +def test_no_order_fits(run_check: CheckRunner) -> None: + """When no order fits, setup_priority is not suggested.""" + messages = run_check(VARIANT_ESP32S2, _strips(192, 192)) + assert ( + "in the order ESPHome sets up these components, 'strip1' will not" + in (messages[0]) + ) + assert "setup_priority" not in messages[0] + + +def test_setup_priority_changes_the_order(run_check: CheckRunner) -> None: + """A component's setup_priority moves it ahead of the others.""" + small = {**_entry("small", 48), CONF_SETUP_PRIORITY: 700.0} + # Without the override, the large strip would come first and the small one fail + assert not run_check(VARIANT_ESP32S3, {"TX": [_entry("large", 288), small]}) + + +def test_receiver_uses_own_rx_blocks(run_check: CheckRunner) -> None: + """On S3 the receiver has its own RX blocks and does not take TX memory.""" + config = {**_strips(192), "RX": [_entry("rx", 192)]} + assert not run_check(VARIANT_ESP32S3, config) + + +@pytest.mark.parametrize("with_transmitter", [False, True]) +def test_receiver_shares_blocks_on_s2( + run_check: CheckRunner, with_transmitter: bool +) -> None: + """On S2 a 64 symbol strip and a 192 symbol receiver fill the four shared blocks.""" + config = {**_strips(64), "RX": [_entry("rx", 192)]} + if with_transmitter: + config["TX"].append(_entry("ir", 64)) + messages = run_check(VARIANT_ESP32S2, config) + assert len(messages) == (1 if with_transmitter else 0) + if with_transmitter: + assert "'rx' will not fit" in messages[0] + + +def test_dma_channel_uses_one_block(run_check: CheckRunner) -> None: + """A DMA channel takes one block whatever its rmt_symbols, which size the DMA buffer.""" + lights = [_entry("dma", 1024, **{CONF_USE_DMA: True}), _entry("other", 144)] + assert not run_check(VARIANT_ESP32S3, {"TX": lights}) + + +def test_suggestion_leaves_out_dma_channels(run_check: CheckRunner) -> None: + """The suggested value splits only the blocks the DMA channel leaves free.""" + lights = [_entry("dma", 192, **{CONF_USE_DMA: True}), _entry("other", 192)] + messages = run_check(VARIANT_ESP32S3, {"TX": lights}) + assert len(messages) == 1 + assert "'dma' (DMA), 'other' (192)" in messages[0] + assert "Set 'rmt_symbols: 144' on 'other'." in messages[0] + + +@pytest.mark.parametrize( + ("variant", "direction", "symbols", "extra", "match"), + [ + # The original ESP32 has blocks of 64 symbols + (VARIANT_ESP32, "TX", 48, {}, "even number of at least 64 on ESP32,"), + (VARIANT_ESP32S3, "RX", 97, {}, "even number of at least 48 on ESP32S3,"), + # Also with DMA, where rmt_symbols sizes the DMA buffer + (VARIANT_ESP32S3, "TX", 40, {CONF_USE_DMA: True}, "at least 48"), + (VARIANT_ESP32S3, "TX", 2048, {CONF_USE_DMA: True}, "at most 2046 with"), + # One channel cannot use more memory than there is + (VARIANT_ESP32S2, "TX", 320, {}, "at most 256 on ESP32S2"), + (VARIANT_ESP32C3, "RX", 144, {}, "at most 96 on ESP32C3"), + (VARIANT_ESP32C3, "TX", 240, {}, "at most 192 on ESP32C3"), + ], +) +def test_invalid_rmt_symbols_fail( + variant: str, + direction: esp32_rmt.Direction, + symbols: int, + extra: dict[str, object], + match: str, +) -> None: + """The driver refuses these values when it creates the channel.""" + _set_variant(variant) + with pytest.raises(cv.Invalid, match=match) as error: + esp32_rmt.request_rmt_channel(direction, PRIORITY)( + _entry("x", symbols, **extra) + ) + assert error.value.path == [CONF_RMT_SYMBOLS] + + +@pytest.mark.parametrize("variant", sorted(esp32_rmt.RMT_MEMORY)) +def test_symbol_limits_of_every_variant(variant: str) -> None: + """One block is the minimum; TX can reach the RX blocks, RX only its own blocks.""" + memory = esp32_rmt.RMT_MEMORY[variant] + size = memory.symbols_per_block + rx_blocks = memory.rx_blocks or 0 + limits = { + "TX": (memory.tx_blocks + rx_blocks) * size, + "RX": (rx_blocks or memory.tx_blocks) * size, + } + _set_variant(variant) + for direction, limit in limits.items(): + check = esp32_rmt.request_rmt_channel(direction, PRIORITY) + for symbols in (size, limit): + check(_entry("ok", symbols)) + for symbols in (size - 2, limit + 2): + with pytest.raises(cv.Invalid): + check(_entry("too_small_or_large", symbols)) + + +@pytest.mark.parametrize( + ("direction", "symbols", "extra"), + [ + # A TX channel can extend into the RX blocks: 384 on S3 boots + ("TX", 384, {}), + ("RX", 192, {}), + # RX has no DMA buffer limit + ("RX", 4096, {CONF_USE_DMA: True}), + ("TX", 2046, {CONF_USE_DMA: True}), + ], +) +def test_valid_rmt_symbols_pass( + direction: esp32_rmt.Direction, symbols: int, extra: dict[str, object] +) -> None: + _set_variant(VARIANT_ESP32S3) + entry = _entry("x", symbols, **extra) + assert esp32_rmt.request_rmt_channel(direction, PRIORITY)(entry) is entry + + +def test_two_dma_channels_in_one_direction_fail(run_check: CheckRunner) -> None: + """Only the last channel of each direction supports DMA.""" + config = { + "TX": [ + _entry("dma", 48, **{CONF_USE_DMA: True}), + _entry("ir", 48, **{CONF_USE_DMA: True}), + ] + } + with pytest.raises(cv.Invalid, match="Only one RMT TX channel on ESP32S3") as error: + run_check(VARIANT_ESP32S3, config) + assert error.value.path == [CONF_USE_DMA] + + +def test_combined_errors_are_not_shown_on_use_dma(run_check: CheckRunner) -> None: + """With errors of other components joined in, the error is on the component itself.""" + config = { + "TX": [_entry(f"dma{i}", 48, **{CONF_USE_DMA: True}) for i in range(2)], + "RX": [_entry(f"rx{i}", 48) for i in range(5)], + } + with pytest.raises(cv.Invalid, match="supports DMA") as error: + run_check(VARIANT_ESP32S3, config) + assert error.value.path == [] + + +def test_dma_on_tx_and_rx(run_check: CheckRunner) -> None: + """One DMA channel per direction is fine.""" + config = { + "TX": [_entry("dma", 48, **{CONF_USE_DMA: True})], + "RX": [_entry("rx", 48, **{CONF_USE_DMA: True})], + } + assert not run_check(VARIANT_ESP32S3, config) + + +@pytest.mark.parametrize( + ("variant", "config", "match"), + [ + (VARIANT_ESP32C3, _strips(48, 48, 48), "ESP32C3 has 2 RMT channels for TX"), + ( + VARIANT_ESP32S2, + {**_strips(64, 64, 64, 64), "RX": [_entry("rx", 64)]}, + "ESP32S2 has 4 RMT channels shared by TX and RX, but these 5 components", + ), + ( + VARIANT_ESP32S3, + {"RX": [_entry(f"rx{i}", 48) for i in range(5)]}, + "ESP32S3 has 4 RMT channels for RX", + ), + ], +) +def test_more_components_than_channels_fail( + run_check: CheckRunner, variant: str, config: Config, match: str +) -> None: + """Every component needs its own channel, whatever its rmt_symbols.""" + with pytest.raises(cv.Invalid, match=match): + run_check(variant, config) + + +def test_error_raised_from_a_component_that_causes_it() -> None: + """The error is not raised from an RMT component that is not involved.""" + _set_variant(VARIANT_ESP32S3) + receiver = _entry("rx", 48) + transmitters = [_entry(f"tx{i}", 48) for i in range(5)] + receivers = [ + receiver, + *(_entry(f"rx{i}", 48, **{CONF_USE_DMA: True}) for i in range(2)), + ] + for entry in transmitters: + esp32_rmt.request_rmt_channel("TX", PRIORITY)(entry) + for entry in receivers: + esp32_rmt.request_rmt_channel("RX", PRIORITY)(entry) + esp32_rmt.final_validate_rmt_channels(receiver) + with pytest.raises(cv.Invalid) as error: + esp32_rmt.final_validate_rmt_channels(transmitters[0]) + # Both errors are reported at once, as final validation stops after the first one + assert "ESP32S3 has 4 RMT channels for TX" in str(error.value) + assert "Only one RMT RX channel on ESP32S3 supports DMA" in str(error.value) + + +def test_error_raised_when_ids_are_copies() -> None: + """IDs are compared by value, so a copied ID still finds its error.""" + _set_variant(VARIANT_ESP32C3) + transmitters = [_entry(f"tx{i}", 48) for i in range(3)] + for entry in transmitters: + esp32_rmt.request_rmt_channel("TX", PRIORITY)(entry) + with pytest.raises(cv.Invalid, match="ESP32C3 has 2 RMT channels for TX"): + esp32_rmt.final_validate_rmt_channels( + {**transmitters[0], CONF_ID: transmitters[0][CONF_ID].copy()} + ) + + +@pytest.mark.parametrize( + ("platform", "variant"), + [ + (PLATFORM_ESP8266, VARIANT_ESP32S3), + (PLATFORM_ESP32, VARIANT_ESP32C2), + (PLATFORM_ESP32, VARIANT_ESP32C61), + (PLATFORM_ESP32, "ESP32X1"), + ], +) +def test_not_checked_without_rmt_caps( + run_check: CheckRunner, platform: str, variant: str +) -> None: + """Other platforms, variants without RMT and variants not in the table are skipped.""" + assert not run_check(variant, _strips(192, 192, 192, 192, 192, 7), platform) + + +def test_not_checked_in_testing_mode( + run_check: CheckRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """Grouped component tests merge many configs, so the combined check is skipped there.""" + monkeypatch.setattr(CORE, "testing_mode", True) + assert not run_check(VARIANT_ESP32C3, _strips(96, 96, 96)) + + +def test_warning_comes_from_code_generation( + caplog: pytest.LogCaptureFixture, +) -> None: + """Validation alone does not warn; code generation warns once.""" + _set_variant(VARIANT_ESP32S3) + strips = _strips(192, 192)["TX"] + with caplog.at_level(logging.WARNING): + _validate({"TX": strips}) + assert not _rmt_messages(caplog) + _generate(strips) + assert len(_rmt_messages(caplog)) == 1 + + +@pytest.mark.parametrize( + ("path", "label"), + [ + (["light", 1, CONF_ID], "light[1]"), + # A component that is not a list, such as an external one without MULTI_CONF + (["my_rmt", CONF_ID], "generated"), + ], +) +def test_generated_ids_are_named_by_position( + run_check: CheckRunner, path: list[str | int], label: str +) -> None: + """A component without a name or manual id is named by where it is in the config.""" + + class FullConfig: + def get_path_for_id(self, _: ID) -> list[str | int]: + return path + + token = fv.full_config.set(FullConfig()) + try: + entry = {**_entry("x", 192), CONF_ID: ID("generated", is_manual=False)} + messages = run_check(VARIANT_ESP32S3, {"TX": [entry, _entry("other", 192)]}) + finally: + fv.full_config.reset(token) + assert f"'{label}' (192), 'other' (192)" in messages[0] From 5d511a1bd180b537b259fff942e924d8e6ea6c95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 18:17:08 -0600 Subject: [PATCH 155/170] [sim800l] Pass callback strings by const reference (#22203) --- esphome/components/sim800l/sim800l.cpp | 5 ++--- esphome/components/sim800l/sim800l.h | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/esphome/components/sim800l/sim800l.cpp b/esphome/components/sim800l/sim800l.cpp index 9cebf35c561..2ee6bb91728 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -200,12 +200,11 @@ void Sim800LComponent::parse_cmd_(std::string message) { ESP_LOGD(TAG, "Check ussd code: '%s'", message.c_str()); if (message.starts_with("+CUSD:")) { this->state_ = STATE_RECEIVED_USSD; - this->ussd_ = ""; size_t start = 10; size_t end = message.find_last_of(','); if (end > start) { - this->ussd_ = message.substr(start + 1, end - start - 2); - this->ussd_received_callback_.call(this->ussd_); + // Not stored in ussd_: send_ussd() from a listener would overwrite the text the others receive + this->ussd_received_callback_.call(message.substr(start + 1, end - start - 2)); } } // Otherwise we receive another OK, we do nothing just wait polling to continuously check for SMS diff --git a/esphome/components/sim800l/sim800l.h b/esphome/components/sim800l/sim800l.h index 82523406aae..08448343d39 100644 --- a/esphome/components/sim800l/sim800l.h +++ b/esphome/components/sim800l/sim800l.h @@ -114,12 +114,12 @@ class Sim800LComponent final : public uart::UARTDevice, public PollingComponent bool dtmf_ready_{false}; uint8_t call_state_{6}; - CallbackManager sms_received_callback_; - CallbackManager incoming_call_callback_; + CallbackManager sms_received_callback_; + CallbackManager incoming_call_callback_; CallbackManager call_connected_callback_; CallbackManager call_disconnected_callback_; - CallbackManager ussd_received_callback_; - CallbackManager dtmf_received_callback_; + CallbackManager ussd_received_callback_; + CallbackManager dtmf_received_callback_; }; } // namespace esphome::sim800l From 2d73971fcc33a8a28394dd9401aeaa821deb28d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 18:28:29 -0600 Subject: [PATCH 156/170] [web_server_idf] Send a default header once when a response sets it (#21974) --- .../web_server_idf/web_server_idf.cpp | 28 +++++++++++++++---- .../web_server_idf/web_server_idf.h | 13 +++++++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 657575df432..2e033285711 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -77,6 +77,13 @@ DefaultHeaders default_headers_instance; DefaultHeaders &DefaultHeaders::Instance() { return default_headers_instance; } +void DefaultHeaders::apply_(httpd_req_t *req, uint8_t skip_mask) const { + for (size_t i = 0; i < this->headers_.size(); i++) { + if (!(skip_mask & (1U << i))) + httpd_resp_set_hdr(req, this->headers_[i].name, this->headers_[i].value); + } +} + namespace { // Non-blocking send function to prevent watchdog timeouts when TCP buffers are full /** @@ -381,14 +388,20 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code } httpd_resp_set_hdr(*this, "Accept-Ranges", "none"); - for (const auto &header : DefaultHeaders::Instance().headers_) { - httpd_resp_set_hdr(*this, header.name, header.value); - } + // A response object sends the defaults in send(), after its own headers, so one it set itself can + // replace a default of the same name (httpd_resp_set_hdr only appends). + if (rsp == nullptr) + DefaultHeaders::Instance().apply_(*this); delete this->rsp_; this->rsp_ = rsp; } +void AsyncWebServerRequest::send(AsyncWebServerResponse *response) { + DefaultHeaders::Instance().apply_(*this, response->replaced_defaults_); + httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); +} + #ifdef USE_WEBSERVER_AUTH #ifdef USE_WEBSERVER_AUTH_DIGEST @@ -679,6 +692,11 @@ std::string AsyncWebServerRequest::arg(const char *name) { } void AsyncWebServerResponse::addHeader(const char *name, const char *value) { + const auto &defaults = DefaultHeaders::Instance().headers_; + for (size_t i = 0; i < defaults.size(); i++) { + if (str_equals_case_insensitive(name, defaults[i].name)) + this->replaced_defaults_ |= 1U << i; + } httpd_resp_set_hdr(*this->req_, name, value); } @@ -819,9 +837,7 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * httpd_resp_set_hdr(req, "Cache-Control", "no-cache"); httpd_resp_set_hdr(req, "Connection", "keep-alive"); - for (const auto &header : DefaultHeaders::Instance().headers_) { - httpd_resp_set_hdr(req, header.name, header.value); - } + DefaultHeaders::Instance().apply_(req); httpd_resp_send_chunk(req, CRLF_STR, CRLF_LEN); diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index e79bad95c79..b017e6b36c4 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -45,6 +45,8 @@ class AsyncWebParameter { class AsyncWebServerRequest; class AsyncWebServerResponse { + friend class AsyncWebServerRequest; + public: AsyncWebServerResponse(const AsyncWebServerRequest *req) : req_(req) {} virtual ~AsyncWebServerResponse() {} @@ -57,6 +59,8 @@ class AsyncWebServerResponse { protected: const AsyncWebServerRequest *req_; + // Bit i set: this response set default header i itself, so it is not sent at send time + uint8_t replaced_defaults_{0}; }; class AsyncWebServerResponseEmpty : public AsyncWebServerResponse { @@ -132,9 +136,7 @@ class AsyncWebServerRequest { void redirect(const char *url); void redirect(const std::string &url) { this->redirect(url.c_str()); } - inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) { - httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); - } + void send(AsyncWebServerResponse *response); inline void ESPHOME_ALWAYS_INLINE send(int code, const char *content_type = nullptr, const char *content = nullptr) { this->init_response_(nullptr, code, content_type); if (content) { @@ -458,9 +460,11 @@ struct HttpHeader { class DefaultHeaders { friend class AsyncWebServerRequest; + friend class AsyncWebServerResponse; #ifdef USE_WEBSERVER friend class AsyncEventSourceResponse; #endif + static_assert(WEB_SERVER_DEFAULT_HEADERS_COUNT <= 8, "AsyncWebServerResponse::replaced_defaults_ holds 8 bits"); public: // NOLINTNEXTLINE(readability-identifier-naming) @@ -472,6 +476,9 @@ class DefaultHeaders { protected: // Stack-allocated, no reallocation machinery. Count defined in web_server_base where headers are added. StaticVector headers_; + + /// Set every default header on the response except those whose bit is set in skip_mask. + void apply_(httpd_req_t *req, uint8_t skip_mask = 0) const; }; } // namespace web_server_idf From 9dea9354992ca1a767f31aab14cbb7adbfb64005 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 18:50:59 -0600 Subject: [PATCH 157/170] [web_server_base] Count the HTTP server's sockets in one place (#21971) --- esphome/components/esp32/__init__.py | 32 ++++--- esphome/components/web_server/__init__.py | 5 +- .../components/web_server_base/__init__.py | 37 +++++++- esphome/components/web_server_idf/__init__.py | 42 +++++++++ .../web_server_idf/web_server_idf.cpp | 1 + esphome/core/defines.h | 1 + .../web_server_idf/__init__.py | 0 .../web_server_idf/config/lwip_override.yaml | 19 ++++ .../web_server_idf/config/web_server.yaml | 13 +++ .../config/web_server_captive_portal.yaml | 17 ++++ .../config/web_server_esp8266.yaml | 11 +++ .../web_server_idf/test_httpd_sockets.py | 87 +++++++++++++++++++ 12 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 tests/component_tests/web_server_idf/__init__.py create mode 100644 tests/component_tests/web_server_idf/config/lwip_override.yaml create mode 100644 tests/component_tests/web_server_idf/config/web_server.yaml create mode 100644 tests/component_tests/web_server_idf/config/web_server_captive_portal.yaml create mode 100644 tests/component_tests/web_server_idf/config/web_server_esp8266.yaml create mode 100644 tests/component_tests/web_server_idf/test_httpd_sockets.py diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index e7353cbeb6b..739bfab91cb 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2535,7 +2535,22 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = cv.Schema(final_validate) -def _configure_lwip_max_sockets(conf: dict) -> None: +def get_user_lwip_max_sockets() -> int | None: + """CONFIG_LWIP_MAX_SOCKETS from the user's sdkconfig_options, or None when unset. + + A value that is not a number reads as 0. + """ + user_max_sockets = CORE.config[DOMAIN][CONF_FRAMEWORK][CONF_SDKCONFIG_OPTIONS].get( + "CONFIG_LWIP_MAX_SOCKETS" + ) + if user_max_sockets is None: + return None + with contextlib.suppress(ValueError, TypeError): + return int(user_max_sockets) + return 0 + + +def _configure_lwip_max_sockets() -> None: """Calculate and set CONFIG_LWIP_MAX_SOCKETS based on component needs. Socket component tracks consumer needs via consume_sockets() called during config validation. @@ -2544,8 +2559,7 @@ def _configure_lwip_max_sockets(conf: dict) -> None: """ from esphome.components.socket import get_socket_counts - # Check if user manually specified CONFIG_LWIP_MAX_SOCKETS - user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS") + user_max_sockets = get_user_lwip_max_sockets() # CONFIG_LWIP_MAX_SOCKETS is a single VFS socket pool shared by all socket # types (TCP clients, TCP listeners, and UDP). Include all three counts. @@ -2555,21 +2569,17 @@ def _configure_lwip_max_sockets(conf: dict) -> None: # User specified their own value - respect it but warn if insufficient if user_max_sockets is not None: _LOGGER.info( - "Using user-provided CONFIG_LWIP_MAX_SOCKETS: %s", + "Using user-provided CONFIG_LWIP_MAX_SOCKETS: %d", user_max_sockets, ) - user_sockets_int = 0 - with contextlib.suppress(ValueError, TypeError): - user_sockets_int = int(user_max_sockets) - - if user_sockets_int < total_sockets: + if user_max_sockets < total_sockets: _LOGGER.warning( "CONFIG_LWIP_MAX_SOCKETS is set to %d but your configuration " "needs %d sockets (%d TCP + %d UDP + %d TCP_LISTEN). You may " "experience socket exhaustion errors. Consider increasing to " "at least %d.", - user_sockets_int, + user_max_sockets, total_sockets, sc.tcp, sc.udp, @@ -3327,7 +3337,7 @@ async def to_code(config): # Kconfig range is [1,63]; 0 gets clamped to the default. add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1) - _configure_lwip_max_sockets(conf) + _configure_lwip_max_sockets() if advanced[CONF_EXECUTE_FROM_PSRAM]: add_idf_sdkconfig_option("CONFIG_SPIRAM_XIP_FROM_PSRAM", True) diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index f2b9b945857..0d550daa153 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -222,13 +222,10 @@ def _final_validate_sorting(config: ConfigType) -> None: def _consume_web_server_sockets(config: ConfigType) -> ConfigType: """Register socket needs for web_server component.""" - from esphome.components import socket - # Web server needs typically 5 concurrent client connections # (browser opens connections for page resources, SSE event stream, and POST # requests for entity control which may linger before closing) - # The listening socket is registered by web_server_base (shared with captive_portal) - socket.consume_sockets(5, "web_server")(config) + web_server_base.add_httpd_clients(5, DOMAIN) return config diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 16251537497..f290873c2f6 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path import esphome.codegen as cg @@ -28,10 +29,39 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +@dataclass +class WebServerBaseData: + # Client connections the consumers keep open on the shared HTTP server. + httpd_clients: int = 0 + + +def _get_data() -> WebServerBaseData: + return CORE.data.setdefault(DOMAIN, WebServerBaseData()) + + +def add_httpd_clients(count: int, consumer: str) -> None: + """Register client connections that consumer keeps open on the shared HTTP server. + + Counts them as TCP sockets for the network stack and adds them to the client + limit of the ESP-IDF httpd server (max_open_sockets). Call during config + validation. + """ + from esphome.components import socket + + socket.consume_sockets(count, consumer)({}) + _get_data().httpd_clients += count + + +def get_httpd_client_count() -> int: + """Total client connections registered with add_httpd_clients().""" + return _get_data().httpd_clients + + def consume_captive_dns_sockets(config: ConfigType, name: str) -> None: """Register the sockets a captive portal needs on top of the shared HTTP server: 1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes, - which make several requests that linger in TIME_WAIT.""" + which make several requests that linger in TIME_WAIT. The probes are not httpd + clients: they only happen in AP mode, where LRU purging handles overflow.""" from esphome.components import socket socket.consume_sockets(3, name)(config) @@ -48,11 +78,12 @@ def _consume_web_server_base_sockets(config: ConfigType) -> ConfigType: """Register the shared listening socket for the HTTP server. web_server_base is the shared HTTP server used by web_server and captive_portal. - The listening socket is registered here rather than in each consumer. + The listening socket is registered here rather than in each consumer; consumers + register their clients with add_httpd_clients(). """ from esphome.components import socket - socket.consume_sockets(1, "web_server_base", socket.SocketType.TCP_LISTEN)(config) + socket.consume_sockets(1, DOMAIN, socket.SocketType.TCP_LISTEN)(config) return config diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 98e067665ad..414d23e8fd0 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,4 +1,9 @@ +import logging + +import esphome.codegen as cg +from esphome.components import socket, web_server_base from esphome.components.esp32 import ( + get_user_lwip_max_sockets, include_builtin_idf_component, set_idf_sdkconfig_default, ) @@ -6,16 +11,53 @@ from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@dentra"] DOMAIN = "web_server_idf" +# ESP-IDF's HTTPD_DEFAULT_CONFIG max_open_sockets. Kept as the floor for the httpd +# client limit so a config that registers fewer clients behaves as before. +DEFAULT_HTTPD_MAX_OPEN_SOCKETS = 7 +# The ESP-IDF httpd server opens 2 UDP sockets of its own to pass control messages. +HTTPD_CONTROL_UDP_SOCKETS = 2 + + CONFIG_SCHEMA = cv.All( cv.Schema({}), cv.only_on_esp32, + # The listening socket is registered by web_server_base. + socket.consume_sockets(HTTPD_CONTROL_UDP_SOCKETS, DOMAIN, socket.SocketType.UDP), ) +def _httpd_max_open_sockets() -> int: + """Client limit for the httpd server: the registered clients, at least the ESP-IDF + default, and no more than a user-set CONFIG_LWIP_MAX_SOCKETS allows.""" + max_open = max( + DEFAULT_HTTPD_MAX_OPEN_SOCKETS, web_server_base.get_httpd_client_count() + ) + # Without a user override esp32 sizes CONFIG_LWIP_MAX_SOCKETS from the socket + # registry, which already holds the clients and the internal sockets. + if (user_max := get_user_lwip_max_sockets()) is None: + return max_open + # httpd_start() refuses max_open_sockets above CONFIG_LWIP_MAX_SOCKETS minus the + # listening socket and the 2 UDP control sockets. esp32 already warns when the + # user's value is below what the config needs. + limit = max(1, user_max - 1 - HTTPD_CONTROL_UDP_SOCKETS) + if max_open > limit: + _LOGGER.info( + "CONFIG_LWIP_MAX_SOCKETS=%d limits the web server to %d clients", + user_max, + limit, + ) + max_open = limit + return max_open + + async def to_code(config: ConfigType) -> None: + # Runs after config validation, so every add_httpd_clients() call is counted. + cg.add_define("WEB_SERVER_MAX_OPEN_SOCKETS", _httpd_max_open_sockets()) # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server set_idf_sdkconfig_default("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) # Re-enable ESP-IDF's HTTP server (excluded by default to save compile time). diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 2e033285711..e82749ebbb5 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -159,6 +159,7 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 4512; config.server_port = this->port_; + config.max_open_sockets = WEB_SERVER_MAX_OPEN_SOCKETS; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; // Always enable LRU purging to handle socket exhaustion gracefully. // When max sockets is reached, the oldest connection is closed to make room for new ones. diff --git a/esphome/core/defines.h b/esphome/core/defines.h index caaa2b83564..d4328860edd 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -591,6 +591,7 @@ #define USE_WEBSERVER_PRIVATE_NETWORK_ACCESS #define USE_WEBSERVER_VERSION 3 #define WEB_SERVER_DEFAULT_HEADERS_COUNT 1 +#define WEB_SERVER_MAX_OPEN_SOCKETS 7 #define USE_CAPTIVE_PORTAL_GZIP #endif #define USE_WIFI_11KV_SUPPORT diff --git a/tests/component_tests/web_server_idf/__init__.py b/tests/component_tests/web_server_idf/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/web_server_idf/config/lwip_override.yaml b/tests/component_tests/web_server_idf/config/lwip_override.yaml new file mode 100644 index 00000000000..3edb7f6f6e9 --- /dev/null +++ b/tests/component_tests/web_server_idf/config/lwip_override.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + sdkconfig_options: + CONFIG_LWIP_MAX_SOCKETS: "9" + +wifi: + ssid: MySSID + password: password1 + ap: + ssid: Fallback + +web_server: + +captive_portal: diff --git a/tests/component_tests/web_server_idf/config/web_server.yaml b/tests/component_tests/web_server_idf/config/web_server.yaml new file mode 100644 index 00000000000..b7ee84636d3 --- /dev/null +++ b/tests/component_tests/web_server_idf/config/web_server.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/component_tests/web_server_idf/config/web_server_captive_portal.yaml b/tests/component_tests/web_server_idf/config/web_server_captive_portal.yaml new file mode 100644 index 00000000000..07e1fcd8481 --- /dev/null +++ b/tests/component_tests/web_server_idf/config/web_server_captive_portal.yaml @@ -0,0 +1,17 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + framework: + type: esp-idf + +wifi: + ssid: MySSID + password: password1 + ap: + ssid: Fallback + +web_server: + +captive_portal: diff --git a/tests/component_tests/web_server_idf/config/web_server_esp8266.yaml b/tests/component_tests/web_server_idf/config/web_server_esp8266.yaml new file mode 100644 index 00000000000..134e1988164 --- /dev/null +++ b/tests/component_tests/web_server_idf/config/web_server_esp8266.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: diff --git a/tests/component_tests/web_server_idf/test_httpd_sockets.py b/tests/component_tests/web_server_idf/test_httpd_sockets.py new file mode 100644 index 00000000000..b9ae7ca6942 --- /dev/null +++ b/tests/component_tests/web_server_idf/test_httpd_sockets.py @@ -0,0 +1,87 @@ +"""Tests for the ESP-IDF httpd server's socket accounting.""" + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +from esphome.components import web_server_base, web_server_idf +from esphome.components.socket import KEY_SOCKET_CONSUMERS_UDP +from esphome.core import CORE +from tests.component_tests.helpers import get_define_value + +MAX_OPEN_DEFINE = "WEB_SERVER_MAX_OPEN_SOCKETS" + + +@pytest.mark.parametrize( + "config_file", + [ + # web_server alone registers 5 clients, below the ESP-IDF default. + "web_server.yaml", + # The captive portal probes are plain TCP sockets, not httpd slots. + "web_server_captive_portal.yaml", + ], +) +def test_limit_stays_at_idf_default( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Configs with fewer clients than the ESP-IDF default keep the default.""" + generate_main(component_config_path(config_file)) + + assert CORE.data[KEY_SOCKET_CONSUMERS_UDP]["web_server_idf"] == 2 + assert get_define_value(MAX_OPEN_DEFINE) == "7" + + +def test_extra_consumer_raises_limit( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """A consumer holding long-lived clients raises the limit.""" + web_server_base.add_httpd_clients(4, "stream") + generate_main(component_config_path("web_server.yaml")) + + assert get_define_value(MAX_OPEN_DEFINE) == "9" + + +def test_user_lwip_limit_caps_clients( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + caplog: pytest.LogCaptureFixture, +) -> None: + """httpd_start() rejects more than CONFIG_LWIP_MAX_SOCKETS - 3 clients.""" + caplog.set_level(logging.INFO) + generate_main(component_config_path("lwip_override.yaml")) + + assert get_define_value(MAX_OPEN_DEFINE) == "6" + # One warning for the undersized pool, from esp32 only. + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "CONFIG_LWIP_MAX_SOCKETS is set to 9" in warnings[0].message + assert "limits the web server to 6 clients" in caplog.text + + +def test_tiny_user_lwip_limit_keeps_define_valid( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A pool too small for any client still yields a limit of at least 1. + + httpd_start() still fails at runtime with such a pool; esp32 warns about it. + """ + monkeypatch.setattr(web_server_idf, "get_user_lwip_max_sockets", lambda: 3) + web_server_base.add_httpd_clients(5, "web_server") + + assert web_server_idf._httpd_max_open_sockets() == 1 + + +def test_esp8266_has_no_httpd_sockets( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """ESPAsyncWebServer has no httpd control sockets or client limit define.""" + generate_main(component_config_path("web_server_esp8266.yaml")) + + assert "web_server_idf" not in CORE.data.get(KEY_SOCKET_CONSUMERS_UDP, {}) + assert get_define_value(MAX_OPEN_DEFINE) is None From 38ed6919f86619efc80e8b6ad60909b96dba3653 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 19:25:55 -0600 Subject: [PATCH 158/170] [bme68x_bsec2] Share BSEC test double types with bme680_bsec to fix the all-components C++ unit test build (#22245) --- tests/components/bme680_bsec/__sdk/bsec.h | 68 +-------------- .../components/bme680_bsec/__sdk/bsec_types.h | 85 +++++++++++++++++++ tests/components/bme68x_bsec2/__init__.py | 12 +++ tests/components/bme68x_bsec2/__sdk/bsec2.h | 75 +--------------- 4 files changed, 102 insertions(+), 138 deletions(-) create mode 100644 tests/components/bme680_bsec/__sdk/bsec_types.h diff --git a/tests/components/bme680_bsec/__sdk/bsec.h b/tests/components/bme680_bsec/__sdk/bsec.h index 3d917e225a4..a256e2bc5dd 100644 --- a/tests/components/bme680_bsec/__sdk/bsec.h +++ b/tests/components/bme680_bsec/__sdk/bsec.h @@ -5,6 +5,8 @@ #include #include +#include "bsec_types.h" + static constexpr int8_t BME680_OK = 0; static constexpr uint8_t BME680_I2C_INTF = 1; static constexpr uint8_t BME680_SLEEP_MODE = 0; @@ -16,77 +18,11 @@ static constexpr uint16_t BME680_GAS_SENSOR_SEL = 8 | 64 | 128; static constexpr uint8_t BME680_NEW_DATA_MSK = 0x80; static constexpr uint8_t BME680_GASM_VALID_MSK = 0x20; -static constexpr uint32_t BSEC_MAX_PROPERTY_BLOB_SIZE = 1; -static constexpr uint32_t BSEC_MAX_STATE_BLOB_SIZE = 16; -static constexpr uint32_t BSEC_MAX_WORKBUFFER_SIZE = 16; -static constexpr uint8_t BSEC_NUMBER_OUTPUTS = 14; -static constexpr uint8_t BSEC_MAX_PHYSICAL_SENSOR = 8; -#define BSEC_SAMPLE_RATE_LP 0.33333f -#define BSEC_SAMPLE_RATE_ULP 0.0033333f - -enum { - BSEC_INPUT_PRESSURE = 1, - BSEC_INPUT_HUMIDITY = 2, - BSEC_INPUT_TEMPERATURE = 3, - BSEC_INPUT_GASRESISTOR = 4, - BSEC_INPUT_HEATSOURCE = 14, -}; static constexpr uint32_t BSEC_PROCESS_PRESSURE = 1 << (BSEC_INPUT_PRESSURE - 1); static constexpr uint32_t BSEC_PROCESS_TEMPERATURE = 1 << (BSEC_INPUT_TEMPERATURE - 1); static constexpr uint32_t BSEC_PROCESS_HUMIDITY = 1 << (BSEC_INPUT_HUMIDITY - 1); static constexpr uint32_t BSEC_PROCESS_GAS = 1 << (BSEC_INPUT_GASRESISTOR - 1); -enum { - BSEC_OUTPUT_IAQ = 1, - BSEC_OUTPUT_STATIC_IAQ = 2, - BSEC_OUTPUT_CO2_EQUIVALENT = 3, - BSEC_OUTPUT_BREATH_VOC_EQUIVALENT = 4, - BSEC_OUTPUT_RAW_PRESSURE = 7, - BSEC_OUTPUT_RAW_GAS = 9, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE = 14, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY = 15, -}; - -enum bsec_library_return_t { - BSEC_OK = 0, -}; - -struct bsec_version_t { - uint8_t major, minor, major_bugfix, minor_bugfix; -}; - -struct bsec_input_t { - int64_t time_stamp; - float signal; - uint8_t signal_dimensions; - uint8_t sensor_id; -}; - -struct bsec_output_t { - int64_t time_stamp; - float signal; - uint8_t signal_dimensions; - uint8_t sensor_id; - uint8_t accuracy; -}; - -struct bsec_sensor_configuration_t { - float sample_rate; - uint8_t sensor_id; -}; - -struct bsec_bme_settings_t { - int64_t next_call; - uint32_t process_data; - uint16_t heater_temperature; - uint16_t heating_duration; - uint8_t run_gas; - uint8_t pressure_oversampling; - uint8_t temperature_oversampling; - uint8_t humidity_oversampling; - uint8_t trigger_measurement; -}; - struct bme680_tph_sett { uint8_t os_hum, os_temp, os_pres; }; diff --git a/tests/components/bme680_bsec/__sdk/bsec_types.h b/tests/components/bme680_bsec/__sdk/bsec_types.h new file mode 100644 index 00000000000..0ad3ae86cb2 --- /dev/null +++ b/tests/components/bme680_bsec/__sdk/bsec_types.h @@ -0,0 +1,85 @@ +// The BSEC library types and constants that both the BSEC 1.x double (bsec.h) and the BSEC2 double +// (bme68x_bsec2/__sdk/bsec2.h) use. The real libraries never share a build, but the all-components C++ unit +// test binary includes both doubles, so they share one definition. Sizes and fields are the union of both. +// An include guard, not #pragma once: the bme68x_bsec2 tests reach this file through another path. +#ifndef ESPHOME_TEST_BSEC_TYPES_H +#define ESPHOME_TEST_BSEC_TYPES_H + +#include + +static constexpr uint32_t BSEC_MAX_PROPERTY_BLOB_SIZE = 16; +static constexpr uint32_t BSEC_MAX_STATE_BLOB_SIZE = 16; +static constexpr uint32_t BSEC_MAX_WORKBUFFER_SIZE = 16; +static constexpr uint8_t BSEC_NUMBER_OUTPUTS = 20; +static constexpr uint8_t BSEC_MAX_PHYSICAL_SENSOR = 8; +#define BSEC_SAMPLE_RATE_LP 0.33333f +#define BSEC_SAMPLE_RATE_ULP 0.0033333f + +enum { + BSEC_INPUT_PRESSURE = 1, + BSEC_INPUT_HUMIDITY = 2, + BSEC_INPUT_TEMPERATURE = 3, + BSEC_INPUT_GASRESISTOR = 4, + BSEC_INPUT_HEATSOURCE = 14, + BSEC_INPUT_PROFILE_PART = 24, +}; + +enum { + BSEC_OUTPUT_IAQ = 1, + BSEC_OUTPUT_STATIC_IAQ = 2, + BSEC_OUTPUT_CO2_EQUIVALENT = 3, + BSEC_OUTPUT_BREATH_VOC_EQUIVALENT = 4, + BSEC_OUTPUT_RAW_PRESSURE = 7, + BSEC_OUTPUT_RAW_GAS = 9, + BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE = 14, + BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY = 15, +}; + +enum bsec_library_return_t { + BSEC_OK = 0, + BSEC_I_SU_SUBSCRIBEDOUTPUTGATES = 12, +}; + +struct bsec_version_t { + uint8_t major, minor, major_bugfix, minor_bugfix; +}; + +struct bsec_input_t { + int64_t time_stamp; + float signal; + uint8_t signal_dimensions; + uint8_t sensor_id; +}; + +struct bsec_output_t { + int64_t time_stamp; + float signal; + uint8_t signal_dimensions; + uint8_t sensor_id; + uint8_t accuracy; +}; + +struct bsec_sensor_configuration_t { + float sample_rate; + uint8_t sensor_id; +}; + +// BSEC 1.x names the heater time heating_duration; BSEC2 names it heater_duration and adds the profile fields. +struct bsec_bme_settings_t { + int64_t next_call; + uint32_t process_data; + uint16_t heater_temperature; + uint16_t heating_duration; + uint16_t heater_duration; + uint16_t heater_temperature_profile[10]; + uint16_t heater_duration_profile[10]; + uint8_t heater_profile_len; + uint8_t run_gas; + uint8_t pressure_oversampling; + uint8_t temperature_oversampling; + uint8_t humidity_oversampling; + uint8_t trigger_measurement; + uint8_t op_mode; +}; + +#endif // ESPHOME_TEST_BSEC_TYPES_H diff --git a/tests/components/bme68x_bsec2/__init__.py b/tests/components/bme68x_bsec2/__init__.py index 1b93dd955d0..f3d9bf8e235 100644 --- a/tests/components/bme68x_bsec2/__init__.py +++ b/tests/components/bme68x_bsec2/__init__.py @@ -1,11 +1,20 @@ """Build the BSEC2 component on the host against the BSEC2/BME68x double in __sdk.""" +from pathlib import Path + import esphome.codegen as cg import esphome.config_validation as cv from esphome.core import CORE +from esphome.helpers import write_file_if_changed from esphome.types import ConfigType from tests.testing_helpers import ComponentManifestOverride +# bsec_types.h is shared with the BSEC 1.x double. Only that header is copied into its own build directory, so it +# is found without the bme680_bsec tests and the BSEC 1.x bsec.h never lands on the include path twice. +_SHARED_TYPES = ( + Path(__file__).resolve().parent.parent / "bme680_bsec" / "__sdk" / "bsec_types.h" +) + def override_manifest(manifest: ComponentManifestOverride) -> None: manifest.dependencies = ["sensor", "text_sensor"] @@ -13,6 +22,9 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: cg.add_build_flag(f"-I{CORE.relative_src_path('bme68x_bsec2', '__sdk')}") + shared = CORE.relative_build_path("bsec_shared") + write_file_if_changed(shared / "bsec_types.h", _SHARED_TYPES.read_text()) + cg.add_build_flag(f"-I{shared}") cg.add_define("USE_BSEC2") cg.add_define("USE_SENSOR") cg.add_define("ESPHOME_ENTITY_SENSOR_COUNT", 0) diff --git a/tests/components/bme68x_bsec2/__sdk/bsec2.h b/tests/components/bme68x_bsec2/__sdk/bsec2.h index 51e54726cf7..5338b4f2669 100644 --- a/tests/components/bme68x_bsec2/__sdk/bsec2.h +++ b/tests/components/bme68x_bsec2/__sdk/bsec2.h @@ -4,6 +4,9 @@ // Every call succeeds; the tests drive the publish path directly. #include +// Shared with the BSEC 1.x double in bme680_bsec/__sdk; see that file. +#include + static constexpr int8_t BME68X_OK = 0; static constexpr int8_t BME68X_W_NO_NEW_DATA = 2; static constexpr uint8_t BME68X_ENABLE = 1; @@ -13,81 +16,9 @@ static constexpr uint8_t BME68X_PARALLEL_MODE = 2; static constexpr uint8_t BME68X_GASM_VALID_MSK = 0x20; static constexpr uint32_t BSEC_INSTANCE_SIZE = 8; -static constexpr uint32_t BSEC_MAX_PROPERTY_BLOB_SIZE = 16; -static constexpr uint32_t BSEC_MAX_STATE_BLOB_SIZE = 16; -static constexpr uint32_t BSEC_MAX_WORKBUFFER_SIZE = 16; -static constexpr uint8_t BSEC_NUMBER_OUTPUTS = 20; -static constexpr uint8_t BSEC_MAX_PHYSICAL_SENSOR = 8; static constexpr uint16_t BSEC_TOTAL_HEAT_DUR = 140; -#define BSEC_SAMPLE_RATE_LP 0.33333f -#define BSEC_SAMPLE_RATE_ULP 0.0033333f #define BSEC_CHECK_INPUT(x, shift) ((x) & (1 << ((shift) - 1))) -enum { - BSEC_INPUT_PRESSURE = 1, - BSEC_INPUT_HUMIDITY = 2, - BSEC_INPUT_TEMPERATURE = 3, - BSEC_INPUT_GASRESISTOR = 4, - BSEC_INPUT_HEATSOURCE = 14, - BSEC_INPUT_PROFILE_PART = 24, -}; - -enum { - BSEC_OUTPUT_IAQ = 1, - BSEC_OUTPUT_STATIC_IAQ = 2, - BSEC_OUTPUT_CO2_EQUIVALENT = 3, - BSEC_OUTPUT_BREATH_VOC_EQUIVALENT = 4, - BSEC_OUTPUT_RAW_PRESSURE = 7, - BSEC_OUTPUT_RAW_GAS = 9, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE = 14, - BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY = 15, -}; - -enum bsec_library_return_t { - BSEC_OK = 0, - BSEC_I_SU_SUBSCRIBEDOUTPUTGATES = 12, -}; - -struct bsec_version_t { - uint8_t major, minor, major_bugfix, minor_bugfix; -}; - -struct bsec_input_t { - int64_t time_stamp; - float signal; - uint8_t signal_dimensions; - uint8_t sensor_id; -}; - -struct bsec_output_t { - int64_t time_stamp; - float signal; - uint8_t signal_dimensions; - uint8_t sensor_id; - uint8_t accuracy; -}; - -struct bsec_sensor_configuration_t { - float sample_rate; - uint8_t sensor_id; -}; - -struct bsec_bme_settings_t { - int64_t next_call; - uint32_t process_data; - uint16_t heater_temperature; - uint16_t heater_duration; - uint16_t heater_temperature_profile[10]; - uint16_t heater_duration_profile[10]; - uint8_t heater_profile_len; - uint8_t run_gas; - uint8_t pressure_oversampling; - uint8_t temperature_oversampling; - uint8_t humidity_oversampling; - uint8_t trigger_measurement; - uint8_t op_mode; -}; - struct bme68x_dev { int dummy; }; From ca291a9724752d7cc477ccd9a325fd7a6b4970c9 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:26:42 +0100 Subject: [PATCH 159/170] [uart] Check devices on a forwarding UART against its source's settings (#20200) Co-authored-by: J. Nick Koston --- esphome/components/uart/__init__.py | 45 +++++++++- .../uart/test_inherit_settings.py | 90 +++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/uart/test_inherit_settings.py diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index b811131eaf3..0bdca43f580 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -441,6 +441,8 @@ KEY_UART_DEVICES = "uart_devices" @dataclass class UARTData: + # (UART, the UART whose settings it runs with), in the order they were declared. + settings_sources: list[tuple[ID, ID]] = field(default_factory=list) # UARTs whose received bytes do not keep the timing of a serial line. unclocked: list[ID] = field(default_factory=list) @@ -451,6 +453,25 @@ def _get_data() -> UARTData: return CORE.data[DOMAIN] +def inherit_settings(uart_id: ID, source_id: ID) -> None: + """Check the devices on a UART against the baud rate, data bits, parity and stop bits of another one. + + For a UART without settings of its own that passes on the bytes of source_id. Call it from + CONFIG_SCHEMA: the devices' final validation can run before that of the caller. + """ + _get_data().settings_sources.append((uart_id, source_id)) + + +def _settings_source(uart_id: ID) -> ID | None: + """Return the UART whose settings uart_id runs with, following every hop, or None.""" + sources = {str(uart): source for uart, source in _get_data().settings_sources} + found = None + # pop() ends the walk on a loop. + while (source := sources.pop(str(uart_id), None)) is not None: + found = uart_id = source + return found + + def mark_unclocked(uart_id: ID) -> ID: """Record that the bytes of uart_id do not arrive with the timing of a serial line, e.g. over TCP or USB. @@ -521,6 +542,7 @@ def final_validate_device_schema( def validate_hub(hub_config): hub_schema = {} + settings_schema = {} uart_id = hub_config[CONF_ID] uart_id_type_str = str(uart_id.type) devices = fv.full_config.get().data.setdefault(KEY_UART_DEVICES, {}) @@ -541,13 +563,28 @@ def final_validate_device_schema( ) ] = validate_pin(CONF_RX_PIN, device) if baud_rate is not None: - hub_schema[cv.Required(CONF_BAUD_RATE)] = validate_baud_rate + settings_schema[cv.Required(CONF_BAUD_RATE)] = validate_baud_rate if data_bits is not None: - hub_schema[cv.Required(CONF_DATA_BITS)] = validate_data_bits + settings_schema[cv.Required(CONF_DATA_BITS)] = validate_data_bits if parity is not None: - hub_schema[cv.Required(CONF_PARITY)] = validate_parity + settings_schema[cv.Required(CONF_PARITY)] = validate_parity if stop_bits is not None: - hub_schema[cv.Required(CONF_STOP_BITS)] = validate_stop_bits + settings_schema[cv.Required(CONF_STOP_BITS)] = validate_stop_bits + if (source_id := _settings_source(uart_id)) is None: + hub_schema.update(settings_schema) + elif settings_schema: + try: + fv.id_declaration_match_schema(settings_schema)(source_id) + except cv.MultipleInvalid as err: + # Each error keeps its path at the source UART; a missing key keeps ESPHome's wording. + raise cv.MultipleInvalid( + [ + e + if isinstance(e, cv.RequiredFieldInvalid) + else cv.Invalid(f"{e.msg} (via {uart_id})", e.path) + for e in err.errors + ] + ) from err return cv.Schema(hub_schema, extra=cv.ALLOW_EXTRA)(hub_config) return cv.Schema( diff --git a/tests/component_tests/uart/test_inherit_settings.py b/tests/component_tests/uart/test_inherit_settings.py new file mode 100644 index 00000000000..c2584094e5a --- /dev/null +++ b/tests/component_tests/uart/test_inherit_settings.py @@ -0,0 +1,90 @@ +"""Tests for checking devices on a UART that runs with the settings of another one.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components import uart +from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS +from esphome.config import Config +from esphome.const import CONF_BAUD_RATE, CONF_ID, CONF_UART_ID, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +def _full_config() -> Config: + """A hardware UART at 9600 baud and a UART without settings, as the ID pass leaves them.""" + full = Config() + full["uart"] = [ + { + CONF_ID: ID("pins"), + CONF_BAUD_RATE: 9600, + CONF_DATA_BITS: 8, + CONF_PARITY: "NONE", + CONF_STOP_BITS: 1, + } + ] + full["forwarder"] = [{CONF_ID: ID("copy")}] + full.declare_ids.append((full["uart"][0][CONF_ID], ["uart", 0, CONF_ID])) + full.declare_ids.append((full["forwarder"][0][CONF_ID], ["forwarder", 0, CONF_ID])) + return full + + +def _device(**settings) -> dict: + return uart.final_validate_device_schema("test_device", **settings)( + {CONF_UART_ID: ID("copy")} + ) + + +def test_settings_source_follows_every_hop() -> None: + uart.inherit_settings(ID("a"), ID("b")) + uart.inherit_settings(ID("b"), ID("pins")) + assert str(uart._settings_source(ID("a"))) == "pins" + assert str(uart._settings_source(ID("b"))) == "pins" + assert uart._settings_source(ID("pins")) is None + + +def test_settings_source_ends_on_a_loop() -> None: + uart.inherit_settings(ID("a"), ID("b")) + uart.inherit_settings(ID("b"), ID("a")) + # a -> b -> a: the walk stops where it started + assert str(uart._settings_source(ID("a"))) == "a" + + +def test_settings_source_ends_on_a_self_loop() -> None: + uart.inherit_settings(ID("a"), ID("a")) + # a -> a: the walk stops at once + assert str(uart._settings_source(ID("a"))) == "a" + + +def test_device_is_checked_against_the_source( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + uart.inherit_settings(ID("copy"), ID("pins")) + _device(baud_rate=9600, data_bits=8, parity="NONE", stop_bits=1) + + +def test_mismatch_is_reported_at_the_source( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + uart.inherit_settings(ID("copy"), ID("pins")) + with pytest.raises(cv.Invalid, match="requires baud rate 115200") as err: + _device(baud_rate=115200) + assert err.value.path[-3:] == ["uart", 0, CONF_BAUD_RATE] + assert "(via copy)" in str(err.value) + + +def test_device_without_settings_needs_no_source( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + _device() + + +def test_without_a_source_the_uart_needs_its_own_settings( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config()) + with pytest.raises(cv.Invalid, match="required key not provided"): + _device(baud_rate=9600) From 10653123456954c59d9429af19bb1028bf5be17b Mon Sep 17 00:00:00 2001 From: psewar Date: Wed, 4 Nov 2026 03:44:21 +0100 Subject: [PATCH 160/170] [voice_assistant] Do not treat "playback has not started" as "playback finished" (#19408) --- .../components/voice_assistant/__init__.py | 2 +- .../voice_assistant/voice_assistant.cpp | 43 ++++++++++++++++--- .../voice_assistant/voice_assistant.h | 4 +- .../voice_assistant/test_playback_timeout.py | 6 +-- .../test_tts_playback_timeout.py | 4 +- 5 files changed, 45 insertions(+), 14 deletions(-) diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 82874506d64..4ab389da784 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -49,7 +49,7 @@ CONF_WAKE_WORD = "wake_word" CONF_CONVERSATION_TIMEOUT = "conversation_timeout" CONF_TTS_PLAYBACK_START_TIMEOUT = "tts_playback_start_timeout" # Keep in sync with VoiceAssistant::tts_playback_start_timeout_ in voice_assistant.h. -TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=2000) +TTS_PLAYBACK_START_TIMEOUT_DEFAULT = core.TimePeriod(milliseconds=15000) CONF_ON_TIMER_STARTED = "on_timer_started" CONF_ON_TIMER_UPDATED = "on_timer_updated" diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index e79968e4c4b..7ea22d756d2 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -30,6 +30,10 @@ static const size_t RING_BUFFER_SIZE = RING_BUFFER_SAMPLES * sizeof(int16_t); static const size_t SEND_BUFFER_SAMPLES = 32 * SAMPLE_RATE_HZ / 1000; // 32ms * 16kHz / 1000ms static const size_t SEND_BUFFER_SIZE = SEND_BUFFER_SAMPLES * sizeof(int16_t); static const size_t RECEIVE_SIZE = 1024; +// Stall watchdog, re-armed from loop() while playback runs. Waiting for playback +// to start uses tts_playback_start_timeout_ instead (home-assistant-voice-pe#621). +static constexpr uint32_t PLAYBACK_STALL_TIMEOUT_MS = 2000; + static const size_t SPEAKER_BUFFER_SIZE = 16 * RECEIVE_SIZE; // If one microphone channel keeps producing audio while another configured channel produces none for this @@ -499,7 +503,8 @@ void VoiceAssistant::loop() { } #endif if (playing) { - this->start_playback_timeout_(); + // Playback is running: short watchdog against a stalled player. + this->start_playback_timeout_(PLAYBACK_STALL_TIMEOUT_MS); } break; } @@ -525,6 +530,23 @@ void VoiceAssistant::loop() { this->tts_stream_end_trigger_.trigger(); } +#endif +#ifdef USE_MEDIA_PLAYER + // Counterpart to the speaker checks above: while the media player is + // still announcing our response, the microphone must stay shut, otherwise + // the device records its own response and answers it. Only our own + // response counts; an unrelated announcement does not hold this state. + if ((this->media_player_ != nullptr) && + (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING)) { + break; + } + // The start timeout fired before playback began: skip the follow-up turn, so a late start + // cannot play into an open microphone. + if ((this->media_player_ != nullptr) && + (this->media_player_response_state_ == MediaPlayerResponseState::URL_SENT)) { + this->set_state_(State::IDLE, State::IDLE); + break; + } #endif if (this->continue_conversation_) { this->set_state_(State::START_MICROPHONE, State::START_PIPELINE); @@ -753,6 +775,14 @@ void VoiceAssistant::request_stop() { this->cancel_response_(); break; case State::RESPONSE_FINISHED: +#ifdef USE_MEDIA_PLAYER + // loop() holds this state while our response is still being announced; + // stop it the same way STREAMING_RESPONSE does. + if ((this->media_player_ != nullptr) && + (this->media_player_response_state_ == MediaPlayerResponseState::PLAYING)) { + this->cancel_response_(); + } +#endif break; // Let the incoming audio stream finish then it will go to idle. } } @@ -786,8 +816,8 @@ void VoiceAssistant::signal_stop_() { } } -void VoiceAssistant::start_playback_timeout_() { - this->set_timeout(PLAYBACK_TIMEOUT_ID, this->tts_playback_start_timeout_, [this]() { +void VoiceAssistant::start_playback_timeout_(uint32_t timeout_ms) { + this->set_timeout(PLAYBACK_TIMEOUT_ID, timeout_ms, [this]() { this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); @@ -866,7 +896,7 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { this->media_player_->make_call().set_media_url(this->tts_response_url_).set_announcement(true).perform(); this->started_streaming_tts_ = true; - this->start_playback_timeout_(); + this->start_playback_timeout_(this->tts_playback_start_timeout_); tts_url_for_trigger = this->tts_response_url_; this->tts_response_url_.clear(); // Reset streaming URL @@ -934,7 +964,7 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { this->media_player_->make_call().set_media_url(url).set_announcement(true).perform(); - this->start_playback_timeout_(); + this->start_playback_timeout_(this->tts_playback_start_timeout_); } this->started_streaming_tts_ = false; // Helps indicate reaching the TTS_END stage #endif @@ -1117,7 +1147,8 @@ void VoiceAssistant::on_announce(const api::VoiceAssistantAnnounceRequest &msg) .perform(); this->continue_conversation_ = msg.start_conversation; - this->start_playback_timeout_(); + // Waiting for playback to start here too, not for it to finish. + this->start_playback_timeout_(this->tts_playback_start_timeout_); if (this->continuous_) { this->set_state_(State::STOP_MICROPHONE, State::STREAMING_RESPONSE); diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index ac8926efcf3..75e382543b3 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -244,7 +244,7 @@ class VoiceAssistant final : public Component { void cancel_response_(); bool start_udp_socket_(); void signal_stop_(); - void start_playback_timeout_(); + void start_playback_timeout_(uint32_t timeout_ms); // Drains the exposed microphone audio and sends it to Home Assistant over the API in one loop() pass. void stream_api_audio_(); @@ -324,7 +324,7 @@ class VoiceAssistant final : public Component { float volume_multiplier_; uint32_t conversation_timeout_; // Keep in sync with TTS_PLAYBACK_START_TIMEOUT_DEFAULT in __init__.py. - uint32_t tts_playback_start_timeout_{2000}; + uint32_t tts_playback_start_timeout_{15000}; Configuration config_{}; diff --git a/tests/component_tests/voice_assistant/test_playback_timeout.py b/tests/component_tests/voice_assistant/test_playback_timeout.py index cae93e18bf8..7f4e87a1e43 100644 --- a/tests/component_tests/voice_assistant/test_playback_timeout.py +++ b/tests/component_tests/voice_assistant/test_playback_timeout.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -@pytest.mark.parametrize("timeout", [None, "2s", "2000ms", "1ms", "10s"]) +@pytest.mark.parametrize("timeout", [None, "15s", "15000ms", "1ms", "2s", "10s"]) def test_playback_timeout_codegen( timeout: str | None, tmp_path: Path, @@ -37,8 +37,8 @@ voice_assistant: + (f" tts_playback_start_timeout: {timeout}\n" if timeout else "") ) main = generate_main(config) - if timeout in (None, "2s", "2000ms"): + if timeout in (None, "15s", "15000ms"): assert "set_tts_playback_start_timeout(" not in main else: - milliseconds = 1 if timeout == "1ms" else 10000 + milliseconds = {"1ms": 1, "2s": 2000, "10s": 10000}[timeout] assert f"set_tts_playback_start_timeout({milliseconds});" in main diff --git a/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py index 96f44a09e32..9334dc4f580 100644 --- a/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py +++ b/tests/unit_tests/components/voice_assistant/test_tts_playback_timeout.py @@ -29,7 +29,7 @@ def test_playback_timeout_accepts_positive_milliseconds( assert config["tts_playback_start_timeout"].total_milliseconds == milliseconds -def test_playback_timeout_default_is_two_seconds() -> None: +def test_playback_timeout_default_is_fifteen_seconds() -> None: CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: "esp32"} config = voice_assistant.CONFIG_SCHEMA({}) - assert config["tts_playback_start_timeout"].total_milliseconds == 2000 + assert config["tts_playback_start_timeout"].total_milliseconds == 15000 From cfb98dc58072dad7581a72ee1064a6553c8f1489 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:20:07 +1300 Subject: [PATCH 161/170] Bump version to 2026.12.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index d9a33a23b70..e7cde4f2a3b 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.11.0-dev +PROJECT_NUMBER = 2026.12.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 4cdb9326651..5baf16f71d4 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.11.0-dev" +__version__ = "2026.12.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From ca7401750de2071fa2c2f498990af72e151fde2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:02:59 -0600 Subject: [PATCH 162/170] [ld6002b] Add unit tests for the wake pulse deferred write (#22241) --- .../components/ld6002b/ld6002b_wake_test.cpp | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/components/ld6002b/ld6002b_wake_test.cpp diff --git a/tests/components/ld6002b/ld6002b_wake_test.cpp b/tests/components/ld6002b/ld6002b_wake_test.cpp new file mode 100644 index 00000000000..0df940e9dea --- /dev/null +++ b/tests/components/ld6002b/ld6002b_wake_test.cpp @@ -0,0 +1,114 @@ +#ifdef USE_HOST +#include "common.h" + +namespace esphome::ld6002b::testing { + +namespace { + +static constexpr uint32_t PULSE_MS = 20; + +// A wake-up pin that only records its level. +class FakePin final : public GPIOPin { + public: + void setup() override {} + void pin_mode(gpio::Flags flags) override {} + gpio::Flags get_flags() const override { return gpio::FLAG_OUTPUT; } + bool digital_read() override { return this->level; } + void digital_write(bool value) override { this->level = value; } + bool level{false}; +}; + +} // namespace + +/// The hub with a wake-up pin, so every command waits for its wake pulse before it is written. +class LD6002BWakeTest : public LD6002BTest { + protected: + void SetUp() override { + this->sut_.set_wakeup_pin(&this->wake_pin_); + this->sut_.set_wakeup_pulse_ms(PULSE_MS); + this->configure_(); + LD6002BTest::SetUp(); + } + + virtual void configure_() {} + + /// Run the scheduler past the pending wake pulse. + void finish_pulse_() { + std::this_thread::sleep_for(std::chrono::milliseconds(PULSE_MS + 10)); + App.scheduler.call(millis()); + } + + /// ACK every boot command in turn, letting each wake pulse finish; returns the id the next command gets. + uint16_t drain_pulsed_commands_() { + uint16_t next_id = FIRST_COMMAND_ID; + for (int i = 0; i < 32; i++) { + this->finish_pulse_(); + if (this->uart_.tx.size() < ld600x::TF_FRAME_OVERHEAD) + break; + const uint16_t type = encode_uint16(this->uart_.tx[5], this->uart_.tx[6]); + this->uart_.tx.clear(); + this->feed_(ack(type)); + next_id++; + } + EXPECT_TRUE(this->uart_.tx.empty()); + return next_id; + } + + FakePin wake_pin_; +}; + +class LD6002BNoAutoWakeTest : public LD6002BWakeTest { + protected: + void configure_() override { this->sut_.set_auto_wake(false); } +}; + +TEST_F(LD6002BWakeTest, FirstCommandWaitsForWakePulse) { + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_FALSE(this->wake_pin_.level); + this->finish_pulse_(); + EXPECT_TRUE(this->wake_pin_.level); + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID, TYPE_CONTROL, u32_payload(CMD_TARGET_DISPLAY_ON))); +} + +TEST_F(LD6002BWakeTest, NextQueuedCommandGetsItsOwnPulse) { + this->finish_pulse_(); + this->uart_.tx.clear(); + this->feed_(ack(TYPE_CONTROL)); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_FALSE(this->wake_pin_.level); + this->finish_pulse_(); + EXPECT_TRUE(this->wake_pin_.level); + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID + 1, TYPE_CONTROL, u32_payload(CMD_POINT_CLOUD_ON))); +} + +// The deferred write carries the command's own type, length and payload +TEST_F(LD6002BWakeTest, DeferredWriteKeepsTypeAndPayload) { + this->feed_(tf_frame(TYPE_REPORT_Z_RANGE, {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40})); + const uint16_t id = this->drain_pulsed_commands_(); + this->z_max_.make_call().set_value(2.5f).perform(); + EXPECT_TRUE(this->uart_.tx.empty()); + this->finish_pulse_(); + // 0.0f and 2.5f (0x40200000) + EXPECT_EQ(this->uart_.tx, command(id, TYPE_SET_Z_RANGE, {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x40})); +} + +TEST_F(LD6002BWakeTest, UnansweredDeferredCommandIsResentAfterAnotherPulse) { + this->finish_pulse_(); + this->uart_.tx.clear(); + // The first attempt waits 600 ms, since a sleeping module only wakes on it + LoopBlockingGuard clock{nullptr, nullptr, App.get_loop_component_start_time() + 650}; + this->feed_({}); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_FALSE(this->wake_pin_.level); + this->finish_pulse_(); + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID + 1, TYPE_CONTROL, u32_payload(CMD_TARGET_DISPLAY_ON))); +} + +TEST_F(LD6002BNoAutoWakeTest, CommandIsWrittenWithoutPulse) { + EXPECT_TRUE(this->wake_pin_.level); + EXPECT_EQ(this->uart_.tx, command(FIRST_COMMAND_ID, TYPE_CONTROL, u32_payload(CMD_TARGET_DISPLAY_ON))); +} + +} // namespace esphome::ld6002b::testing + +#endif // USE_HOST From bea7994c9f3f52350d414d8bace1ed21716ee37a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:33:26 -0500 Subject: [PATCH 163/170] [bme280_base][bmp280_base] Compensate raw 0x80000 when the channel is enabled (#22236) --- .../components/bme280_base/bme280_base.cpp | 25 ++-- .../components/bmp280_base/bmp280_base.cpp | 14 +- tests/components/bme280_base/__init__.py | 6 + .../bme280_base/test_bme280_base.cpp | 138 ++++++++++++++++++ tests/components/bmp280_base/__init__.py | 6 + .../bmp280_base/test_bmp280_base.cpp | 116 +++++++++++++++ 6 files changed, 285 insertions(+), 20 deletions(-) create mode 100644 tests/components/bme280_base/__init__.py create mode 100644 tests/components/bme280_base/test_bme280_base.cpp create mode 100644 tests/components/bmp280_base/__init__.py create mode 100644 tests/components/bmp280_base/test_bmp280_base.cpp diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index ce1e16b185d..7a450070e56 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -171,6 +171,10 @@ inline uint8_t oversampling_to_time(BME280Oversampling over_sampling) { return ( void BME280Component::update() { // Enable sensor ESP_LOGV(TAG, "Sending conversion request"); + if (!this->write_byte(BME280_REGISTER_CONTROLHUMID, this->humidity_oversampling_ & 0b111)) { + this->status_set_warning(); + return; + } uint8_t meas_value = 0; meas_value |= (this->temperature_oversampling_ & 0b111) << 5; meas_value |= (this->pressure_oversampling_ & 0b111) << 2; @@ -210,12 +214,11 @@ void BME280Component::update() { }); } float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { - int32_t adc = encode_uint24(data[3], data[4], data[5]); - adc >>= 4; - if (adc == 0x80000) { - // temperature was disabled + if (this->temperature_oversampling_ == BME280_OVERSAMPLING_NONE) { return NAN; } + int32_t adc = encode_uint24(data[3], data[4], data[5]); + adc >>= 4; const int32_t t1 = this->calibration_.t1; const int32_t t2 = this->calibration_.t2; @@ -230,12 +233,11 @@ float BME280Component::read_temperature_(const uint8_t *data, int32_t *t_fine) { } float BME280Component::read_pressure_(const uint8_t *data, int32_t t_fine) { - int32_t adc = encode_uint24(data[0], data[1], data[2]); - adc >>= 4; - if (adc == 0x80000) { - // pressure was disabled + if (this->pressure_oversampling_ == BME280_OVERSAMPLING_NONE) { return NAN; } + int32_t adc = encode_uint24(data[0], data[1], data[2]); + adc >>= 4; const int64_t p1 = this->calibration_.p1; const int64_t p2 = this->calibration_.p2; const int64_t p3 = this->calibration_.p3; @@ -267,11 +269,10 @@ float BME280Component::read_pressure_(const uint8_t *data, int32_t t_fine) { } float BME280Component::read_humidity_(const uint8_t *data, int32_t t_fine) { - uint16_t const raw_adc = encode_uint16(data[6], data[7]); - if (raw_adc == 0x8000) + if (this->humidity_oversampling_ == BME280_OVERSAMPLING_NONE) { return NAN; - - int32_t const adc = raw_adc; + } + int32_t const adc = encode_uint16(data[6], data[7]); const int32_t h1 = this->calibration_.h1; const int32_t h2 = this->calibration_.h2; diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index eb2227ef18b..d8170c638bb 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -148,16 +148,15 @@ void BMP280Component::update() { } float BMP280Component::read_temperature_(int32_t *t_fine) { + if (this->temperature_oversampling_ == BMP280_OVERSAMPLING_NONE) { + return NAN; + } uint8_t data[3]{}; if (!this->bmp_read_bytes(BMP280_REGISTER_TEMPDATA, data, 3)) return NAN; ESP_LOGV(TAG, "Read temperature data, raw: %02X %02X %02X", data[0], data[1], data[2]); int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; - if (adc == 0x80000) { - // temperature was disabled - return NAN; - } const int32_t t1 = this->calibration_.t1; const int32_t t2 = this->calibration_.t2; @@ -172,15 +171,14 @@ float BMP280Component::read_temperature_(int32_t *t_fine) { } float BMP280Component::read_pressure_(int32_t t_fine) { + if (this->pressure_oversampling_ == BMP280_OVERSAMPLING_NONE) { + return NAN; + } uint8_t data[3]; if (!this->bmp_read_bytes(BMP280_REGISTER_PRESSUREDATA, data, 3)) return NAN; int32_t adc = encode_uint24(data[0], data[1], data[2]); adc >>= 4; - if (adc == 0x80000) { - // pressure was disabled - return NAN; - } const int64_t p1 = this->calibration_.p1; const int64_t p2 = this->calibration_.p2; const int64_t p3 = this->calibration_.p3; diff --git a/tests/components/bme280_base/__init__.py b/tests/components/bme280_base/__init__.py new file mode 100644 index 00000000000..c860d61c4c9 --- /dev/null +++ b/tests/components/bme280_base/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The base driver publishes to sensor entities, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["sensor"] diff --git a/tests/components/bme280_base/test_bme280_base.cpp b/tests/components/bme280_base/test_bme280_base.cpp new file mode 100644 index 00000000000..439cbfa1bec --- /dev/null +++ b/tests/components/bme280_base/test_bme280_base.cpp @@ -0,0 +1,138 @@ +#include + +#include +#include + +#include "esphome/components/bme280_base/bme280_base.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::bme280_base::testing { + +static constexpr uint8_t REGISTER_CONTROLHUMID = 0xF2; +static constexpr uint8_t REGISTER_MEASUREMENTS = 0xF7; + +// The datasheet's worked example: adc_T=519888 gives 25.08 C, adc_P=415148 gives 1006.53 hPa. +static constexpr BME280CalibrationData DATASHEET_CALIBRATION{ + 27504, 26435, -1000, 36477, -10685, 3024, 2855, 140, -7, 15500, -14600, 6000, 75, 355, 0, 333, 0, 30, +}; + +// A chip whose measurement registers hold fixed raw values; the bus never fails. +class FakeBME280 final : public BME280Component { + public: + FakeBME280() { this->calibration_ = DATASHEET_CALIBRATION; } + + void set_raw(uint32_t pressure, uint32_t temperature, uint16_t humidity) { + this->registers[0] = pressure >> 12; + this->registers[1] = pressure >> 4; + this->registers[2] = pressure << 4; + this->registers[3] = temperature >> 12; + this->registers[4] = temperature >> 4; + this->registers[5] = temperature << 4; + this->registers[6] = humidity >> 8; + this->registers[7] = humidity; + } + + std::array registers{}; + int ctrl_hum{-1}; + + protected: + bool read_byte(uint8_t a_register, uint8_t *data) override { return false; } + bool write_byte(uint8_t a_register, uint8_t data) override { + if (a_register == REGISTER_CONTROLHUMID) + this->ctrl_hum = data; + return true; + } + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + EXPECT_EQ(a_register, REGISTER_MEASUREMENTS); + EXPECT_EQ(len, this->registers.size()); + for (size_t i = 0; i < len; i++) + data[i] = this->registers[i]; + return true; + } + bool read_byte_16_le(uint8_t a_register, uint16_t *data) override { return false; } +}; + +// update() arms a conversion timeout of up to 114 ms at 16x; run the scheduler past it. +static void run_update(FakeBME280 &sensor) { + sensor.update(); + delay(150); // NOLINT + App.scheduler.call(millis()); +} + +TEST(BME280Base, DatasheetExampleCompensates) { + FakeBME280 sensor; + sensor::Sensor temperature, pressure, humidity; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_humidity_sensor(&humidity); + sensor.set_raw(415148, 519888, 0x8000); + + run_update(sensor); + + EXPECT_NEAR(temperature.state, 25.08f, 0.01f); + EXPECT_NEAR(pressure.state, 1006.53f, 0.01f); + EXPECT_TRUE(std::isfinite(humidity.state)); +} + +TEST(BME280Base, MidpointRawCodesPublishWhenSamplingIsEnabled) { + FakeBME280 sensor; + sensor::Sensor temperature, pressure, humidity; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_humidity_sensor(&humidity); + sensor.set_temperature_oversampling(BME280_OVERSAMPLING_1X); + sensor.set_pressure_oversampling(BME280_OVERSAMPLING_1X); + sensor.set_humidity_oversampling(BME280_OVERSAMPLING_1X); + // 0x80000 and 0x8000 are what a skipped channel reports, but an enabled one can read them too. + sensor.set_raw(0x80000, 0x80000, 0x8000); + + run_update(sensor); + + ASSERT_TRUE(temperature.has_state()); + EXPECT_NEAR(temperature.state, 26.47f, 0.01f); + ASSERT_TRUE(pressure.has_state()); + EXPECT_TRUE(std::isfinite(pressure.state)); + ASSERT_TRUE(humidity.has_state()); + EXPECT_TRUE(std::isfinite(humidity.state)); + EXPECT_FALSE(sensor.status_has_warning()); + EXPECT_EQ(sensor.ctrl_hum, BME280_OVERSAMPLING_1X); +} + +TEST(BME280Base, DisabledChannelsPublishNan) { + FakeBME280 sensor; + sensor::Sensor temperature, pressure, humidity; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_humidity_sensor(&humidity); + sensor.set_pressure_oversampling(BME280_OVERSAMPLING_NONE); + sensor.set_humidity_oversampling(BME280_OVERSAMPLING_NONE); + sensor.set_raw(0x80000, 519888, 0x8000); + + run_update(sensor); + + EXPECT_NEAR(temperature.state, 25.08f, 0.01f); + ASSERT_TRUE(pressure.has_state()); + EXPECT_TRUE(std::isnan(pressure.state)); + ASSERT_TRUE(humidity.has_state()); + EXPECT_TRUE(std::isnan(humidity.state)); + // The skipped humidity channel is written to the chip each cycle, not only in setup(). + EXPECT_EQ(sensor.ctrl_hum, BME280_OVERSAMPLING_NONE); +} + +TEST(BME280Base, DisabledTemperaturePublishesNothing) { + FakeBME280 sensor; + sensor::Sensor temperature, pressure; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_temperature_oversampling(BME280_OVERSAMPLING_NONE); + sensor.set_raw(415148, 0x80000, 0x8000); + + run_update(sensor); + + EXPECT_FALSE(temperature.has_state()); + EXPECT_FALSE(pressure.has_state()); + EXPECT_TRUE(sensor.status_has_warning()); +} + +} // namespace esphome::bme280_base::testing diff --git a/tests/components/bmp280_base/__init__.py b/tests/components/bmp280_base/__init__.py new file mode 100644 index 00000000000..c860d61c4c9 --- /dev/null +++ b/tests/components/bmp280_base/__init__.py @@ -0,0 +1,6 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The base driver publishes to sensor entities, so pull in what it builds against. + manifest.dependencies = manifest.dependencies + ["sensor"] diff --git a/tests/components/bmp280_base/test_bmp280_base.cpp b/tests/components/bmp280_base/test_bmp280_base.cpp new file mode 100644 index 00000000000..553a4f8a381 --- /dev/null +++ b/tests/components/bmp280_base/test_bmp280_base.cpp @@ -0,0 +1,116 @@ +#include + +#include +#include + +#include "esphome/components/bmp280_base/bmp280_base.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::bmp280_base::testing { + +static constexpr uint8_t REGISTER_PRESSUREDATA = 0xF7; +static constexpr uint8_t REGISTER_TEMPDATA = 0xFA; + +// The datasheet's worked example: adc_T=519888 gives 25.08 C, adc_P=415148 gives 1006.53 hPa. +static constexpr BMP280CalibrationData DATASHEET_CALIBRATION{ + 27504, 26435, -1000, 36477, -10685, 3024, 2855, 140, -7, 15500, -14600, 6000, +}; + +// A chip whose measurement registers hold fixed raw values; the bus never fails. +class FakeBMP280 final : public BMP280Component { + public: + FakeBMP280() { this->calibration_ = DATASHEET_CALIBRATION; } + + void set_raw(uint32_t pressure, uint32_t temperature) { + this->pressure_ = {uint8_t(pressure >> 12), uint8_t(pressure >> 4), uint8_t(pressure << 4)}; + this->temperature_ = {uint8_t(temperature >> 12), uint8_t(temperature >> 4), uint8_t(temperature << 4)}; + } + + protected: + bool bmp_read_byte(uint8_t a_register, uint8_t *data) override { return false; } + bool bmp_write_byte(uint8_t a_register, uint8_t data) override { return true; } + bool bmp_read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + EXPECT_EQ(len, 3u); + const auto &source = a_register == REGISTER_TEMPDATA ? this->temperature_ : this->pressure_; + EXPECT_TRUE(a_register == REGISTER_TEMPDATA || a_register == REGISTER_PRESSUREDATA); + for (size_t i = 0; i < len; i++) + data[i] = source[i]; + return true; + } + bool bmp_read_byte_16_le(uint8_t a_register, uint16_t *data) override { return false; } + + std::array pressure_{}; + std::array temperature_{}; +}; + +// update() arms a conversion timeout of up to 76 ms at 16x; run the scheduler past it. +static void run_update(FakeBMP280 &sensor) { + sensor.update(); + delay(150); // NOLINT + App.scheduler.call(millis()); +} + +TEST(BMP280Base, DatasheetExampleCompensates) { + FakeBMP280 sensor; + sensor::Sensor temperature, pressure; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_raw(415148, 519888); + + run_update(sensor); + + EXPECT_NEAR(temperature.state, 25.08f, 0.01f); + EXPECT_NEAR(pressure.state, 1006.53f, 0.01f); +} + +TEST(BMP280Base, MidpointRawCodesPublishWhenSamplingIsEnabled) { + FakeBMP280 sensor; + sensor::Sensor temperature, pressure; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_temperature_oversampling(BMP280_OVERSAMPLING_1X); + sensor.set_pressure_oversampling(BMP280_OVERSAMPLING_1X); + // 0x80000 is what a skipped channel reports, but an enabled one can read it too. + sensor.set_raw(0x80000, 0x80000); + + run_update(sensor); + + ASSERT_TRUE(temperature.has_state()); + EXPECT_NEAR(temperature.state, 26.47f, 0.01f); + ASSERT_TRUE(pressure.has_state()); + EXPECT_TRUE(std::isfinite(pressure.state)); + EXPECT_FALSE(sensor.status_has_warning()); +} + +TEST(BMP280Base, DisabledPressurePublishesNan) { + FakeBMP280 sensor; + sensor::Sensor temperature, pressure; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_pressure_oversampling(BMP280_OVERSAMPLING_NONE); + sensor.set_raw(0x80000, 519888); + + run_update(sensor); + + EXPECT_NEAR(temperature.state, 25.08f, 0.01f); + ASSERT_TRUE(pressure.has_state()); + EXPECT_TRUE(std::isnan(pressure.state)); +} + +TEST(BMP280Base, DisabledTemperaturePublishesNothing) { + FakeBMP280 sensor; + sensor::Sensor temperature, pressure; + sensor.set_temperature_sensor(&temperature); + sensor.set_pressure_sensor(&pressure); + sensor.set_temperature_oversampling(BMP280_OVERSAMPLING_NONE); + sensor.set_raw(415148, 0x80000); + + run_update(sensor); + + EXPECT_FALSE(temperature.has_state()); + EXPECT_FALSE(pressure.has_state()); + EXPECT_TRUE(sensor.status_has_warning()); +} + +} // namespace esphome::bmp280_base::testing From ffab9a81e7cda20ae0532f1e12563af73a165969 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:36:45 -0600 Subject: [PATCH 164/170] [ld6002b] Keep the wake pulse frame in members so its timeout does not allocate (#22242) --- esphome/components/ld6002b/ld6002b.cpp | 9 ++++++--- esphome/components/ld6002b/ld6002b.h | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 4a04690857a..91952ec638c 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -144,20 +144,23 @@ bool LD6002BComponent::defer_frame_write(uint16_t type, const uint8_t *data, uin if (len > 0 && data != nullptr) { std::memcpy(this->wake_scratch_.data(), data, len); } + this->wake_type_ = type; + this->wake_len_ = len; + this->wake_track_ = track; // A button pulse must not raise the pin in the middle of this one. this->cancel_timeout(WAKE_BUTTON_TIMEOUT_ID); this->wake_pulse_pending_ = true; this->wakeup_pin_->digital_write(false); const uint8_t generation = this->send_generation_; - this->set_timeout(this->wakeup_pulse_ms_, [this, type, len, track, generation]() { + this->set_timeout(this->wakeup_pulse_ms_, [this, generation]() { this->wakeup_pin_->digital_write(true); this->wake_pulse_pending_ = false; // Anonymous timeouts are never cancelled, so a tracked pulse whose command has since been // retired must not transmit: the frame would land after its successor and be booked to it. - if (track && generation != this->send_generation_) { + if (this->wake_track_ && generation != this->send_generation_) { return; } - this->write_frame_(type, (len > 0) ? this->wake_scratch_.data() : nullptr, len, track); + this->write_frame_(this->wake_type_, this->wake_scratch_.data(), this->wake_len_, this->wake_track_); }); return true; } diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index 8c0a6ef8d8a..75518681b0c 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -48,7 +48,11 @@ class LD6002BComponent final : public ld600x::LD600XComponent { GPIOPin *wakeup_pin_{nullptr}; uint32_t wakeup_pulse_ms_{50}; // Frame a pending wake pulse will write, snapshotted because active_command_ may move on first. + // Kept here rather than in the timeout's capture, which then fits std::function's inline buffer. std::array wake_scratch_{}; + uint16_t wake_type_{0}; + uint8_t wake_len_{0}; + bool wake_track_{false}; bool auto_wake_{true}; bool wake_pulse_pending_{false}; bool work_mode_reported_{false}; From be25249209b9f5c2598acfa3d31bfeb34d66ebcf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:37:53 -0600 Subject: [PATCH 165/170] [ai] Keep scheduler lambda captures within the std::function small buffer (#22240) --- .agents/skills/code-review/SKILL.md | 6 ++++++ AGENTS.md | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 7a80215b18a..bdebf210aab 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -41,6 +41,12 @@ performance nit, because it fragments a small shared heap. Flag: literal already lives in flash). - `std::string` / `std::to_string` / string-returning helpers on hot paths where a buffer or view API exists. +- A `set_timeout` / `set_interval` / `defer` lambda whose captures exceed 8 bytes + on 32-bit or are not trivially copyable (`this` plus two 4-byte values, `this` + plus a member function pointer, a `std::string`, a `std::function`): + `std::function` then heap-allocates each time it is scheduled. Flag it on + retries, re-armed timeouts and hot-path `defer`; a `set_interval` set up once + in `setup()` is a single allocation. `[this]` plus one 4-byte value fits. ## C++ conventions diff --git a/AGENTS.md b/AGENTS.md index 5082a6fe017..d4393f480a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,6 +205,18 @@ file does, and it is the authority when they disagree. The most useful starting static constexpr uint32_t READ_TIMEOUT_ID = 0; this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); }); ``` + * **Scheduler lambdas capture at most 8 bytes:** `set_timeout`, `set_interval` and `defer` store the lambda + in a `std::function`, which keeps it inline only when it is at most 8 bytes on 32-bit targets and trivially + copyable; anything else is a heap allocation each time it is scheduled, which matters for retries, re-armed + timeouts and `defer` on a hot path. `[this]` plus one 4-byte value fits. `[this]` plus two 4-byte values, + `[this]` plus a member function pointer (8 bytes), a `std::string` or a `std::function` do not: keep that + state in members and capture `[this]`, or capture a small index instead. + ```cpp + // Bad - 12 bytes on 32-bit, allocates on every retry + this->set_timeout(RETRY_ID, 10, [this, configure = config.configure]() { (this->*configure)(); }); + // Good - 8 bytes, the pointer is looked up when the timeout runs + this->set_timeout(RETRY_ID, 10, [this, feature]() { this->run_configure_(feature); }); + ``` * **Don't override a default with the same value:** if a base class method already returns what you want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a component that wants `DATA` should simply leave it alone. From 5c6a7a89c8fd33e696c86a4d305ab8a3dd5e61e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:39:38 -0600 Subject: [PATCH 166/170] [rx8111] Keep boot retry callback in std::function inline buffer (#22239) --- esphome/components/rx8111/rx8111.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp index 3eb3e600025..e66282d1e83 100644 --- a/esphome/components/rx8111/rx8111.cpp +++ b/esphome/components/rx8111/rx8111.cpp @@ -261,8 +261,12 @@ void RX8111Component::boot_config_result_(BootFeature feature, bool success) { } this->set_warning_(config.warning, BootFailureNames::get_log_str(feature, BootFailureNames::LAST_INDEX)); if (attempts < RX8111_BOOT_MAX_ATTEMPTS) { - this->set_timeout(config.retry_id, this->boot_retry_delay_ms_, - [this, configure = config.configure]() { (this->*configure)(); }); + // Capture the feature, not the 8-byte member pointer, so the callback fits std::function's inline buffer + this->set_timeout(config.retry_id, this->boot_retry_delay_ms_, [this, feature]() { + BootConfig retry; + progmem_memcpy(&retry, &CONFIGS[feature], sizeof(retry)); + (this->*retry.configure)(); + }); } } From ab4e3b42f55d2f5233d5bc274bd7fc6058f2a44a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:41:40 -0600 Subject: [PATCH 167/170] [opt3001] Avoid heap allocation in the read path (#22238) --- esphome/components/opt3001/opt3001.cpp | 117 +++++++++++-------------- esphome/components/opt3001/opt3001.h | 8 +- 2 files changed, 57 insertions(+), 68 deletions(-) diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index dd655743913..6c4f014f986 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -1,6 +1,8 @@ #include "opt3001.h" #include "esphome/core/log.h" +#include + namespace esphome::opt3001 { ESPHOME_LOG_TAG(TAG, "opt3001.sensor"); @@ -8,20 +10,23 @@ ESPHOME_LOG_TAG(TAG, "opt3001.sensor"); static constexpr uint32_t RESULT_RETRY_TIMEOUT_ID = 0; static constexpr uint32_t READ_TIMEOUT_ID = 1; -static const uint8_t OPT3001_REG_RESULT = 0x00; -static const uint8_t OPT3001_REG_CONFIGURATION = 0x01; +static constexpr uint8_t OPT3001_REG_RESULT = 0x00; +static constexpr uint8_t OPT3001_REG_CONFIGURATION = 0x01; // See datasheet for full description of each bit. -static const uint16_t OPT3001_CONFIGURATION_RANGE_FULL = 0b1100000000000000; -static const uint16_t OPT3001_CONFIGURATION_CONVERSION_TIME_800 = 0b100000000000; -static const uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_MASK = 0b11000000000; -static const uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_SINGLE_SHOT = 0b01000000000; -static const uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN = 0b00000000000; +static constexpr uint16_t OPT3001_CONFIGURATION_RANGE_FULL = 0b1100000000000000; +static constexpr uint16_t OPT3001_CONFIGURATION_CONVERSION_TIME_800 = 0b100000000000; +static constexpr uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_MASK = 0b11000000000; +static constexpr uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_SINGLE_SHOT = 0b01000000000; +static constexpr uint16_t OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN = 0b00000000000; // tl;dr: Configure an automatic-ranged, 800ms single shot reading, // with INT processing disabled -static const uint16_t OPT3001_CONFIGURATION_FULL_RANGE_ONE_SHOT = OPT3001_CONFIGURATION_RANGE_FULL | - OPT3001_CONFIGURATION_CONVERSION_TIME_800 | - OPT3001_CONFIGURATION_CONVERSION_MODE_SINGLE_SHOT; -static const uint16_t OPT3001_CONVERSION_TIME_800 = 825; // give it 25 extra ms; it seems to not be ready quite often +static constexpr uint16_t OPT3001_CONFIGURATION_FULL_RANGE_ONE_SHOT = OPT3001_CONFIGURATION_RANGE_FULL | + OPT3001_CONFIGURATION_CONVERSION_TIME_800 | + OPT3001_CONFIGURATION_CONVERSION_MODE_SINGLE_SHOT; +// give it 25 extra ms; it seems to not be ready quite often +static constexpr uint16_t OPT3001_CONVERSION_TIME_800 = 825; +// 10 ms apart: 1 s past the conversion wait covers an auto-range restart (10 ms plus another 800 ms conversion) +static constexpr uint8_t RESULT_MAX_RETRIES = 100; /* opt3001 properties: @@ -32,56 +37,35 @@ opt3001 properties: */ -void OPT3001Sensor::read_result_(const std::function &f) { +void OPT3001Sensor::read_result_(uint8_t retries_left) { // ensure the single shot flag is clear, indicating it's done uint16_t raw_value; - if (this->read(reinterpret_cast(&raw_value), 2) != i2c::ERROR_OK) { + if (!this->read_byte_16(OPT3001_REG_CONFIGURATION, &raw_value)) { ESP_LOGW(TAG, "Reading configuration register failed"); - f(NAN); + this->publish_lx_(NAN); return; } - raw_value = i2c::i2ctohs(raw_value); if ((raw_value & OPT3001_CONFIGURATION_CONVERSION_MODE_MASK) != OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN) { - // not ready; wait 10ms and try again - ESP_LOGW(TAG, "Data not ready; waiting 10ms"); - this->set_timeout(RESULT_RETRY_TIMEOUT_ID, 10, [this, f]() { read_result_(f); }); - return; - } - - if (this->read_register(OPT3001_REG_RESULT, reinterpret_cast(&raw_value), 2) != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Reading result register failed"); - f(NAN); - return; - } - raw_value = i2c::i2ctohs(raw_value); - - uint8_t exponent = raw_value >> 12; - uint16_t mantissa = raw_value & 0b111111111111; - - double lx = 0.01 * pow(2.0, double(exponent)) * double(mantissa); - f(float(lx)); -} - -void OPT3001Sensor::read_lx_(const std::function &f) { - // turn on (after one-shot sensor automatically powers down) - uint16_t start_measurement = i2c::htoi2cs(OPT3001_CONFIGURATION_FULL_RANGE_ONE_SHOT); - if (this->write_register(OPT3001_REG_CONFIGURATION, reinterpret_cast(&start_measurement), 2) != - i2c::ERROR_OK) { - ESP_LOGW(TAG, "Triggering one shot measurement failed"); - f(NAN); - return; - } - - this->set_timeout(READ_TIMEOUT_ID, OPT3001_CONVERSION_TIME_800, [this, f]() { - if (this->write(&OPT3001_REG_CONFIGURATION, 1) != i2c::ERROR_OK) { - ESP_LOGW(TAG, "Starting configuration register read failed"); - f(NAN); + if (retries_left == 0) { + ESP_LOGW(TAG, "Measurement did not complete"); + this->publish_lx_(NAN); return; } + ESP_LOGV(TAG, "Data not ready; waiting 10ms"); + this->set_timeout(RESULT_RETRY_TIMEOUT_ID, 10, [this, retries_left]() { this->read_result_(retries_left - 1); }); + return; + } - this->read_result_(f); - }); + if (!this->read_byte_16(OPT3001_REG_RESULT, &raw_value)) { + ESP_LOGW(TAG, "Reading result register failed"); + this->publish_lx_(NAN); + return; + } + + const uint8_t exponent = raw_value >> 12; + const uint32_t mantissa = raw_value & 0b111111111111; + this->publish_lx_(0.01f * static_cast(mantissa << exponent)); } void OPT3001Sensor::dump_config() { @@ -92,25 +76,30 @@ void OPT3001Sensor::dump_config() { } void OPT3001Sensor::update() { - // Set a flag and skip just in case the sensor isn't responding, - // and we just keep waiting for it in read_result_. - // This way we don't end up with potentially boundless "threads" - // using up memory and eventually crashing the device + // skip while the previous measurement is still in flight if (this->updating_) { return; } this->updating_ = true; - this->read_lx_([this](float val) { - this->updating_ = false; + // start a one-shot measurement (the sensor powers down after each one) + if (!this->write_byte_16(OPT3001_REG_CONFIGURATION, OPT3001_CONFIGURATION_FULL_RANGE_ONE_SHOT)) { + ESP_LOGW(TAG, "Triggering one shot measurement failed"); + this->publish_lx_(NAN); + return; + } + this->set_timeout(READ_TIMEOUT_ID, OPT3001_CONVERSION_TIME_800, [this]() { this->read_result_(RESULT_MAX_RETRIES); }); +} - if (!this->status_comm_result(!std::isnan(val))) { - this->publish_state(NAN); - return; - } - ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", LOG_STR_ARG(this->get_log_name()), val); - this->publish_state(val); - }); +void OPT3001Sensor::publish_lx_(float val) { + this->updating_ = false; + + if (!this->status_comm_result(!std::isnan(val))) { + this->publish_state(NAN); + return; + } + ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", LOG_STR_ARG(this->get_log_name()), val); + this->publish_state(val); } } // namespace esphome::opt3001 diff --git a/esphome/components/opt3001/opt3001.h b/esphome/components/opt3001/opt3001.h index 92f5136bf71..6b690306901 100644 --- a/esphome/components/opt3001/opt3001.h +++ b/esphome/components/opt3001/opt3001.h @@ -13,10 +13,10 @@ class OPT3001Sensor final : public sensor::Sensor, public PollingComponent, publ void update() override; protected: - // checks if one-shot is complete before reading the result and returning it - void read_result_(const std::function &f); - // begins a one-shot measurement - void read_lx_(const std::function &f); + // checks if one-shot is complete, then reads and publishes the result + void read_result_(uint8_t retries_left); + // ends the update: publishes the reading, or NAN on failure + void publish_lx_(float val); bool updating_{false}; }; From f960d99d66f49013eef29e5907dfa03b866b6ac2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:42:37 -0600 Subject: [PATCH 168/170] [sx1509] Add a 16-bit update_register helper for the register pairs (#22223) --- esphome/components/sx1509/sx1509.cpp | 85 ++++++++++------------------ esphome/components/sx1509/sx1509.h | 2 + 2 files changed, 32 insertions(+), 55 deletions(-) diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index f5b7c0b0bbe..2a74098e74d 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -88,77 +88,54 @@ bool SX1509Component::digital_read_cache(uint8_t pin) { } void SX1509Component::digital_write_hw(uint8_t pin, bool bit_value) { - if ((~this->ddr_mask_) & (1 << pin)) { - // If the pin is an output, write high/low - uint16_t temp_reg_data = 0; - this->read_byte_16(REG_DATA_B, &temp_reg_data); - assign_bits(this->output_state_, 1 << pin, bit_value); - // Outputs take the shadow state, inputs keep what was read - temp_reg_data = (temp_reg_data & this->ddr_mask_) | (this->output_state_ & ~this->ddr_mask_); - this->write_byte_16(REG_DATA_B, temp_reg_data); + const uint16_t bit = 1 << pin; + if ((~this->ddr_mask_) & bit) { + assign_bits(this->output_state_, bit, bit_value); + // Outputs take the shadow state, inputs keep what was read. Unlike update_register_16_(), a failed read + // still writes, since every output bit comes from the shadow. + uint16_t data = 0; + this->read_byte_16(REG_DATA_B, &data); + this->write_byte_16(REG_DATA_B, (data & this->ddr_mask_) | (this->output_state_ & ~this->ddr_mask_)); } } void SX1509Component::pin_mode(uint8_t pin, gpio::Flags flags) { ESP_LOGI(TAG, "Configuring pin %u with flags %x", pin, flags); - uint16_t temp_word = 0; + const uint16_t bit = 1 << pin; this->read_byte_16(REG_DIR_B, &this->ddr_mask_); if (flags & gpio::FLAG_OUTPUT) { // Always disable input buffer - this->read_byte_16(REG_INPUT_DISABLE_B, &temp_word); - temp_word |= (1 << pin); - this->write_byte_16(REG_INPUT_DISABLE_B, temp_word); + this->update_register_16_(REG_INPUT_DISABLE_B, bit, bit); if (flags & gpio::FLAG_OPEN_DRAIN) { // Pullup must be disabled for open drain mode - this->read_byte_16(REG_PULL_UP_B, &temp_word); - temp_word &= ~(1 << pin); - this->write_byte_16(REG_PULL_UP_B, temp_word); - this->read_byte_16(REG_OPEN_DRAIN_B, &temp_word); - temp_word |= (1 << pin); - this->write_byte_16(REG_OPEN_DRAIN_B, temp_word); + this->update_register_16_(REG_PULL_UP_B, bit, 0); + this->update_register_16_(REG_OPEN_DRAIN_B, bit, bit); ESP_LOGD(TAG, "Open drain output mode set for %u", pin); } else { ESP_LOGD(TAG, "Output Mode for %u", pin); } - - // Set direction to output - this->ddr_mask_ &= ~(1 << pin); - this->write_byte_16(REG_DIR_B, this->ddr_mask_); } else { ESP_LOGD(TAG, "Input Mode for %u", pin); // Always enable input buffer - this->read_byte_16(REG_INPUT_DISABLE_B, &temp_word); - temp_word &= ~(1 << pin); - this->write_byte_16(REG_INPUT_DISABLE_B, temp_word); - - // Pullup - this->read_byte_16(REG_PULL_UP_B, &temp_word); - assign_bits(temp_word, 1 << pin, flags & gpio::FLAG_PULLUP); - this->write_byte_16(REG_PULL_UP_B, temp_word); - - // Pulldown - this->read_byte_16(REG_PULL_DOWN_B, &temp_word); - assign_bits(temp_word, 1 << pin, flags & gpio::FLAG_PULLDOWN); - this->write_byte_16(REG_PULL_DOWN_B, temp_word); - - // Set direction to input - this->ddr_mask_ |= (1 << pin); - this->write_byte_16(REG_DIR_B, this->ddr_mask_); + this->update_register_16_(REG_INPUT_DISABLE_B, bit, 0); + this->update_register_16_(REG_PULL_UP_B, bit, (flags & gpio::FLAG_PULLUP) ? bit : 0); + this->update_register_16_(REG_PULL_DOWN_B, bit, (flags & gpio::FLAG_PULLDOWN) ? bit : 0); } + // A set direction bit makes the pin an input + assign_bits(this->ddr_mask_, bit, !(flags & gpio::FLAG_OUTPUT)); + this->write_byte_16(REG_DIR_B, this->ddr_mask_); } void SX1509Component::setup_led_driver(uint8_t pin) { - uint16_t temp_word = 0; + const uint16_t bit = 1 << pin; - this->read_byte_16(REG_INPUT_DISABLE_B, &temp_word); - temp_word |= (1 << pin); - this->write_byte_16(REG_INPUT_DISABLE_B, temp_word); + this->update_register_16_(REG_INPUT_DISABLE_B, bit, bit); - this->ddr_mask_ &= ~(1 << pin); // 0=output + this->ddr_mask_ &= ~bit; // 0=output this->write_byte_16(REG_DIR_B, this->ddr_mask_); // Internal 2MHz oscillator: set bit 6, clear bit 5 @@ -167,14 +144,15 @@ void SX1509Component::setup_led_driver(uint8_t pin) { // Linear mode for banks A (bit 3) and B (bit 7); LED driver clock ClkX of all IOs (bits 6:4) this->update_register(REG_MISC, (1 << 7) | (1 << 3) | 0x70, 0x70); - this->read_byte_16(REG_LED_DRIVER_ENABLE_B, &temp_word); - temp_word |= (1 << pin); - this->write_byte_16(REG_LED_DRIVER_ENABLE_B, temp_word); + this->update_register_16_(REG_LED_DRIVER_ENABLE_B, bit, bit); - this->read_byte_16(REG_DATA_B, &temp_word); - temp_word &= ~(1 << pin); - output_state_ &= ~(1 << pin); - this->write_byte_16(REG_DATA_B, temp_word); + this->output_state_ &= ~bit; + this->update_register_16_(REG_DATA_B, bit, 0); +} + +bool SX1509Component::update_register_16_(uint8_t a_register, uint16_t mask, uint16_t value) { + uint16_t data; + return this->read_byte_16(a_register, &data) && this->write_byte_16(a_register, (data & ~mask) | (value & mask)); } void SX1509Component::clock_(uint8_t osc_source, uint8_t osc_pin_function, uint8_t osc_freq_out, uint8_t osc_divider) { @@ -257,10 +235,7 @@ void SX1509Component::set_debounce_time_(uint8_t time) { } void SX1509Component::set_debounce_enable_(uint8_t pin) { - uint16_t debounce_enable = 0; - this->read_byte_16(REG_DEBOUNCE_ENABLE_B, &debounce_enable); - debounce_enable |= (1 << pin); - this->write_byte_16(REG_DEBOUNCE_ENABLE_B, debounce_enable); + this->update_register_16_(REG_DEBOUNCE_ENABLE_B, 1 << pin, 1 << pin); } void SX1509Component::set_debounce_pin_(uint8_t pin) { set_debounce_enable_(pin); } diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 8de5559f01e..965185c8a89 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -88,6 +88,8 @@ class SX1509Component final : public Component, uint32_t last_loop_timestamp_ = 0; const uint32_t min_loop_period_ = 15; // ms + /// 16-bit update_register() for the B/A register pairs + bool update_register_16_(uint8_t a_register, uint16_t mask, uint16_t value); void setup_keypad_(); void set_debounce_config_(uint8_t config_value); void set_debounce_time_(uint8_t time); From 5f7fd7692d502dc44289fdb5f0f63a316096a798 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:43:01 -0600 Subject: [PATCH 169/170] [xgzp68xx] Use update_register for the oversampling config (#22221) --- esphome/components/xgzp68xx/xgzp68xx.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/esphome/components/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index ebf383b4ed5..4bf2a3ad9ef 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -16,6 +16,7 @@ static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; static const uint8_t CMD_ADDRESS = 0x30; static const uint8_t SYSCONFIG_ADDRESS = 0xA5; static const uint8_t PCONFIG_ADDRESS = 0xA6; +static constexpr uint8_t PCONFIG_OVERSAMPLING_MASK = 0x07; static const uint8_t READ_COMMAND = 0x0A; // Indexed by the XGZP68XXOversampling register value, which is not in size order @@ -27,14 +28,10 @@ PROGMEM_STRING_TABLE(OversamplingStrings, "1024x", "2048x", "4096x", "8192x", "2 } void XGZP68XXComponent::update() { - // Do we need to change oversampling? - if (this->last_pressure_oversampling_ != this->pressure_oversampling_) { - uint8_t oldconfig = 0; - this->read_register(PCONFIG_ADDRESS, &oldconfig, 1); - uint8_t newconfig = (oldconfig & 0xf8) | (this->pressure_oversampling_ & 0x7); - this->write_register(PCONFIG_ADDRESS, &newconfig, 1); - ESP_LOGD(TAG, "oversampling to %s: oldconfig = 0x%x newconfig = 0x%x", - LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_)), oldconfig, newconfig); + // A failed write is retried on the next update + if (this->last_pressure_oversampling_ != this->pressure_oversampling_ && + this->update_register(PCONFIG_ADDRESS, PCONFIG_OVERSAMPLING_MASK, this->pressure_oversampling_)) { + ESP_LOGD(TAG, "Oversampling set to %s", LOG_STR_ARG(oversampling_to_str(this->pressure_oversampling_))); this->last_pressure_oversampling_ = this->pressure_oversampling_; } From 75d2cd10bfd462d52d43b04ced47d40364f3775e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Nov 2026 21:43:19 -0600 Subject: [PATCH 170/170] [qmp6988] Use update_register for the CTRLMEAS register (#22218) --- esphome/components/qmp6988/qmp6988.cpp | 33 +++++--------------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 0c6ed3a4268..6a5af000f97 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -198,24 +198,11 @@ void QMP6988Component::software_reset_() { } void QMP6988Component::set_power_mode_(uint8_t power_mode) { - uint8_t data; - ESP_LOGD(TAG, "Setting Power mode to: %d", power_mode); qmp6988_data_.power_mode = power_mode; - this->read_register(QMP6988_CTRLMEAS_REG, &data, 1); - data = data & 0xfc; - if (power_mode == QMP6988_SLEEP_MODE) { - data |= 0x00; - } else if (power_mode == QMP6988_FORCED_MODE) { - data |= 0x01; - } else if (power_mode == QMP6988_NORMAL_MODE) { - data |= 0x03; - } - this->write_byte(QMP6988_CTRLMEAS_REG, data); - - ESP_LOGD(TAG, "Set Power mode 0xf4=0x%x \r\n", data); - + // The power mode values are the register's mode bits + this->update_register(QMP6988_CTRLMEAS_REG, QMP6988_CTRLMEAS_REG_MODE_MSK, power_mode); delay(10); } @@ -228,22 +215,14 @@ void QMP6988Component::write_filter_(QMP6988IIRFilter filter) { } void QMP6988Component::write_oversampling_pressure_(QMP6988Oversampling oversampling_p) { - uint8_t data; - - this->read_register(QMP6988_CTRLMEAS_REG, &data, 1); - data &= 0xe3; - data |= (oversampling_p << 2); - this->write_byte(QMP6988_CTRLMEAS_REG, data); + this->update_register(QMP6988_CTRLMEAS_REG, QMP6988_CTRLMEAS_REG_OSRSP_MSK, + oversampling_p << QMP6988_CTRLMEAS_REG_OSRSP_POS); delay(10); } void QMP6988Component::write_oversampling_temperature_(QMP6988Oversampling oversampling_t) { - uint8_t data; - - this->read_register(QMP6988_CTRLMEAS_REG, &data, 1); - data &= 0x1f; - data |= (oversampling_t << 5); - this->write_byte(QMP6988_CTRLMEAS_REG, data); + this->update_register(QMP6988_CTRLMEAS_REG, QMP6988_CTRLMEAS_REG_OSRST_MSK, + oversampling_t << QMP6988_CTRLMEAS_REG_OSRST_POS); delay(10); }