From f9a81869efd3ec43329cf8cd56cf82e0cfc2dfbc Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:43:38 +1100 Subject: [PATCH 01/97] [it8951] Hold display enable pins off during deep-sleep (#20471) --- esphome/components/it8951/display.py | 4 ++-- esphome/components/it8951/it8951.cpp | 6 +++++ esphome/components/it8951/it8951.h | 1 + tests/component_tests/it8951/__init__.py | 0 tests/component_tests/it8951/test_init.py | 28 +++++++++++++++++++++++ 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/it8951/__init__.py create mode 100644 tests/component_tests/it8951/test_init.py diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index b5f3019a126..76fbadd1968 100644 --- a/esphome/components/it8951/display.py +++ b/esphome/components/it8951/display.py @@ -145,8 +145,8 @@ IT8951Model( reset_pin=12, cs_pin=10, # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply - # (GPIO11). Driven high during setup so no separate power_supply is needed. - enable_pin=[21, 11], + # (GPIO11). Driven high during setup so no separate power_supply is needed, and held low during deep_sleep. + enable_pin=[{"number": 21, "hold_state": True}, {"number": 11, "hold_state": True}], vcom=1400, # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET # selector; using the default 0x0001 leaves VCOM unchanged and breaks diff --git a/esphome/components/it8951/it8951.cpp b/esphome/components/it8951/it8951.cpp index 2cfcf111ea3..fe99017774d 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -351,6 +351,12 @@ void IT8951Display::on_safe_shutdown() { this->spi_cmd_(TCON_SLEEP); } +void IT8951Display::on_powerdown() { + for (auto *pin : this->enable_pins_) { + pin->digital_write(false); + } +} + // --- Init op enqueuers ------------------------------------------------------- void IT8951Display::enqueue_init_reset_() { diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index fcdc6d1d196..e1273587c45 100644 --- a/esphome/components/it8951/it8951.h +++ b/esphome/components/it8951/it8951.h @@ -150,6 +150,7 @@ class IT8951Display : public Display, void loop() override; void dump_config() override; void on_safe_shutdown() override; + void on_powerdown() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } // --- Config setters (called from generated code) --- diff --git a/tests/component_tests/it8951/__init__.py b/tests/component_tests/it8951/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/it8951/test_init.py b/tests/component_tests/it8951/test_init.py new file mode 100644 index 00000000000..46c203e5eb5 --- /dev/null +++ b/tests/component_tests/it8951/test_init.py @@ -0,0 +1,28 @@ +"""Tests for it8951 configuration validation.""" + +from collections.abc import Callable +from typing import Any + +from esphome.components.const import CONF_HOLD_STATE +from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32S3 +from esphome.components.it8951.display import CONFIG_SCHEMA +from esphome.const import CONF_ENABLE_PIN, CONF_NUMBER, PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def test_reterminal_e1003_enable_pins_hold_state( + set_core_config: SetCoreConfigCallable, + set_component_config: Callable[[str, Any], None], +) -> None: + """The reTerminal E1003 enable pins must hold their state during deep sleep.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + set_component_config("spi", {"id": "spi_bus", "clk_pin": 7, "mosi_pin": 9}) + + config = CONFIG_SCHEMA({"id": "test_display", "model": "seeed-reterminal-e1003"}) + + enable_pins = config[CONF_ENABLE_PIN] + assert [pin[CONF_NUMBER] for pin in enable_pins] == [21, 11] + assert all(pin[CONF_HOLD_STATE] for pin in enable_pins) From 721228c1d2f5604efc81ac4ffd53d236cdb1f6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:31:06 +0200 Subject: [PATCH 02/97] [teleinfo] Add predefined properties for main tags of TIC standard and historical (#9167) Co-authored-by: Claude Opus 4.6 Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 +- esphome/components/teleinfo/__init__.py | 2 +- .../components/teleinfo/sensor/__init__.py | 144 ++++++++++++++++- tests/component_tests/teleinfo/__init__.py | 0 tests/component_tests/teleinfo/test_sensor.py | 149 ++++++++++++++++++ tests/components/teleinfo/common.yaml | 86 +++++++++- 6 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 tests/component_tests/teleinfo/__init__.py create mode 100644 tests/component_tests/teleinfo/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a851e62a3e5..4264479349c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -570,7 +570,7 @@ esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet esphome/components/tcp_uart/* @Bascht74 esphome/components/tee501/* @Stock-M -esphome/components/teleinfo/* @0hax +esphome/components/teleinfo/* @0hax @FredM67 esphome/components/tem3200/* @bakerkj esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/template/datetime/* @rfdarter diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 8caf0202129..5a6a01f6695 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -4,7 +4,7 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -CODEOWNERS = ["@0hax"] +CODEOWNERS = ["@0hax", "@FredM67"] DOMAIN = "teleinfo" MULTI_CONF = True diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b51d4cb7953..6a23836c8ab 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,10 +1,32 @@ import esphome.codegen as cg from esphome.components import sensor +import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, ICON_FLASH, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_MILLIVOLT, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = sensor.sensor_schema( - TeleInfoSensor, - unit_of_measurement=UNIT_WATT_HOURS, - icon=ICON_FLASH, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, -).extend(TELEINFO_LISTENER_SCHEMA) + +def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]: + return { + CONF_UNIT_OF_MEASUREMENT: unit, + CONF_DEVICE_CLASS: device_class, + CONF_STATE_CLASS: state_class, + } + + +_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING) +_REACTIVE_ENERGY = _preset( + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT) +_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT) +_APPARENT_POWER_VA = _preset( + UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_APPARENT_POWER_KVA = _preset( + UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT) + +# Legacy defaults, kept for tags without a known preset +_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH} + +# Units that are valid for each device class, used to decide whether a preset's +# device class can be kept when the user overrides the unit (and vice versa) +_COMPATIBLE_UNITS = { + DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS}, + DEVICE_CLASS_REACTIVE_ENERGY: { + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + }, + DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP}, + DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT}, + DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS}, + DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT}, +} + +# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant. +TIC_TAG_CONFIGS = { + # Standard mode + "EA": _ENERGY, + "ER": _REACTIVE_ENERGY, + "IRMS": _CURRENT, + "URMS": _VOLTAGE, + "UMOY": _VOLTAGE, + "SINST": _APPARENT_POWER_VA, + "SMAX": _APPARENT_POWER_VA, + "CC": _POWER, + "PREF": _APPARENT_POWER_KVA, + "PCOUP": _APPARENT_POWER_KVA, + # Historical mode + "BASE": _ENERGY, + "HCH": _ENERGY, + "EJP": _ENERGY, + "BBRH": _ENERGY, + "IINST": _CURRENT, + "IMAX": _CURRENT, + "ISOUSC": _CURRENT, + "ADPS": _CURRENT, + "ADIR": _CURRENT, + "PAPP": _APPARENT_POWER_VA, + "PMAX": _POWER, +} + + +def apply_tag_config(config: ConfigType) -> ConfigType: + """Apply preset configurations based on the tag name. + + Only keys not set by the user are filled in. For known tags, a preset unit or + device class is dropped if it is incompatible with the one set by the user. + Unknown tags get the legacy defaults, merged key by key as before. + """ + if CONF_TAG_NAME not in config: + return config + + # Runs before cv.string, so the raw value may not be a string yet + tag_name = str(config[CONF_TAG_NAME]) + preset = next( + (p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)), + None, + ) + + config = dict(config) + if preset is None: + for key, value in _LEGACY_DEFAULT.items(): + config.setdefault(key, value) + return config + + unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT]) + device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS]) + compatible = unit in _COMPATIBLE_UNITS.get(device_class, ()) + + for key, value in preset.items(): + if key in config: + continue + if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible: + continue + config[key] = value + return config + + +CONFIG_SCHEMA = cv.All( + apply_tag_config, + sensor.sensor_schema( + TeleInfoSensor, + accuracy_decimals=0, + ).extend(TELEINFO_LISTENER_SCHEMA), +) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) + teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID]) cg.add(teleinfo.register_teleinfo_listener(var)) diff --git a/tests/component_tests/teleinfo/__init__.py b/tests/component_tests/teleinfo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/teleinfo/test_sensor.py b/tests/component_tests/teleinfo/test_sensor.py new file mode 100644 index 00000000000..a8f9420deac --- /dev/null +++ b/tests/component_tests/teleinfo/test_sensor.py @@ -0,0 +1,149 @@ +"""Tests for teleinfo sensor tag presets.""" + +import pytest + +from esphome.components.teleinfo import CONF_TAG_NAME +from esphome.components.teleinfo.sensor import apply_tag_config +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, + ICON_FLASH, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, + UNIT_WATT_HOURS, +) + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class", "state_class"), + [ + ("EAST", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ( + "ERQ1", + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, + ), + ("IRMS1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("URMS1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT), + ( + "SINSTS", + UNIT_VOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ( + "PREF", + UNIT_KILOVOLT_AMPS, + DEVICE_CLASS_APPARENT_POWER, + STATE_CLASS_MEASUREMENT, + ), + ("CCASN", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ("HCHC", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("BBRHCJB", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING), + ("IINST1", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("ADIR2", UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT), + ("PAPP", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT), + ("PMAX", UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT), + ], +) +def test_known_tag_preset( + tag: str, unit: str, device_class: str, state_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + assert config[CONF_STATE_CLASS] == state_class + assert CONF_ICON not in config + + +def test_unknown_tag_keeps_legacy_defaults() -> None: + config = apply_tag_config({CONF_TAG_NAME: "MYTAG"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_user_unit_keeps_legacy_device_class() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "MYTAG", CONF_UNIT_OF_MEASUREMENT: UNIT_KILOWATT_HOURS} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_KILOWATT_HOURS + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + assert config[CONF_STATE_CLASS] == STATE_CLASS_TOTAL_INCREASING + assert config[CONF_ICON] == ICON_FLASH + + +def test_unknown_tag_starting_with_u_is_not_voltage() -> None: + config = apply_tag_config({CONF_TAG_NAME: "UNKNOWN"}) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +def test_numeric_tag_name_does_not_crash() -> None: + config = apply_tag_config({CONF_TAG_NAME: 1234}) + assert config[CONF_TAG_NAME] == 1234 + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + + +@pytest.mark.parametrize( + ("tag", "unit", "device_class"), + [ + ("HCHP", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("EAST", UNIT_KILOWATT_HOURS, DEVICE_CLASS_ENERGY), + ("PREF", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER), + ("IINST1", UNIT_MILLIAMP, DEVICE_CLASS_CURRENT), + ], +) +def test_user_compatible_unit_keeps_preset_device_class( + tag: str, unit: str, device_class: str +) -> None: + config = apply_tag_config({CONF_TAG_NAME: tag, CONF_UNIT_OF_MEASUREMENT: unit}) + assert config[CONF_UNIT_OF_MEASUREMENT] == unit + assert config[CONF_DEVICE_CLASS] == device_class + + +def test_user_incompatible_unit_skips_preset_device_class() -> None: + config = apply_tag_config({CONF_TAG_NAME: "PAPP", CONF_UNIT_OF_MEASUREMENT: "W"}) + assert config[CONF_UNIT_OF_MEASUREMENT] == "W" + assert CONF_DEVICE_CLASS not in config + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + +def test_user_incompatible_device_class_skips_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_POWER} + ) + assert config[CONF_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert CONF_UNIT_OF_MEASUREMENT not in config + + +def test_user_same_device_class_keeps_preset_unit() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "PAPP", CONF_DEVICE_CLASS: DEVICE_CLASS_APPARENT_POWER} + ) + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_VOLT_AMPS + + +def test_user_state_class_is_kept() -> None: + config = apply_tag_config( + {CONF_TAG_NAME: "HCHP", CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT} + ) + assert config[CONF_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert config[CONF_UNIT_OF_MEASUREMENT] == UNIT_WATT_HOURS diff --git a/tests/components/teleinfo/common.yaml b/tests/components/teleinfo/common.yaml index dcb2cb14bdf..7f73bca62c2 100644 --- a/tests/components/teleinfo/common.yaml +++ b/tests/components/teleinfo/common.yaml @@ -22,11 +22,95 @@ teleinfo: update_interval: 60s sensor: + # Historical mode tags - auto-configured via apply_tag_config - platform: teleinfo name: hchc tag_name: HCHC teleinfo_id: test_teleinfo - unit_of_measurement: Wh + - platform: teleinfo + name: base + tag_name: BASE + teleinfo_id: test_teleinfo + - platform: teleinfo + name: iinst1 + tag_name: IINST1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: papp + tag_name: PAPP + teleinfo_id: test_teleinfo + - platform: teleinfo + name: bbrhcjb + tag_name: BBRHCJB + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ejphn + tag_name: EJPHN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pmax + tag_name: PMAX + teleinfo_id: test_teleinfo + - platform: teleinfo + name: imax1 + tag_name: IMAX1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: isousc + tag_name: ISOUSC + teleinfo_id: test_teleinfo + - platform: teleinfo + name: adps + tag_name: ADPS + teleinfo_id: test_teleinfo + # Standard mode tags - auto-configured via apply_tag_config + - platform: teleinfo + name: east + tag_name: EAST + teleinfo_id: test_teleinfo + - platform: teleinfo + name: erqt + tag_name: ERQT + teleinfo_id: test_teleinfo + - platform: teleinfo + name: irms1 + tag_name: IRMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: urms1 + tag_name: URMS1 + teleinfo_id: test_teleinfo + - platform: teleinfo + name: sinsts + tag_name: SINSTS + teleinfo_id: test_teleinfo + - platform: teleinfo + name: smaxsn + tag_name: SMAXSN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: ccasn + tag_name: CCASN + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pref + tag_name: PREF + teleinfo_id: test_teleinfo + - platform: teleinfo + name: pcoup + tag_name: PCOUP + teleinfo_id: test_teleinfo + # Explicit override - user-specified unit should not be overwritten + - platform: teleinfo + name: hchp_override + tag_name: HCHP + teleinfo_id: test_teleinfo + unit_of_measurement: kWh + # Unknown tag - no auto-config match, should still compile + - platform: teleinfo + name: custom_tag + tag_name: MYTAG + teleinfo_id: test_teleinfo text_sensor: - platform: teleinfo From f4fbc3215d226c4f9bb47ab3650e14935e258acc Mon Sep 17 00:00:00 2001 From: jabla Date: Sat, 10 Oct 2026 19:28:40 +0200 Subject: [PATCH 03/97] [espidf] Clean the build when the IDF target changes (#20473) --- esphome/espidf/toolchain.py | 50 ++++++ tests/unit_tests/test_espidf_toolchain.py | 199 ++++++++++++++++++++++ 2 files changed, 249 insertions(+) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index af3fda63495..871e74d36d3 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -1,5 +1,6 @@ """ESP-IDF direct build API for ESPHome.""" +import contextlib from dataclasses import dataclass, field import fnmatch import json @@ -124,6 +125,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]: env_cache[version] = os.environ.copy() # Do not leak PYTHONPATH into child env env_cache[version].pop("PYTHONPATH", None) + # Do not let an inherited IDF_TARGET override the variant + env_cache[version].pop("IDF_TARGET", None) # Use provided IDF framework if available if _esphome_manages_idf(): @@ -227,6 +230,8 @@ FILTER_IDF_LINES: list[str] = [ # click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE. _CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"}) _CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$") +_SDKCONFIG_TARGET_LINE = re.compile(r'^CONFIG_IDF_TARGET="([^"]*)"$', re.MULTILINE) +_TOOLCHAIN_FILE_TARGET = re.compile(r"toolchain-(?:clang-)?(\w+)\.cmake$") @dataclass(frozen=True, kw_only=True) @@ -310,6 +315,50 @@ def tree_skips_bootloader(build_dir: Path) -> bool: return cache.get(SKIP_BOOTLOADER_DEFINE) == "1" +def _foreign_idf_target(target: str) -> str | None: + """Return another IDF target recorded in the CMake caches or sdkconfig.""" + build_dir = _build_dir() + sdkconfig = CORE.relative_build_path(f"sdkconfig.{CORE.name}") + found = [] + for cache_path in ( + build_dir / "CMakeCache.txt", + build_dir / "bootloader" / "CMakeCache.txt", + ): + with contextlib.suppress(OSError, ValueError): + cache = _parse_cmakecache(cache_path) + found.append(cache.get("IDF_TARGET")) + if m := _TOOLCHAIN_FILE_TARGET.search( + cache.get("CMAKE_TOOLCHAIN_FILE", "") + ): + found.append(m.group(1)) + with contextlib.suppress(OSError, ValueError): + if m := _SDKCONFIG_TARGET_LINE.search(sdkconfig.read_text(encoding="utf-8")): + found.append(m.group(1)) + return next((t for t in found if t and t != target), None) + + +def _clean_foreign_target_tree() -> None: + """Clean a build configured for another IDF target, like idf.py set-target.""" + variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT) + if variant is None: + return + target = variant_to_idf_target(variant) + if (old := _foreign_idf_target(target)) is None: + return + from esphome.writer import clean_build + + _LOGGER.info( + "IDF target changed from %s to %s, cleaning build files...", old, target + ) + clean_build(clear_pio_cache=False) + if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): + return + # Restart sdkconfig from ESPHome's options + internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") + if internal.is_file(): + shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) + + def _skip_bootloader() -> bool: """Whether this tree should not build a bootloader at all; per-run memo.""" cache = _cache() @@ -804,6 +853,7 @@ def run_compile(config, verbose: bool) -> int: 3. Run full build """ jobs = _build_jobs(config) + _clean_foreign_target_tree() if need_reconfigure(): if (rc := _configure_project(verbose)) != 0: return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index 2a799a4d84a..c73858ed529 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -7,6 +7,7 @@ from contextlib import contextmanager import json import os from pathlib import Path +import shutil import subprocess from unittest.mock import MagicMock, call, patch @@ -262,6 +263,14 @@ def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None: assert "PYTHONPATH" not in env +def test_get_idf_env_pops_inherited_idf_target(setup_core: Path) -> None: + """An inherited IDF_TARGET does not reach cmake.""" + toolchain._cache().env.clear() + with patch.dict(os.environ, {"IDF_PATH": str(setup_core), "IDF_TARGET": "esp32"}): + env = toolchain._get_idf_env(version="5.5.4") + assert "IDF_TARGET" not in env + + def test_get_cmake_output_without_build_dir(setup_core: Path) -> None: """A build dir that was never created raises EsphomeError. @@ -1575,3 +1584,193 @@ def test_missing_image_hint_names_the_flag(setup_core: Path) -> None: f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n" ) assert "--skip-bootloader" in toolchain.missing_image_hint() + + +def _write_target_tree( + main: str | None = None, + bootloader: str | None = None, + bootloader_toolchain: str | None = None, + sdkconfig: str | None = None, +) -> None: + """Write the target into the CMake caches and sdkconfig.""" + tools = "/idf/tools/cmake" + for rel, target, toolchain_target in ( + ("build", main, main), + ("build/bootloader", bootloader, bootloader_toolchain or bootloader), + ): + if target is None: + continue + cache = CORE.relative_build_path(rel, "CMakeCache.txt") + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text( + f"CMAKE_TOOLCHAIN_FILE:FILEPATH={tools}/toolchain-{toolchain_target}.cmake\n" + f"IDF_TARGET:STRING={target}\n" + ) + if sdkconfig is not None: + path = CORE.relative_build_path("sdkconfig.test") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f'CONFIG_FOO=y\nCONFIG_IDF_TARGET="{sdkconfig}"\n') + + +@pytest.mark.parametrize( + ("tree", "expected"), + [ + ({}, None), + ({"main": "esp32c6", "bootloader": "esp32c6", "sdkconfig": "esp32c6"}, None), + ({"main": "esp32h2"}, "esp32h2"), + ({"bootloader": "esp32h2"}, "esp32h2"), + # Failed bootloader configure: new IDF_TARGET, old toolchain file + ({"bootloader": "esp32c6", "bootloader_toolchain": "esp32h2"}, "esp32h2"), + ({"main": "esp32c6", "sdkconfig": "esp32h2"}, "esp32h2"), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32c6", + }, + None, + ), + ( + { + "main": "esp32c6", + "bootloader": "esp32c6", + "bootloader_toolchain": "clang-esp32h2", + }, + "esp32h2", + ), + ], +) +def test_foreign_idf_target( + setup_core: Path, tree: dict[str, str], expected: str | None +) -> None: + """Another target in any cache or sdkconfig counts.""" + _setup_build(setup_core) + _write_target_tree(**tree) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +@pytest.mark.parametrize( + ("cache", "sdkconfig", "expected"), + [ + ("IDF_TARGET:STRING=esp32h2\n", None, "esp32h2"), + (None, "CONFIG_FOO=y\n", None), + ], +) +def test_foreign_idf_target_partial_entries( + setup_core: Path, cache: str | None, sdkconfig: str | None, expected: str | None +) -> None: + """Missing entries are skipped.""" + _setup_build(setup_core) + for rel, content in ( + ("build/CMakeCache.txt", cache), + ("sdkconfig.test", sdkconfig), + ): + if content is not None: + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + assert toolchain._foreign_idf_target("esp32c6") == expected + + +def test_foreign_idf_target_ignores_unreadable_files(setup_core: Path) -> None: + """Unreadable files are ignored.""" + _setup_build(setup_core) + for rel in ("build/CMakeCache.txt", "sdkconfig.test"): + path = CORE.relative_build_path(rel) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"\xff\xfe IDF_TARGET:STRING=esp32h2\n") + assert toolchain._foreign_idf_target("esp32c6") is None + + +@pytest.mark.parametrize("snapshot", [True, False]) +def test_clean_foreign_target_tree_cleans_and_resets_sdkconfig( + setup_core: Path, snapshot: bool +) -> None: + """A foreign tree is cleaned and sdkconfig reset from the snapshot.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(bootloader="esp32c6", bootloader_toolchain="esp32h2") + _write_target_tree(sdkconfig="esp32h2") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + if snapshot: + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + + with patch( + "esphome.writer.clean_build", + side_effect=lambda **_: shutil.rmtree(CORE.relative_build_path("build")), + ) as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_called_once_with(clear_pio_cache=False) + sdkconfig = CORE.relative_build_path("sdkconfig.test").read_text() + if snapshot: + assert sdkconfig == "CONFIG_IDF_TARGET_ESP32C6=y\n" + else: + assert 'CONFIG_IDF_TARGET="esp32h2"' in sdkconfig + + +@pytest.mark.parametrize("variant", ["ESP32C6", None]) +def test_clean_foreign_target_tree_keeps_matching_tree( + setup_core: Path, variant: str | None +) -> None: + """A matching tree or unknown variant is left alone.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = variant + _write_target_tree(main="esp32c6", bootloader="esp32c6", sdkconfig="esp32c6") + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + before = sdkconfig.read_text() + + with patch("esphome.writer.clean_build") as mock_clean: + toolchain._clean_foreign_target_tree() + + mock_clean.assert_not_called() + assert sdkconfig.read_text() == before + + +@pytest.mark.parametrize("main", ["esp32h2", None]) +def test_clean_foreign_target_tree_skipped_clean_keeps_sdkconfig( + setup_core: Path, monkeypatch: pytest.MonkeyPatch, main: str | None +) -> None: + """ESPHOME_SKIP_CLEAN_BUILD keeps the tree and sdkconfig.""" + _setup_build(setup_core) + monkeypatch.setenv("ESPHOME_SKIP_CLEAN_BUILD", "1") + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main=main, sdkconfig="esp32h2") + CORE.relative_build_path("sdkconfig.test.esphomeinternal").write_text( + "CONFIG_IDF_TARGET_ESP32C6=y\n" + ) + + toolchain._clean_foreign_target_tree() + + assert CORE.relative_build_path("build/CMakeCache.txt").is_file() == bool(main) + assert 'CONFIG_IDF_TARGET="esp32h2"' in ( + CORE.relative_build_path("sdkconfig.test").read_text() + ) + + +def test_run_compile_cleans_foreign_target_tree_first(setup_core: Path) -> None: + """run_compile cleans a foreign tree before the reconfigure check.""" + _setup_build(setup_core) + CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6" + _write_target_tree(main="esp32h2") + calls: list[str] = [] + + with ( + patch( + "esphome.writer.clean_build", + side_effect=lambda **_: calls.append("clean"), + ), + patch.object( + toolchain, + "need_reconfigure", + side_effect=lambda: calls.append("check") or False, + ), + patch.object(toolchain, "_cache_entries_changed", return_value=False), + patch.object(toolchain, "_run_ninja", return_value=0), + patch.object(toolchain, "print_summary"), + ): + assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0 + + assert calls == ["clean", "check"] From 4ba0e1bfa2796116ec831d5a46e8eb54954f18f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 07:39:49 -1000 Subject: [PATCH 04/97] [core] Fix the native ESP8266 and host builds on Windows without ccache (#20468) --- esphome/build_gen/arduino8266.py | 2 +- esphome/build_gen/host.py | 2 +- esphome/build_helpers/ninja_gen.py | 21 ++++++++++++------- .../unit_tests/build_gen/test_arduino8266.py | 3 ++- tests/unit_tests/build_gen/test_host.py | 5 ++++- .../build_helpers/test_ninja_gen.py | 18 ++++++++++++++++ 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index 44369136a01..b0ba7663927 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), *pch_rule_lines(), "rule link", diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py index f1e53e1ccb2..3a8587891b2 100644 --- a/esphome/build_gen/host.py +++ b/esphome/build_gen/host.py @@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool: lines = [ *tool_lines(compilers.cc, compilers.cxx, ccache), - *compile_rule_lines(), + *compile_rule_lines(ccache), *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags", diff --git a/esphome/build_helpers/ninja_gen.py b/esphome/build_helpers/ninja_gen.py index a2ee244a29f..c74262fdeea 100644 --- a/esphome/build_helpers/ninja_gen.py +++ b/esphome/build_helpers/ninja_gen.py @@ -74,33 +74,40 @@ def tool_lines( # path gets f"python = {_q(strip_win_long_path_prefix(sys.executable))}", f"buildtool = {_q(_BUILD_TOOL)}", - f"ccache = {_q(ccache) if ccache else ''}", + *([f"ccache = {_q(ccache)}"] if ccache else []), "", ] -def compile_rule_lines() -> list[str]: - """The compile rules; ``$own_includes`` is empty unless an edge sets it.""" +def compile_rule_lines(ccache: str | None) -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it. + + ``ccache`` is the launcher ``tool_lines`` was given; the same test here + keeps the variable and the rules in step. It is spliced in rather than + left as an empty variable: a leading space on the command line makes + CreateProcess fail on Windows. + """ + launcher = "$ccache " if ccache else "" return [ "rule c", - " command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CC $out", "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = CXX $out", "rule aspp", - " command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", + f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out", " depfile = $out.d", " deps = gcc", " description = AS $out", # Plain assembler, as SCons's ASCOM: no preprocessor, so no # depfile and no $flags (defines/includes) either "rule asm", - " command = $ccache $cc -x assembler $asflags -c $in -o $out", + f" command = {launcher}$cc -x assembler $asflags -c $in -o $out", " description = AS $out", ] diff --git a/tests/unit_tests/build_gen/test_arduino8266.py b/tests/unit_tests/build_gen/test_arduino8266.py index 2ea4ffabe6e..4c60b4e4764 100644 --- a/tests/unit_tests/build_gen/test_arduino8266.py +++ b/tests/unit_tests/build_gen/test_arduino8266.py @@ -888,8 +888,9 @@ def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None _set_flags() content = _write_ninja(paths) assert "lowlevel.s.o: asm " in content + assert "rule asm\n command = $cc -x assembler $asflags -c $in -o $out" in content assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in ( - content + _write_ninja(paths, ccache="/cc/ccache") ) diff --git a/tests/unit_tests/build_gen/test_host.py b/tests/unit_tests/build_gen/test_host.py index 9e40698c91c..c3936ee65a8 100644 --- a/tests/unit_tests/build_gen/test_host.py +++ b/tests/unit_tests/build_gen/test_host.py @@ -308,7 +308,10 @@ def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render() - assert "ccache = \n" in ninja + # No launcher variable, and no leading space on the commands either: + # CreateProcess on Windows rejects a command line that starts with one + assert "$ccache" not in ninja + assert " command = $cxx -MMD -MF $out.d" in ninja def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: diff --git a/tests/unit_tests/build_helpers/test_ninja_gen.py b/tests/unit_tests/build_helpers/test_ninja_gen.py index e7f9b96af2e..d51110efad8 100644 --- a/tests/unit_tests/build_helpers/test_ninja_gen.py +++ b/tests/unit_tests/build_helpers/test_ninja_gen.py @@ -55,3 +55,21 @@ def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None: tmp_path / "b.cpp", tmp_path / "sub" / "c.S", ] + + +@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None]) +def test_compile_rule_lines_splice_the_launcher(ccache: str | None) -> None: + """Without ccache no command keeps a leading space, which CreateProcess + on Windows rejects; with it every compile rule starts with the launcher.""" + commands = [ + line.removeprefix(" command = ") + for line in ninja_gen.compile_rule_lines(ccache) + if line.startswith(" command = ") + ] + assert len(commands) == 4 + for command in commands: + if ccache: + assert command.startswith("$ccache $c") + else: + assert command.startswith("$c") + assert "$ccache" not in command From 9f4a7cf5cc715e8795ddc4c270c061669dfee847 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Sat, 10 Oct 2026 19:49:35 +0200 Subject: [PATCH 05/97] [nrf52] fix build error on 3.4.0 with pch ON (#20474) --- esphome/components/nrf52/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 7cd0c6cdc1b..de9dc6401e4 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -835,7 +835,7 @@ _PCH_CMAKE_LINES = [ "set(esphome_kept_options)", "set(esphome_pch_headers)", "foreach(option IN LISTS esphome_options)", - ' if(option MATCHES "imacros> (.+)$")', + ' if(option MATCHES "imacros> ([^>]+)")', ' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")', " list(APPEND esphome_kept_options", ' "$<$,$,app>>>:${option}>")', From 3447b681363f6aae7aeed9f7a1570b7028b5dc7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 08:00:40 -1000 Subject: [PATCH 06/97] [ci] Compile an ESP8266 config with the native toolchain on Windows (#20467) --- .github/workflows/ci.yml | 86 +++++++++++++++++++++++++++++++++ script/test_build_components.py | 5 +- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 586263c6514..285d6869e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1335,6 +1335,91 @@ jobs: # config first, so a separate config pass is redundant. python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests + test-esp8266-native-windows: + name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }}) + runs-on: windows-latest + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true' + # A hung compiler must fail the job, not hold the runner for six hours. + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + # Both ways the build runs on Windows: ESPHome Desktop bundles + # ccache and puts it on PATH, a pip install has none. + ccache: ["1", "0"] + env: + ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }} + # The test script prints characters the cp1252 console cannot encode + PYTHONUTF8: "1" + # The exact static build ESPHome Desktop ships + CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip + CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + + - name: Install ccache + if: matrix.ccache == '1' + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP "ccache" + New-Item -ItemType Directory -Force $dir | Out-Null + $zip = Join-Path $dir "ccache.zip" + Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() + if ($hash -ne $env:CCACHE_SHA256) { + Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256" + exit 1 + } + Expand-Archive -Path $zip -DestinationPath $dir + $bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1 + Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName + & $bin.FullName --version + + - name: Compile + shell: pwsh + # One component test: web_server brings wifi, api and the async web + # server libraries, the shape of a typical ESP8266 device + run: | + . .\venv\Scripts\Activate.ps1 + $arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests" + $p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru + # Reading the handle keeps ExitCode available after the process exits + $null = $p.Handle + if (-not $p.WaitForExit(25 * 60 * 1000)) { + Write-Host "::error::The compile did not finish in 25 minutes; processes still running:" + Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } | + Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine | + Format-List | Out-String -Width 4096 | Write-Host + Get-Process | + Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } | + Select-Object Id, Name, CPU, WorkingSet64 | + Format-Table | Out-String | Write-Host + taskkill /PID $p.Id /T /F + exit 1 + } + exit $p.ExitCode + + - name: Show ccache statistics + # Also after a failed or killed compile: the counters say whether + # ccache was ever reached + if: always() && matrix.ccache == '1' + shell: pwsh + # The build keeps its store under the esphome tools cache + run: | + $env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache" + ccache -s + device-builder: name: Test downstream esphome/device-builder runs-on: ubuntu-24.04 @@ -1705,6 +1790,7 @@ jobs: - test-build-components-split - test-esp32-platformio - test-esp8266-native + - test-esp8266-native-windows - device-builder - memory-impact-target-branch - memory-impact-pr-branch diff --git a/script/test_build_components.py b/script/test_build_components.py index c955391a619..85e4df38f17 100755 --- a/script/test_build_components.py +++ b/script/test_build_components.py @@ -21,6 +21,7 @@ from dataclasses import dataclass import hashlib import os from pathlib import Path +import shutil import subprocess import sys import time @@ -83,7 +84,9 @@ def show_disk_space_if_ci(esphome_command: str) -> None: print("=" * 80) # Use sys.stdout.flush() to ensure output appears immediately sys.stdout.flush() - subprocess.run(["df", "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr) + # Windows has no df + if df := shutil.which("df"): + subprocess.run([df, "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr) print("=" * 80 + "\n") sys.stdout.flush() From eb1afa4245e32f6bf806f8a84817d136d9ca7fa6 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:15:30 +0200 Subject: [PATCH 07/97] [tests] Close leftover server connections in the tcp_uart bridge and TCP client link tests (#20215) Co-authored-by: J. Nick Koston --- tests/integration/test_socket_tcp_client_link.py | 6 ++++++ tests/integration/test_tcp_uart_bridge.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/integration/test_socket_tcp_client_link.py b/tests/integration/test_socket_tcp_client_link.py index 16b5f544f79..89a6ff42347 100644 --- a/tests/integration/test_socket_tcp_client_link.py +++ b/tests/integration/test_socket_tcp_client_link.py @@ -35,6 +35,7 @@ async def test_socket_tcp_client_link( link_down = asyncio.Event() second_link_up = asyncio.Event() link_up_count = 0 + writers: list[asyncio.StreamWriter] = [] def on_log_line(line: str) -> None: nonlocal link_up_count @@ -48,6 +49,7 @@ async def test_socket_tcp_client_link( async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: + writers.append(writer) if not echo_done.is_set(): writer.write(PAYLOAD) await writer.drain() @@ -98,5 +100,9 @@ async def test_socket_tcp_client_link( "Second session echo wrong; stale bytes from the first session?" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() diff --git a/tests/integration/test_tcp_uart_bridge.py b/tests/integration/test_tcp_uart_bridge.py index fb22a26b558..70befc90498 100644 --- a/tests/integration/test_tcp_uart_bridge.py +++ b/tests/integration/test_tcp_uart_bridge.py @@ -41,12 +41,14 @@ async def test_tcp_uart_bridge( service_byte_ok = asyncio.Event() reconnect_echo_ok = asyncio.Event() sessions = 0 + writers: list[asyncio.StreamWriter] = [] async def handle( reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: nonlocal sessions sessions += 1 + writers.append(writer) if sessions == 1: writer.write(GREETING) await writer.drain() @@ -98,5 +100,9 @@ async def test_tcp_uart_bridge( reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again" ) finally: + # On Python 3.12 wait_closed() waits for every connection the handlers + # left open; nothing else closes them once the device has exited. + for writer in writers: + writer.close() server.close() await server.wait_closed() From ea7c277c08af45828fdd8309d8c8d80d6f09c06e Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:34:29 -0400 Subject: [PATCH 08/97] [ci] Mark PRs with merge conflicts stale after 30 days (#20490) --- .github/workflows/stale.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 43b7e6663d6..7507eb4a424 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,16 +16,18 @@ 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@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main + uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: # Live only on dev: a workflow_dispatch from any other branch is a dry run dry-run: ${{ github.ref != 'refs/heads/dev' }} days-before-stale: 90 + days-before-stale-conflicting: 30 days-before-close: 7 stale-label: stale exempt-label: not-stale + conflict-label: needs-rebase ignored-users: esphbot,codecov-commenter stale-pr-message: > There hasn't been any activity on this pull request recently. This @@ -36,6 +38,18 @@ jobs: to keep it open. Also, please merge the latest dev branch into your branch to ensure that it's up to date with the latest changes. + Thank you for your contribution! + conflict-pr-message: > + This pull request has merge conflicts. Please merge the latest dev + branch into your branch to resolve them. + stale-conflicting-pr-message: > + The merge conflicts on this pull request have not been resolved for + 30 days, so it has been marked as stale and will be closed if they + are not resolved within 7 days. + + Please merge the latest dev branch into your branch. Once the pull + request is mergeable again the stale label is removed. + Thank you for your contribution! stale-issue-message: > There hasn't been any activity on this issue recently. Due to the From a4ef8a6c905197680341c631f851f4e16241d56a Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 10 Oct 2026 11:43:33 -0700 Subject: [PATCH 09/97] [addressable_light] Clear white channel when setting HSV color (#20265) --- esphome/components/light/esp_color_view.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 440a23e9c93..018e30a9a81 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -19,10 +19,7 @@ class ESPColorSettable { 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) { - Color rgb = color.to_rgb(); - this->set_rgb(rgb.r, rgb.g, rgb.b); - } + void set_hsv(const ESPHSVColor &color) { this->set(color.to_rgb()); } void set_rgb(uint8_t red, uint8_t green, uint8_t blue) { this->set_red(red); this->set_green(green); From 667dbd1ec3fb752928b95e4b4c44ad3e65ac0d8d Mon Sep 17 00:00:00 2001 From: Jeff Brown Date: Sat, 10 Oct 2026 11:56:16 -0700 Subject: [PATCH 10/97] [adalight] Add a component build test (#19103) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- tests/components/adalight/common.yaml | 13 +++++++++++++ tests/components/adalight/test.esp8266-ard.yaml | 3 +++ 2 files changed, 16 insertions(+) create mode 100644 tests/components/adalight/common.yaml create mode 100644 tests/components/adalight/test.esp8266-ard.yaml diff --git a/tests/components/adalight/common.yaml b/tests/components/adalight/common.yaml new file mode 100644 index 00000000000..4203c76a500 --- /dev/null +++ b/tests/components/adalight/common.yaml @@ -0,0 +1,13 @@ +adalight: + +light: + - platform: neopixelbus + id: adalight_light + name: Neopixelbus Light + method: esp8266_uart + num_leds: 5 + pin: 2 + type: GRBW + variant: SK6812 + effects: + - adalight: diff --git a/tests/components/adalight/test.esp8266-ard.yaml b/tests/components/adalight/test.esp8266-ard.yaml new file mode 100644 index 00000000000..08ddd3f007a --- /dev/null +++ b/tests/components/adalight/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml + adalight: !include common.yaml From 2ac48d408f33c6430acbecf9036096f0384c9f7f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 08:56:49 -1000 Subject: [PATCH 11/97] [const] Move automatic_baseline_calibration to the shared constants (#20492) --- esphome/components/const/__init__.py | 1 + esphome/components/mhz19/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index b54d797f096..6b1ffc5473d 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_ALLOWED_IPS = "allowed_ips" +CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" diff --git a/esphome/components/mhz19/sensor.py b/esphome/components/mhz19/sensor.py index 053f17765e1..4daeef5059c 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -2,6 +2,7 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( CONF_CO2, @@ -19,7 +20,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] -CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_DETECTION_RANGE = "detection_range" mhz19_ns = cg.esphome_ns.namespace("mhz19") From f70b6a6b03afe9256b72a77159fffaa9ae49e5c9 Mon Sep 17 00:00:00 2001 From: Jeff Zigler <123041141+zigboi@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:04:06 -0700 Subject: [PATCH 12/97] [mitsubishi] Add fahrenheit_compatibility mode (#18880) 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/mitsubishi/climate.py | 3 ++ esphome/components/mitsubishi/mitsubishi.cpp | 32 +++++++++++++++++++- esphome/components/mitsubishi/mitsubishi.h | 10 ++++++ tests/components/mitsubishi/common.yaml | 4 +++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 2d38351898a..37431648969 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -20,6 +20,7 @@ SETFANMODE = { CONF_SUPPORTS_DRY = "supports_dry" CONF_SUPPORTS_FAN_ONLY = "supports_fan_only" +CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") @@ -49,6 +50,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).ex cv.Optional(CONF_SET_FAN_MODE, default="3levels"): cv.enum(SETFANMODE), cv.Optional(CONF_SUPPORTS_DRY, default=False): cv.boolean, cv.Optional(CONF_SUPPORTS_FAN_ONLY, default=False): cv.boolean, + cv.Optional(CONF_FAHRENHEIT_COMPATIBILITY, default=False): cv.boolean, cv.Optional(CONF_HORIZONTAL_DEFAULT, default="middle"): cv.enum( HORIZONTAL_DIRECTIONS ), @@ -65,5 +67,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fan_mode(config[CONF_SET_FAN_MODE])) cg.add(var.set_supports_dry(config[CONF_SUPPORTS_DRY])) cg.add(var.set_supports_fan_only(config[CONF_SUPPORTS_FAN_ONLY])) + cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index dabd62b8f1d..8ee4bda042e 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -1,4 +1,5 @@ #include "mitsubishi.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -53,6 +54,33 @@ const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; const uint8_t MITSUBISHI_BYTE16 = 0x00; +// Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree +// maps to its own code. +static const uint8_t FAHRENHEIT_CODES[] PROGMEM = {0x00, 0x10, 0x01, 0x11, 0x02, 0x12, 0x03, 0x04, 0x05, 0x15, + 0x06, 0x16, 0x07, 0x17, 0x08, 0x18, 0x09, 0x19, 0x0a, 0x1a, + 0x0b, 0x1b, 0x0c, 0x1c, 0x0d, 0x1d, 0x0e, 0x0f}; +static constexpr uint8_t FAHRENHEIT_MIN = 61; +static constexpr uint8_t MITSUBISHI_HALF_DEGREE = 0x10; + +static uint8_t fahrenheit_code(float celsius) { + int fahrenheit = (int) roundf(celsius_to_fahrenheit(celsius)); + size_t index = clamp(fahrenheit - FAHRENHEIT_MIN, 0, sizeof(FAHRENHEIT_CODES) - 1); + return progmem_read_byte(&FAHRENHEIT_CODES[index]); +} + +// In Fahrenheit mode a code from the table decodes to the exact Fahrenheit value. Any other code, and every code in +// Celsius mode, is a whole degree plus the half degree bit, which a remote set to Fahrenheit also sets. +static float decode_temperature(uint8_t code, bool fahrenheit) { + if (fahrenheit) { + for (size_t i = 0; i < sizeof(FAHRENHEIT_CODES); i++) { + if (code == progmem_read_byte(&FAHRENHEIT_CODES[i])) { + return fahrenheit_to_celsius(FAHRENHEIT_MIN + i); + } + } + } + return MITSUBISHI_TEMP_MIN + (code & 0x0F) + ((code & MITSUBISHI_HALF_DEGREE) ? 0.5f : 0.0f); +} + void MitsubishiClimate::transmit_state() { // Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00 // Byte 5: On=0x20, Off: 0x00 @@ -113,6 +141,8 @@ void MitsubishiClimate::transmit_state() { // Temperature if (this->mode == climate::CLIMATE_MODE_DRY) { remote_state[7] = 24 - MITSUBISHI_TEMP_MIN; // Remote sends always 24°C if "Dry" mode is selected + } else if (this->fahrenheit_compatibility_) { + remote_state[7] = fahrenheit_code(this->target_temperature); } else { remote_state[7] = (uint8_t) roundf( clamp(this->target_temperature, MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX) - MITSUBISHI_TEMP_MIN); @@ -300,7 +330,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Temp - this->target_temperature = state_frame[7] + MITSUBISHI_TEMP_MIN; + this->target_temperature = decode_temperature(state_frame[7], this->fahrenheit_compatibility_); // Fan uint8_t fan = state_frame[9] & 0x07; //(Bit 0,1,2 = Speed) diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 269d39b06b4..cf943389e11 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/core/helpers.h" #include @@ -9,6 +10,8 @@ namespace esphome::mitsubishi { // Temperature const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius +// Remotes set to Fahrenheit go up to 88°F, one step past the Celsius range +static constexpr float MITSUBISHI_TEMP_MAX_FAHRENHEIT = fahrenheit_to_celsius(88.0f); // Fan mode enum SetFanMode : uint8_t { @@ -55,6 +58,12 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_supports_fan_only(bool supports_fan_only) { this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only); } + void set_fahrenheit_compatibility(bool fahrenheit_compatibility) { + this->fahrenheit_compatibility_ = fahrenheit_compatibility; + if (fahrenheit_compatibility) { + this->maximum_temperature_ = MITSUBISHI_TEMP_MAX_FAHRENHEIT; + } + } void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; @@ -80,6 +89,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); SetFanMode fan_mode_; + bool fahrenheit_compatibility_{false}; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; diff --git a/tests/components/mitsubishi/common.yaml b/tests/components/mitsubishi/common.yaml index 4a2deda1630..8335ea35435 100644 --- a/tests/components/mitsubishi/common.yaml +++ b/tests/components/mitsubishi/common.yaml @@ -2,3 +2,7 @@ climate: - platform: mitsubishi name: Mitsubishi transmitter_id: xmitr + fahrenheit_compatibility: true + - platform: mitsubishi + name: Mitsubishi Celsius + transmitter_id: xmitr From 17cbb751613e6559b31651f37c1e9068c6cbd6eb Mon Sep 17 00:00:00 2001 From: Jeff Zigler <123041141+zigboi@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:11:14 -0700 Subject: [PATCH 13/97] [mitsubishi] Control both vanes on multi-vane Mitsubishi minisplit heads (#12609) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mitsubishi/climate.py | 4 ++++ esphome/components/mitsubishi/mitsubishi.cpp | 19 +++++++++++++------ esphome/components/mitsubishi/mitsubishi.h | 2 ++ tests/components/mitsubishi/common.yaml | 1 + 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 37431648969..1fbe1d67cf3 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -44,6 +44,8 @@ VERTICAL_DIRECTIONS = { "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, } +CONF_VERTICAL_VANES = "vertical_vanes" + CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).extend( { @@ -57,6 +59,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).ex cv.Optional(CONF_VERTICAL_DEFAULT, default="middle"): cv.enum( VERTICAL_DIRECTIONS ), + cv.Optional(CONF_VERTICAL_VANES, default=1): cv.int_range(1, 2), } ) @@ -70,3 +73,4 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) + cg.add(var.set_vertical_vanes(config[CONF_VERTICAL_VANES])) diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 8ee4bda042e..515cd81fdfe 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -27,6 +27,7 @@ const uint8_t MITSUBISHI_WIDE_VANE_SWING = 0xC0; const uint8_t MITSUBISHI_FAN_AUTO = 0x00; +const uint8_t MITSUBISHI_VERTICAL_VANE_MASK = 0x38; // Bits 3,4,5 const uint8_t MITSUBISHI_VERTICAL_VANE_SWING = 0x38; // const uint8_t MITSUBISHI_AUTO = 0x80; @@ -52,7 +53,6 @@ const uint8_t MITSUBISHI_BYTE02 = 0x26; const uint8_t MITSUBISHI_BYTE03 = 0x01; const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; -const uint8_t MITSUBISHI_BYTE16 = 0x00; // Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree // maps to its own code. @@ -100,7 +100,7 @@ void MitsubishiClimate::transmit_state() { // Byte 13: Constant 0x00 // Byte 14: HVAC specfic, i.e. ECONO COOL, CLEAN MODE, always 0x00 // Byte 15: HVAC specfic, i.e. POWERFUL, SMART SET, PLASMA, always 0x00 - // Byte 16: Constant 0x00 + // Byte 16: Left vane control (for specific models). Constants match right vane controls. // Byte 17: Checksum: SUM[Byte0...Byte16] uint8_t remote_state[18] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; @@ -214,7 +214,7 @@ void MitsubishiClimate::transmit_state() { break; } - ESP_LOGD(TAG, "default_vertical_direction_: %02X", this->default_vertical_direction_); + ESP_LOGD(TAG, "Vertical default: 0x%02X, vanes: %u", this->default_vertical_direction_, this->vertical_vanes_); // Special modes switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { @@ -236,6 +236,11 @@ void MitsubishiClimate::transmit_state() { break; } + if (this->vertical_vanes_ > 1) { + // Heads with two vertical vanes carry the left vane in byte 16; it follows the right one, presets included + remote_state[16] = remote_state[9] & MITSUBISHI_VERTICAL_VANE_MASK; + } + // Checksum for (int i = 0; i < 17; i++) { remote_state[17] += remote_state[i]; @@ -296,11 +301,11 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } state_frame[pos] = byte; - // Check Header && Footer + // Check Header && Footer. Byte 16 only ever carries the left vane position (bits 3,4,5). if ((pos == 0 && byte != MITSUBISHI_BYTE00) || (pos == 1 && byte != MITSUBISHI_BYTE01) || (pos == 2 && byte != MITSUBISHI_BYTE02) || (pos == 3 && byte != MITSUBISHI_BYTE03) || (pos == 4 && byte != MITSUBISHI_BYTE04) || (pos == 13 && byte != MITSUBISHI_BYTE13) || - (pos == 16 && byte != MITSUBISHI_BYTE16)) { + (pos == 16 && (byte & ~MITSUBISHI_VERTICAL_VANE_MASK) != 0)) { ESP_LOGV(TAG, "Bytes 0,1,2,3,4,13 or 16 fail - invalid value"); return false; } @@ -362,7 +367,9 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Vertical Vane - uint8_t vertical_vane = state_frame[9] & 0x38; // Bits 3,4,5 + // On dual vane heads, the left vane is in [16] and right vane is in [9]. Left is ignored here because + // the swing_mode enum doesn't convey that level of detail. + uint8_t vertical_vane = state_frame[9] & MITSUBISHI_VERTICAL_VANE_MASK; switch (vertical_vane) { case MITSUBISHI_VERTICAL_VANE_SWING: if (this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL) { diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index cf943389e11..493661f9a6d 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -80,6 +80,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_vertical_default(VerticalDirection vertical_direction) { this->default_vertical_direction_ = vertical_direction; } + void set_vertical_vanes(uint8_t vertical_vanes) { this->vertical_vanes_ = vertical_vanes; } protected: // Transmit via IR the state of this climate controller. @@ -93,6 +94,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; + uint8_t vertical_vanes_{1}; }; } // namespace esphome::mitsubishi diff --git a/tests/components/mitsubishi/common.yaml b/tests/components/mitsubishi/common.yaml index 8335ea35435..541ba4f5587 100644 --- a/tests/components/mitsubishi/common.yaml +++ b/tests/components/mitsubishi/common.yaml @@ -3,6 +3,7 @@ climate: name: Mitsubishi transmitter_id: xmitr fahrenheit_compatibility: true + vertical_vanes: 2 - platform: mitsubishi name: Mitsubishi Celsius transmitter_id: xmitr From 865863abf5fc848d3056a93a47a2e1dcfa436fdd Mon Sep 17 00:00:00 2001 From: CzBiX Date: Sun, 11 Oct 2026 03:12:40 +0800 Subject: [PATCH 14/97] [cm1106] Add ABC function (#8894) 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: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/cm1106/cm1106.cpp | 81 ++++++++++++++++++++-------- esphome/components/cm1106/cm1106.h | 25 ++++++++- esphome/components/cm1106/sensor.py | 29 ++++++++++ tests/components/cm1106/common.yaml | 8 +++ 4 files changed, 120 insertions(+), 23 deletions(-) diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index a25a753f754..edfcd0f7309 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -6,10 +6,16 @@ namespace esphome::cm1106 { ESPHOME_LOG_TAG(TAG, "cm1106"); -static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +static constexpr uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +// The factory default ABC calibration cycle differs between CM1106 models, so +// every ABC write applies the configured cycle and baseline explicitly. +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS[10] = {0x11, 0x07, 0x10, 0x64, 0x00, 0x0F, 0x01, 0x90, 0x64, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS_RESPONSE[4] = {0x16, 0x01, 0x10, 0xD9}; +static constexpr uint8_t CM1106_ABC_FLAG_ENABLE = 0x0; +static constexpr uint8_t CM1106_ABC_FLAG_DISABLE = 0x2; uint8_t cm1106_checksum(const uint8_t *response, size_t len) { uint8_t crc = 0; for (size_t i = 0; i < len - 1; i++) { @@ -25,34 +31,46 @@ void CM1106Component::setup() { this->mark_failed(); return; } + + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + this->abc_set_(this->abc_boot_logic_ == CM1106_ABC_ENABLED); + } } void CM1106Component::update() { uint8_t response[8] = {0}; if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + ESP_LOGW(TAG, "Reading data failed!"); this->status_set_warning(); return; } if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); return; } uint8_t checksum = cm1106_checksum(response, sizeof(response)); if (response[7] != checksum) { - ESP_LOGW(TAG, "CM1106 Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); + ESP_LOGW(TAG, "Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); this->status_set_warning(); return; } + const uint16_t ppm = response[3] << 8 | response[4]; + const uint8_t status = response[5]; + if (status & 0x1) { + ESP_LOGW(TAG, "Preheating; CO₂=%uppm", ppm); + this->status_set_warning(); + return; + } + if (status) { + ESP_LOGW(TAG, "Status: %02X", status); + } this->status_clear_warning(); - uint16_t ppm = response[3] << 8 | response[4]; - ESP_LOGD(TAG, "CM1106 Received CO₂=%uppm DF3=%02X DF4=%02X", ppm, response[5], response[6]); + ESP_LOGD(TAG, "CO₂=%uppm DF4=%02X", ppm, response[6]); if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(ppm); } @@ -62,24 +80,38 @@ void CM1106Component::calibrate_zero(uint16_t ppm) { memcpy(cmd, C_M1106_CMD_SET_CO2_CALIB, sizeof(cmd)); cmd[3] = ppm >> 8; cmd[4] = ppm & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_CO2_CALIB_RESPONSE)) { + ESP_LOGD(TAG, "Successfully calibrated sensor to %uppm", ppm); + } +} + +void CM1106Component::abc_set_(bool enabled) { + ESP_LOGD(TAG, "%sabling automatic baseline calibration", enabled ? LOG_STR_LITERAL("En") : LOG_STR_LITERAL("Dis")); + uint8_t cmd[10]; + memcpy(cmd, C_M1106_CMD_SET_ABC_STATUS, sizeof(cmd)); + cmd[4] = enabled ? CM1106_ABC_FLAG_ENABLE : CM1106_ABC_FLAG_DISABLE; + cmd[5] = this->abc_cycle_; + cmd[6] = this->abc_baseline_ >> 8; + cmd[7] = this->abc_baseline_ & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_ABC_STATUS_RESPONSE)) { + ESP_LOGD(TAG, "Successfully set ABC status"); + } +} + +bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack) { uint8_t response[4] = {0}; - - if (!this->cm1106_write_command_(cmd, sizeof(cmd), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) { + ESP_LOGW(TAG, "Command %02X failed", command[2]); this->status_set_warning(); - return; + return false; } - - // check if correct response received - if (memcmp(response, C_M1106_CMD_SET_CO2_CALIB_RESPONSE, sizeof(response)) != 0) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + if (memcmp(response, ack, sizeof(response)) != 0) { + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); - return; + return false; } - this->status_clear_warning(); - ESP_LOGD(TAG, "CM1106 Successfully calibrated sensor to %uppm", ppm); + return true; } bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, @@ -100,6 +132,13 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma void CM1106Component::dump_config() { ESP_LOGCONFIG(TAG, "CM1106:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + ESP_LOGCONFIG(TAG, + " Automatic baseline calibration on boot: %s\n" + " ABC calibration cycle: %u days\n" + " ABC baseline: %uppm", + ONOFF(this->abc_boot_logic_ == CM1106_ABC_ENABLED), this->abc_cycle_, this->abc_baseline_); + } if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index e381db7b5cd..1066a5c44dd 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -6,6 +6,14 @@ namespace esphome::cm1106 { +// ABC logic requested at boot; CM1106_ABC_NONE means nothing is sent and the +// sensor keeps its stored setting. +enum CM1106ABCLogic : uint8_t { + CM1106_ABC_NONE = 0, + CM1106_ABC_ENABLED, + CM1106_ABC_DISABLED, +}; + class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; @@ -13,13 +21,26 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { void dump_config() override; void calibrate_zero(uint16_t ppm); + void abc_enable() { this->abc_set_(true); } + void abc_disable() { this->abc_set_(false); } void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; } + void set_abc(bool enabled, uint8_t cycle_days, uint16_t baseline_ppm) { + this->abc_boot_logic_ = enabled ? CM1106_ABC_ENABLED : CM1106_ABC_DISABLED; + this->abc_cycle_ = cycle_days; + this->abc_baseline_ = baseline_ppm; + } protected: - sensor::Sensor *co2_sensor_{nullptr}; - bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); + /// Sends a command and checks the sensor's four byte acknowledgement, updating the warning status. + bool send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack); + void abc_set_(bool enabled); + + sensor::Sensor *co2_sensor_{nullptr}; + CM1106ABCLogic abc_boot_logic_{CM1106_ABC_NONE}; + uint8_t abc_cycle_{15}; + uint16_t abc_baseline_{400}; }; } // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index c3939735a43..fa179f9667d 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -4,9 +4,12 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( + CONF_BASELINE, CONF_CO2, + CONF_CYCLE, CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, @@ -18,11 +21,22 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andrewjswan"] + +# true enables ABC with these defaults, false disables it, a mapping enables it with custom values +_ABC_SETTINGS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_CYCLE, default=15): cv.int_range(min=1, max=90), + cv.Optional(CONF_BASELINE, default=400): cv.int_range(min=400, max=10000), + } +) + + cm1106_ns = cg.esphome_ns.namespace("cm1106") CM1106Component = cm1106_ns.class_( "CM1106Component", cg.PollingComponent, uart.UARTDevice ) + CONFIG_SCHEMA = ( cv.Schema( { @@ -34,6 +48,9 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_AUTOMATIC_BASELINE_CALIBRATION): cv.Any( + cv.boolean, _ABC_SETTINGS_SCHEMA + ), }, ) .extend(cv.polling_component_schema("60s")) @@ -57,6 +74,11 @@ async def to_code(config: ConfigType) -> None: sensors = sensor.sub_sensors(config) await sensors(CONF_CO2, var.set_co2_sensor) + if (abc := config.get(CONF_AUTOMATIC_BASELINE_CALIBRATION)) is not None: + enabled = abc if isinstance(abc, bool) else True + settings = _ABC_SETTINGS_SCHEMA({}) if isinstance(abc, bool) else abc + cg.add(var.set_abc(enabled, settings[CONF_CYCLE], settings[CONF_BASELINE])) + CALIBRATION_ACTION_SCHEMA = maybe_simple_id( { @@ -70,3 +92,10 @@ automation.register_apply_action( CALIBRATION_ACTION_SCHEMA, automation.ApplyCall("calibrate_zero(400)"), ) +for _name, _call in ( + ("cm1106.abc_enable", "abc_enable()"), + ("cm1106.abc_disable", "abc_disable()"), +): + automation.register_apply_action( + _name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/cm1106/common.yaml b/tests/components/cm1106/common.yaml index ed2fd670075..eb2fde3a492 100644 --- a/tests/components/cm1106/common.yaml +++ b/tests/components/cm1106/common.yaml @@ -1,5 +1,13 @@ sensor: - platform: cm1106 + id: cm1106_sensor co2: name: CM1106 CO2 Value + on_value: + then: + - cm1106.abc_enable: cm1106_sensor + - cm1106.abc_disable: cm1106_sensor update_interval: 15s + automatic_baseline_calibration: + cycle: 7 + baseline: 420 From ff96530e4d455b7e6d3c6c710690c978b48a4310 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:13:05 -0400 Subject: [PATCH 15/97] [esp32] Honor sdkconfig CPU frequency options (#20158) Co-authored-by: J. Nick Koston --- esphome/components/esp32/__init__.py | 90 +++++++- tests/component_tests/esp32/test_esp32.py | 239 ++++++++++++++++++++++ 2 files changed, 326 insertions(+), 3 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index aa4f78de69d..9bceaf5676d 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -551,9 +551,88 @@ assert all(variant in CPU_FREQUENCIES for variant in VARIANTS) FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values())) +_SDKCONFIG_CPU_FREQUENCY_OPTION = "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ" +_SDKCONFIG_CPU_FREQUENCY_PREFIX = f"{_SDKCONFIG_CPU_FREQUENCY_OPTION}_" +# Older IDF versions named the CPU frequency options after the chip +_LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS = frozenset( + {VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3} +) + + +def _get_sdkconfig_cpu_frequencies( + sdkconfig_options: dict[str, str], variant: str +) -> tuple[set[int], set[int]]: + """Return the CPU frequencies in MHz that sdkconfig_options selects and disables.""" + # (integer option, choice option prefix) pairs that can carry a frequency + keys = [(_SDKCONFIG_CPU_FREQUENCY_OPTION, _SDKCONFIG_CPU_FREQUENCY_PREFIX)] + if variant in _LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS: + keys.append( + ( + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_MHZ", + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_", + ) + ) + selected: set[int] = set() + disabled: set[int] = set() + for integer_key, choice_prefix in keys: + if (value := sdkconfig_options.get(integer_key)) is not None: + try: + selected.add(int(value)) + except ValueError as err: + raise cv.Invalid( + f"{integer_key} must be an integer MHz value", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) from err + for name, value in sdkconfig_options.items(): + mhz = name.removeprefix(choice_prefix) + if mhz == name or not mhz.isdigit(): + continue + if value.lower() == "y": + selected.add(int(mhz)) + elif value.lower() == "n": + disabled.add(int(mhz)) + else: + raise cv.Invalid( + f"{name} must be y or n", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) + return selected, disabled + + def set_core_data(config): - cpu_frequency = config.get(CONF_CPU_FREQUENCY, None) + cpu_frequency = config.get(CONF_CPU_FREQUENCY) variant = config[CONF_VARIANT] + sdkconfig_path = [CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS] + selected, disabled = _get_sdkconfig_cpu_frequencies( + config[CONF_FRAMEWORK][CONF_SDKCONFIG_OPTIONS], variant + ) + if len(selected) > 1: + conflicting = ", ".join(f"{mhz}MHz" for mhz in sorted(selected)) + raise cv.Invalid( + f"sdkconfig_options contains conflicting CPU frequencies ({conflicting})", + path=sdkconfig_path, + ) + sdkconfig_cpu_frequency = f"{selected.pop()}MHZ" if selected else None + if cpu_frequency is None and sdkconfig_cpu_frequency is not None: + if sdkconfig_cpu_frequency not in CPU_FREQUENCIES[variant]: + raise cv.Invalid( + f"sdkconfig_options selects {sdkconfig_cpu_frequency}, which {variant} does not support", + path=sdkconfig_path, + ) + _LOGGER.warning( + "sdkconfig_options contains a CPU frequency setting; using %s. " + "Set 'esp32.cpu_frequency' to configure it directly.", + sdkconfig_cpu_frequency, + ) + cpu_frequency = sdkconfig_cpu_frequency + elif ( + sdkconfig_cpu_frequency is not None and sdkconfig_cpu_frequency != cpu_frequency + ): + raise cv.Invalid( + f"esp32.cpu_frequency ({cpu_frequency}) conflicts with sdkconfig_options " + f"({sdkconfig_cpu_frequency})", + path=sdkconfig_path, + ) # if not specified in config, default to the maximum supported frequency # (ESP32-P4 engineering samples are limited to 360MHz, non-engineering can do 400MHz) if cpu_frequency is None: @@ -562,12 +641,17 @@ def set_core_data(config): cpu_frequency = "360MHZ" else: cpu_frequency = choices[-1] - config[CONF_CPU_FREQUENCY] = cpu_frequency elif cpu_frequency not in CPU_FREQUENCIES[variant]: raise cv.Invalid( f"Invalid CPU frequency '{cpu_frequency}' for {config[CONF_VARIANT]}", path=[CONF_CPU_FREQUENCY], ) + if int(cpu_frequency[:-3]) in disabled: + raise cv.Invalid( + f"sdkconfig_options disables the selected CPU frequency ({cpu_frequency})", + path=sdkconfig_path, + ) + config[CONF_CPU_FREQUENCY] = cpu_frequency if variant == VARIANT_ESP32P4 and cpu_frequency == "400MHZ": _LOGGER.warning( @@ -3138,7 +3222,7 @@ async def to_code(config): # Set default CPU frequency add_idf_sdkconfig_option( - f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True + f"{_SDKCONFIG_CPU_FREQUENCY_PREFIX}{config[CONF_CPU_FREQUENCY][:-3]}", True ) # Apply LWIP optimization settings diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 4b7a0a6ab8e..9072a906293 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -111,6 +111,245 @@ def test_esp32_config( assert VARIANT_FRIENDLY[variant].lower() in config["board"] +@pytest.mark.parametrize( + ( + "sdkconfig_options", + "cpu_frequency", + "expected_frequency", + "warns", + "variant", + "framework_type", + ), + [ + pytest.param( + { + "CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y", + "CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160", + }, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-frequency-options-with-default", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"}, + "160MHz", + "160MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="explicit-cpu-frequency", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="legacy-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "esp-idf", + id="current-idf-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "n"}, + None, + "240MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="disabled-frequency-choice", + ), + pytest.param( + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ_240": "n"}, + "240MHz", + "240MHZ", + False, + VARIANT_ESP32, + "esp-idf", + id="ignore-unrecognized-legacy-disabled-choice", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32C3, + "esp-idf", + id="current-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32S2_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32S2, + "esp-idf", + id="s2-legacy-scalar-frequency", + ), + pytest.param( + {"CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160": "y"}, + None, + "160MHZ", + True, + VARIANT_ESP32S3, + "esp-idf", + id="s3-legacy-choice-frequency", + ), + pytest.param( + {"CONFIG_ESP32C3_DEFAULT_CPU_FREQ_80": "y"}, + None, + "80MHZ", + True, + VARIANT_ESP32C3, + "esp-idf", + id="c3-legacy-choice-frequency", + ), + pytest.param( + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"}, + None, + "160MHZ", + True, + VARIANT_ESP32, + "arduino", + id="arduino-framework-current-scalar-frequency", + ), + ], +) +def test_sdkconfig_cpu_frequency_is_honored( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + sdkconfig_options: dict[str, str], + cpu_frequency: str | None, + expected_frequency: str, + warns: bool, + variant: str, + framework_type: str, +) -> None: + """SDK config frequency settings are honored and recommend the native config key.""" + platform_framework = ( + PlatformFramework.ESP32_ARDUINO + if framework_type == "arduino" + else PlatformFramework.ESP32_IDF + ) + set_core_config(platform_framework) + from esphome.components.esp32 import CONFIG_SCHEMA + + config: dict[str, Any] = { + "variant": variant, + "framework": { + "type": framework_type, + "sdkconfig_options": sdkconfig_options, + }, + } + if cpu_frequency is not None: + config["cpu_frequency"] = cpu_frequency + + with caplog.at_level(logging.WARNING): + config = CONFIG_SCHEMA(config) + + assert ("CPU frequency setting" in caplog.text) is warns + assert config["cpu_frequency"] == expected_frequency + + +@pytest.mark.parametrize( + ("variant", "cpu_frequency", "sdkconfig_options", "error_match"), + [ + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "fast"}, + "must be an integer MHz value", + id="integer-option-not-a-number", + ), + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"}, + "ESP32C3 does not support", + id="unsupported-frequency", + ), + pytest.param( + VARIANT_ESP32C3, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "yes"}, + "must be y or n", + id="choice-option-not-y-or-n", + ), + pytest.param( + VARIANT_ESP32C3, + None, + { + "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y", + "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y", + }, + r"conflicting CPU frequencies \(160MHz, 240MHz\)", + id="two-frequencies-selected", + ), + pytest.param( + VARIANT_ESP32, + "160MHz", + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"}, + "conflicts with sdkconfig_options", + id="conflicts-with-explicit-value", + ), + pytest.param( + VARIANT_ESP32, + None, + {"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "n"}, + "disables the selected CPU frequency", + id="default-frequency-disabled", + ), + pytest.param( + VARIANT_ESP32, + "160MHz", + {"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "n"}, + "disables the selected CPU frequency", + id="explicit-frequency-disabled-by-legacy-option", + ), + ], +) +def test_sdkconfig_cpu_frequency_errors( + set_core_config: SetCoreConfigCallable, + variant: str, + cpu_frequency: str | None, + sdkconfig_options: dict[str, str], + error_match: str, +) -> None: + """Bad, conflicting or disabled sdkconfig frequencies fail at validation.""" + set_core_config(PlatformFramework.ESP32_IDF) + from esphome.components.esp32 import CONFIG_SCHEMA + + config: dict[str, Any] = { + "variant": variant, + "framework": {"type": "esp-idf", "sdkconfig_options": sdkconfig_options}, + } + if cpu_frequency is not None: + config["cpu_frequency"] = cpu_frequency + + with pytest.raises(cv.Invalid, match=error_match) as exc_info: + CONFIG_SCHEMA(config) + assert exc_info.value.path == ["framework", "sdkconfig_options"] + + @pytest.mark.parametrize( ("config_toolchain", "expected"), [ From ebbe3860eca1edee66f564483a64a3ee414bdfb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 09:43:57 -1000 Subject: [PATCH 16/97] [const] Move aqi to the shared constants (#20494) --- esphome/components/aqi/__init__.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/ens160_base/__init__.py | 2 +- esphome/components/hm3301/sensor.py | 3 ++- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/aqi/__init__.py b/esphome/components/aqi/__init__.py index 02c0bd2228a..56308e32983 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components.const import CONF_AQI # noqa: F401 CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] DOMAIN = "aqi" @@ -6,7 +7,6 @@ DOMAIN = "aqi" aqi_ns = cg.esphome_ns.namespace("aqi") AQICalculatorType = aqi_ns.enum("AQICalculatorType") -CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" CONF_EXTENDED_RANGE = "extended_range" diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 6b1ffc5473d..e93d508a3d8 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_ALLOWED_IPS = "allowed_ips" +CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index b41575c7171..8ef5971f17a 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_COMPENSATION, @@ -26,7 +27,6 @@ DOMAIN = "ens160_base" ens160_ns = cg.esphome_ns.namespace("ens160_base") -CONF_AQI = "aqi" CONFIG_SCHEMA_BASE = cv.Schema( { diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 79fb81ea5f5..d5e15f7c620 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -2,7 +2,8 @@ import logging import esphome.codegen as cg from esphome.components import i2c, sensor -from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_ID, From 6559ea470b1119d0c7a14d04ea905afcde7ae271 Mon Sep 17 00:00:00 2001 From: DAVe3283 Date: Sat, 10 Oct 2026 14:58:09 -0600 Subject: [PATCH 17/97] =?UTF-8?q?[tca9548a]=20Allow=20separate=20I=C2=B2C?= =?UTF-8?q?=20bus=20frequencies=20on=20each=20multiplexer=20port=20(#10422?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/i2c/i2c_bus.h | 10 ++ esphome/components/i2c/i2c_bus_arduino.cpp | 93 +++++++++------- esphome/components/i2c/i2c_bus_arduino.h | 14 ++- esphome/components/i2c/i2c_bus_esp_idf.cpp | 96 ++++++++++++----- esphome/components/i2c/i2c_bus_esp_idf.h | 15 +++ esphome/components/i2c/i2c_bus_host.h | 5 + esphome/components/i2c/i2c_bus_zephyr.cpp | 33 ++++-- esphome/components/i2c/i2c_bus_zephyr.h | 7 ++ esphome/components/tca9548a/__init__.py | 53 ++++++++- esphome/components/tca9548a/tca9548a.cpp | 102 +++++++++++++++++- esphome/components/tca9548a/tca9548a.h | 28 ++++- esphome/core/defines.h | 1 + tests/components/tca9548a/common.yaml | 4 + .../tca9548a/test.nrf52-adafruit.yaml | 3 + 14 files changed, 380 insertions(+), 84 deletions(-) create mode 100644 tests/components/tca9548a/test.nrf52-adafruit.yaml diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index 0c5e80bfe5f..be38317497a 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -4,6 +4,7 @@ #include #include +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" namespace esphome::i2c { @@ -53,6 +54,15 @@ class I2CBus { return this->write_readv(address, buffer, len, nullptr, 0); } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// @brief Switch the bus frequency for the transactions that follow; an external bus + /// that does not override this reports an error and keeps its frequency + virtual ErrorCode switch_frequency(uint32_t frequency) { return ERROR_UNKNOWN; } + + /// @brief The frequency the bus runs at + virtual uint32_t get_frequency() const { return 0; } +#endif + protected: /// @brief Scans the I2C bus for devices. Devices presence is kept in an array of std::pair /// that contains the address and the corresponding bool presence flag. diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 95aee2bddb2..215cc1364f8 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -15,7 +15,7 @@ ESPHOME_LOG_TAG(TAG, "i2c"); static constexpr size_t I2C_MAX_LOG_BYTES = 32; void ArduinoI2CBus::setup() { - recover_(); + this->recover_(); #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) @@ -25,9 +25,9 @@ void ArduinoI2CBus::setup() { // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf // RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf if ((this->sda_pin_ / 2) % 2 == 0) { - wire_ = &Wire; + this->wire_ = &Wire; } else { - wire_ = &Wire1; + this->wire_ = &Wire1; } #endif @@ -42,22 +42,22 @@ void ArduinoI2CBus::setup() { void ArduinoI2CBus::set_pins_and_clock_() { #ifdef USE_RP2 - wire_->setSDA(this->sda_pin_); - wire_->setSCL(this->scl_pin_); - wire_->begin(); + this->wire_->setSDA(this->sda_pin_); + this->wire_->setSCL(this->scl_pin_); + this->wire_->begin(); #else - wire_->begin(static_cast(sda_pin_), static_cast(scl_pin_)); + this->wire_->begin(static_cast(this->sda_pin_), static_cast(this->scl_pin_)); #endif - if (timeout_ > 0) { // if timeout specified in yaml + if (this->timeout_ > 0) { // if timeout specified in yaml #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h - wire_->setClockStretchLimit(timeout_); // unit: us + this->wire_->setClockStretchLimit(this->timeout_); // unit: us #elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h - wire_->setTimeout(timeout_ / 1000); // unit: ms + this->wire_->setTimeout(this->timeout_ / 1000); // unit: ms #endif } - wire_->setClock(frequency_); + this->wire_->setClock(this->frequency_); } void ArduinoI2CBus::dump_config() { @@ -67,7 +67,7 @@ void ArduinoI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %u Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); #elif defined(USE_RP2) @@ -87,10 +87,10 @@ void ArduinoI2CBus::dump_config() { } if (this->scan_) { ESP_LOGI(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGI(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGI(TAG, "Found device at address 0x%02X", s.first); } else { @@ -106,7 +106,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe #if defined(USE_ESP8266) this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances #endif - if (!initialized_) { + if (!this->initialized_) { ESP_LOGD(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -118,22 +118,22 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe uint8_t status = 0; if (write_count != 0 || read_count == 0) { - wire_->beginTransmission(address); - size_t ret = wire_->write(write_buffer, write_count); + this->wire_->beginTransmission(address); + size_t ret = this->wire_->write(write_buffer, write_count); if (ret != write_count) { ESP_LOGV(TAG, "TX failed"); return ERROR_UNKNOWN; } - status = wire_->endTransmission(read_count == 0); + status = this->wire_->endTransmission(read_count == 0); } if (status == 0 && read_count != 0) { - size_t ret2 = wire_->requestFrom(address, read_count, true); + size_t ret2 = this->wire_->requestFrom(address, read_count, true); if (ret2 != read_count) { ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", read_count, address, ret2); return ERROR_TIMEOUT; } for (size_t j = 0; j != read_count; j++) - read_buffer[j] = wire_->read(); + read_buffer[j] = this->wire_->read(); } // Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266 if (status == 0) @@ -154,6 +154,19 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_UNKNOWN; } +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ArduinoI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + this->frequency_ = frequency; + if (this->initialized_) { + this->wire_->setClock(this->frequency_); + } + return ERROR_OK; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf @@ -167,15 +180,15 @@ void ArduinoI2CBus::recover_() { const auto half_period_usec = 1000000 / 100000 / 2; // Activate input and pull up resistor for the SCL pin. - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT // This should make the signal on the line HIGH. If SCL is pulled low // on the I2C bus however, then some device is interfering with the SCL // line. In that case, the I2C bus cannot be recovered. delayMicroseconds(half_period_usec); - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -189,18 +202,18 @@ void ArduinoI2CBus::recover_() { // Make sure that switching to output mode will make SCL low, just in // case other code has setup the pin for a HIGH signal. - digitalWrite(scl_pin_, LOW); // NOLINT + digitalWrite(this->scl_pin_, LOW); // NOLINT delayMicroseconds(half_period_usec); for (auto i = 0; i < 9; i++) { // Release pull up resistor and switch to output to make the signal LOW. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, OUTPUT); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, OUTPUT); // NOLINT delayMicroseconds(half_period_usec); // Release output and activate pull up resistor to make the signal HIGH. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT delayMicroseconds(half_period_usec); // When SCL is kept LOW at this point, we might be looking at a device @@ -211,28 +224,28 @@ void ArduinoI2CBus::recover_() { // should recover in a few ms or less else not likely to recovery at // all. auto wait = 250; - while (wait-- && digitalRead(scl_pin_) == LOW) { // NOLINT + while (wait-- && digitalRead(this->scl_pin_) == LOW) { // NOLINT App.feed_wdt(); delayMicroseconds(half_period_usec * 2); } - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } // Activate input and pull resistor for the SDA pin, so we can verify // that SDA is pulled HIGH in the following step. - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT - digitalWrite(sda_pin_, LOW); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT + digitalWrite(this->sda_pin_, LOW); // NOLINT // By now, any stuck device ought to have sent all remaining bits of its // transaction, meaning that it should have freed up the SDA line, resulting // in SDA being pulled up. - if (digitalRead(sda_pin_) == LOW) { // NOLINT + if (digitalRead(this->sda_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -248,8 +261,8 @@ void ArduinoI2CBus::recover_() { // SCL and SDA are already high at this point, so we can generate a START // condition by making the SDA signal LOW. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, OUTPUT); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, OUTPUT); // NOLINT // From the specification: // "A START condition immediately followed by a STOP condition (void @@ -258,10 +271,10 @@ void ArduinoI2CBus::recover_() { // Finally, we'll bring the I2C bus into a starting state by generating // a STOP condition. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index 71e91e770bb..9feea9f7ffa 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -22,13 +22,17 @@ class ArduinoI2CBus final : public InternalI2CBus, public Component { size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } - void set_scan(bool scan) { scan_ = scan; } - void set_sda_pin(uint8_t sda_pin) { sda_pin_ = sda_pin; } - void set_scl_pin(uint8_t scl_pin) { scl_pin_ = scl_pin; } - void set_frequency(uint32_t frequency) { frequency_ = frequency; } - void set_timeout(uint32_t timeout) { timeout_ = timeout; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } + void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } int get_port() const override { return 0; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index b13b525abbb..931bcfa0889 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -32,8 +32,8 @@ void IDFI2CBus::setup() { i2c_master_bus_config_t bus_conf{}; memset(&bus_conf, 0, sizeof(bus_conf)); - bus_conf.sda_io_num = gpio_num_t(sda_pin_); - bus_conf.scl_io_num = gpio_num_t(scl_pin_); + bus_conf.sda_io_num = gpio_num_t(this->sda_pin_); + bus_conf.scl_io_num = gpio_num_t(this->scl_pin_); bus_conf.glitch_ignore_cnt = 7; #if SOC_LP_I2C_SUPPORTED if (this->lp_mode_) { @@ -59,7 +59,7 @@ void IDFI2CBus::setup() { } #endif bus_conf.i2c_port = this->port_; - bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_; + bus_conf.flags.enable_internal_pullup = this->sda_pullup_enabled_ || this->scl_pullup_enabled_; esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_); if (err != ESP_OK) { ESP_LOGW(TAG, "i2c_new_master_bus failed: %s", esp_err_to_name(err)); @@ -67,18 +67,21 @@ void IDFI2CBus::setup() { return; } - i2c_device_config_t dev_conf{}; - memset(&dev_conf, 0, sizeof(dev_conf)); - dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; - dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; - dev_conf.scl_speed_hz = this->frequency_; - dev_conf.scl_wait_us = this->timeout_; - err = i2c_master_bus_add_device(this->bus_, &dev_conf, &this->dev_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + this->dev_ = this->add_device_(this->frequency_); + if (this->dev_ == nullptr) { this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + this->add_frequency(this->frequency_); + for (auto &device : this->devices_) { + device.dev = device.frequency == this->frequency_ ? this->dev_ : this->add_device_(device.frequency); + if (device.dev == nullptr) { + this->mark_failed(); + return; + } + } +#endif this->initialized_ = true; @@ -88,6 +91,21 @@ void IDFI2CBus::setup() { } } +i2c_master_dev_handle_t IDFI2CBus::add_device_(uint32_t frequency) { + i2c_device_config_t dev_conf{}; + dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; + dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; + dev_conf.scl_speed_hz = frequency; + dev_conf.scl_wait_us = this->timeout_; + i2c_master_dev_handle_t dev{}; + const esp_err_t err = i2c_master_bus_add_device(this->bus_, &dev_conf, &dev); + if (err != ESP_OK) { + ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + return nullptr; + } + return dev; +} + void IDFI2CBus::dump_config() { ESP_LOGCONFIG(TAG, "I2C Bus:"); ESP_LOGCONFIG(TAG, @@ -95,7 +113,7 @@ void IDFI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %" PRIu32 " Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 "us", this->timeout_); } switch (this->recovery_result_) { @@ -111,10 +129,10 @@ void IDFI2CBus::dump_config() { } if (this->scan_) { ESP_LOGCONFIG(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGCONFIG(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first); } else { @@ -129,7 +147,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s size_t read_count) { // logging is only enabled with v level, if warnings are shown the caller // should log them - if (!initialized_) { + if (!this->initialized_) { ESP_LOGW(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -198,14 +216,44 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s return ERROR_OK; } +#ifdef I2C_PORT_FREQUENCY_COUNT +void IDFI2CBus::add_frequency(uint32_t frequency) { + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + return; + } + } + this->devices_.push_back({frequency, nullptr}); +} + +ErrorCode IDFI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + if (!this->initialized_) { + this->frequency_ = frequency; + return ERROR_OK; + } + // Every handle exists since setup(); a frequency nobody registered has none + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + this->dev_ = device.dev; + this->frequency_ = frequency; + return ERROR_OK; + } + } + return ERROR_UNKNOWN; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf void IDFI2CBus::recover_() { ESP_LOGI(TAG, "Performing bus recovery"); - const auto scl_pin = static_cast(scl_pin_); - const auto sda_pin = static_cast(sda_pin_); + const auto scl_pin = static_cast(this->scl_pin_); + const auto sda_pin = static_cast(this->sda_pin_); // For the upcoming operations, target for a 60kHz toggle frequency. // 1000kHz is the maximum frequency for I2C running in standard-mode, @@ -218,7 +266,7 @@ void IDFI2CBus::recover_() { // Configure SCL pin for open drain input/output, with a pull up resistor. gpio_set_level(scl_pin, 1); gpio_config_t scl_config{}; - scl_config.pin_bit_mask = 1ULL << scl_pin_; + scl_config.pin_bit_mask = 1ULL << this->scl_pin_; scl_config.mode = GPIO_MODE_INPUT_OUTPUT_OD; scl_config.pull_up_en = GPIO_PULLUP_ENABLE; scl_config.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -228,7 +276,7 @@ void IDFI2CBus::recover_() { // Configure SDA pin for open drain input/output, with a pull up resistor. gpio_set_level(sda_pin, 1); gpio_config_t sda_conf{}; - sda_conf.pin_bit_mask = 1ULL << sda_pin_; + sda_conf.pin_bit_mask = 1ULL << this->sda_pin_; sda_conf.mode = GPIO_MODE_INPUT_OUTPUT_OD; sda_conf.pull_up_en = GPIO_PULLUP_ENABLE; sda_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -240,7 +288,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -271,7 +319,7 @@ void IDFI2CBus::recover_() { } if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } @@ -281,7 +329,7 @@ void IDFI2CBus::recover_() { // in SDA being pulled up. if (gpio_get_level(sda_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -308,7 +356,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); gpio_set_level(sda_pin, 1); - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 92e96f649b6..b9d8824d25e 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -34,13 +34,28 @@ class IDFI2CBus final : public InternalI2CBus, public Component { #endif int get_port() const override { return this->port_; } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Register a frequency used on the bus so setup() creates its device handle + void add_frequency(uint32_t frequency); + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); + i2c_master_dev_handle_t add_device_(uint32_t frequency); RecoveryCode recovery_result_{}; protected: i2c_master_dev_handle_t dev_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + // One handle per frequency used on the bus, registered at code generation + struct FrequencyDevice { + uint32_t frequency; + i2c_master_dev_handle_t dev; + }; + StaticVector devices_; +#endif i2c_master_bus_handle_t bus_{}; i2c_port_t port_{}; uint8_t sda_pin_{}; diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8064016a520..086baab26ca 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -21,7 +21,12 @@ class HostI2CBus final : public I2CBus, public Component { void set_device(const std::string &device) { this->device_ = device; } void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + // The kernel driver owns the clock, so switch_frequency keeps the base default + uint32_t get_frequency() const override { return this->frequency_; } +#endif const std::string &get_device() const { return this->device_; } diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 4145cd58592..ee6e66893e0 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -120,15 +120,34 @@ ErrorCode ZephyrI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer return ERROR_OK; } -void ZephyrI2CBus::set_frequency(uint32_t frequency) { - this->dev_config_ &= ~I2C_SPEED_MASK; - if (frequency >= 400000) { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_FAST); - } else { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_STANDARD); - } +static uint32_t speed_config(uint32_t dev_config, uint32_t frequency) { + const uint32_t speed = frequency >= 400000 ? I2C_SPEED_FAST : I2C_SPEED_STANDARD; + return (dev_config & ~I2C_SPEED_MASK) | I2C_SPEED_SET(speed); } +void ZephyrI2CBus::set_frequency(uint32_t frequency) { +#ifdef I2C_PORT_FREQUENCY_COUNT + this->frequency_ = frequency; +#endif + this->dev_config_ = speed_config(this->dev_config_, frequency); +} + +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ZephyrI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + const uint32_t dev_config = speed_config(this->dev_config_, frequency); + // Until ready, setup() applies dev_config_ + if (this->is_ready() && i2c_configure(this->i2c_dev_, dev_config) < 0) { + return ERROR_UNKNOWN; + } + this->dev_config_ = dev_config; + this->frequency_ = frequency; + return ERROR_OK; +} +#endif + } // namespace esphome::i2c #endif diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3ada1e0a0f2..413d78d6a38 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -22,6 +22,10 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } void set_frequency(uint32_t frequency); +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif int get_port() const override { return 0; } @@ -31,6 +35,9 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { uint8_t sda_pin_{}; uint8_t scl_pin_{}; uint32_t dev_config_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{}; +#endif }; } // namespace esphome::i2c diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index a1945e97c52..368e34f3250 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -1,7 +1,14 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_CHANNELS, CONF_ID +from esphome.const import ( + CONF_CHANNEL, + CONF_CHANNELS, + CONF_FREQUENCY, + CONF_I2C_ID, + CONF_ID, +) +from esphome.core import CORE, ID from esphome.types import ConfigType CODEOWNERS = ["@andreashergert1984"] @@ -16,14 +23,51 @@ TCA9548AChannel = tca9548a_ns.class_("TCA9548AChannel", i2c.I2CBus) MULTI_CONF = True CONF_BUS_ID = "bus_id" +# Sizes the root bus's frequency table; only emitted when a frequency is set +_request_port_frequency_slot = cg.slot_counter("I2C_PORT_FREQUENCY_COUNT") + + +def _root_bus_id(bus_id: ID) -> ID: + """The hardware bus behind a port, through any multiplexers in between.""" + parents = { + str(chan[CONF_BUS_ID]): mux[CONF_I2C_ID] + for mux in CORE.config.get(DOMAIN, []) + for chan in mux.get(CONF_CHANNELS, []) + } + while (parent := parents.get(str(bus_id))) is not None: + bus_id = parent + return bus_id + + +def _declared_bus_type(bus_id: ID) -> cg.MockObjClass | None: + """The class the i2c component declared for a bus, or None for an external bus.""" + for bus in CORE.config.get(i2c.DOMAIN, []): + if bus[CONF_ID].id == bus_id.id: + return bus[CONF_ID].type + return None + + +async def _add_frequency(root_bus_id: ID, frequency: int) -> None: + """Count the frequency against the root bus; the built-in ESP32 bus also + creates its device handle in setup().""" + _request_port_frequency_slot(str(root_bus_id)) + if _declared_bus_type(root_bus_id) is i2c.IDFI2CBus: + root = await cg.get_variable(root_bus_id) + cg.add(root.add_frequency(frequency)) + + +_FREQUENCY = cv.All(cv.frequency, cv.Range(min=0, min_included=False)) + CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(TCA9548AComponent), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, cv.Optional(CONF_CHANNELS, default=[]): cv.ensure_list( { cv.Required(CONF_BUS_ID): cv.declare_id(TCA9548AChannel), cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=7), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, } ), } @@ -37,8 +81,15 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + root_bus_id = _root_bus_id(config[CONF_I2C_ID]) + if (frequency := config.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(var.set_frequency(int(frequency))) for conf in config[CONF_CHANNELS]: chan = cg.new_Pvariable(conf[CONF_BUS_ID]) cg.add(chan.set_parent(var)) cg.add(chan.set_channel(conf[CONF_CHANNEL])) + if (frequency := conf.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(chan.set_frequency(int(frequency))) diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index f10578c97c2..ac33ca8dedb 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -1,4 +1,6 @@ #include "tca9548a.h" + +#include #include "esphome/core/log.h" namespace esphome::tca9548a { @@ -7,20 +9,111 @@ ESPHOME_LOG_TAG(TAG, "tca9548a"); i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) { - auto err = this->parent_->switch_to_channel(channel_); +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t select_frequency = this->select_frequency_(); + if (select_frequency != 0) { + return this->write_readv_at_port_frequency_(select_frequency, address, write_buffer, write_count, read_buffer, + read_count); + } +#endif + auto err = this->parent_->switch_to_channel(this->channel_); if (err != i2c::ERROR_OK) return err; err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); this->parent_->disable_all_channels(); return err; } + +#ifdef I2C_PORT_FREQUENCY_COUNT +// The multiplexer's own frequency, else the slower of the bus and the port +uint32_t TCA9548AChannel::select_frequency_() const { + if (this->parent_->frequency_ != 0) + return this->parent_->frequency_; + if (this->frequency_ == 0) + return 0; + // A bus that does not report its frequency gets the port's, and fails the switch + const uint32_t bus_frequency = this->parent_->bus_->get_frequency(); + return bus_frequency != 0 ? std::min(this->frequency_, bus_frequency) : this->frequency_; +} + +// The port frequency, else the multiplexer's +uint32_t TCA9548AChannel::transfer_frequency_() const { + return this->frequency_ != 0 ? this->frequency_ : this->parent_->frequency_; +} + +i2c::ErrorCode TCA9548AChannel::write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, + const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + const uint32_t original_frequency = this->parent_->bus_->get_frequency(); + // A failed switch leaves the bus as it was + auto err = this->switch_bus_(select_frequency); + if (err != i2c::ERROR_OK) + return err; + + err = this->parent_->switch_to_channel(this->channel_); + if (err == i2c::ERROR_OK) { + err = this->transfer_(select_frequency, address, write_buffer, write_count, read_buffer, read_count); + this->parent_->disable_all_channels(); + } + const i2c::ErrorCode restored = this->switch_bus_(original_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Transfer at the port frequency, back to the select frequency for the deselect +i2c::ErrorCode TCA9548AChannel::transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count) { + const uint32_t transfer_frequency = this->transfer_frequency_(); + if (transfer_frequency == select_frequency) { + return this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + auto err = this->switch_bus_(transfer_frequency); + if (err == i2c::ERROR_OK) { + err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + const i2c::ErrorCode restored = this->switch_bus_(select_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Forward to the shared upstream bus +i2c::ErrorCode TCA9548AChannel::switch_frequency(uint32_t frequency) { + return this->parent_->bus_->switch_frequency(frequency); +} + +i2c::ErrorCode TCA9548AChannel::switch_bus_(uint32_t frequency) { + const i2c::ErrorCode err = this->switch_frequency(frequency); + if (err != i2c::ERROR_OK) { + this->parent_->status_set_error(LOG_STR("Failed to switch the bus frequency")); + } + return err; +} + +uint32_t TCA9548AChannel::get_frequency() const { return this->parent_->bus_->get_frequency(); } +#endif + void TCA9548AComponent::setup() { uint8_t status = 0; - if (this->read(&status, 1) != i2c::ERROR_OK) { +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t original_frequency = this->bus_->get_frequency(); + if (this->frequency_ != 0 && this->bus_->switch_frequency(this->frequency_) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif + const i2c::ErrorCode err = this->read(&status, 1); +#ifdef I2C_PORT_FREQUENCY_COUNT + const bool restored = this->frequency_ == 0 || this->bus_->switch_frequency(original_frequency) == i2c::ERROR_OK; +#endif + if (err != i2c::ERROR_OK) { ESP_LOGE(TAG, "TCA9548A failed"); this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + if (!restored) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif ESP_LOGD(TAG, "Channels currently open: %d", status); } void TCA9548AComponent::dump_config() { @@ -32,14 +125,13 @@ i2c::ErrorCode TCA9548AComponent::switch_to_channel(uint8_t channel) { if (this->is_failed()) return i2c::ERROR_NOT_INITIALIZED; - uint8_t channel_val = 1 << channel; + const uint8_t channel_val = 1 << channel; return this->write(&channel_val, 1); } void TCA9548AComponent::disable_all_channels() { if (this->write(&TCA9548A_DISABLE_CHANNELS_COMMAND, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Failed to disable all channels."); - this->status_set_error(); // couldn't disable channels, set error status + this->status_set_error(LOG_STR("Failed to disable all channels.")); } } diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index a98c226d322..5b0d8a0a769 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -10,13 +10,30 @@ static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; class TCA9548AChannel final : public i2c::I2CBus { public: - void set_channel(uint8_t channel) { channel_ = channel; } - void set_parent(TCA9548AComponent *parent) { parent_ = parent; } + void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_parent(TCA9548AComponent *parent) { this->parent_ = parent; } i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) override; +#ifdef I2C_PORT_FREQUENCY_COUNT + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + i2c::ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override; +#endif + protected: +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t select_frequency_() const; + uint32_t transfer_frequency_() const; + i2c::ErrorCode write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count); + i2c::ErrorCode transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count); + /// Switch the upstream bus, flagging the multiplexer on failure + i2c::ErrorCode switch_bus_(uint32_t frequency); + uint32_t frequency_{0}; +#endif uint8_t channel_; TCA9548AComponent *parent_; }; @@ -30,8 +47,15 @@ class TCA9548AComponent final : public Component, public i2c::I2CDevice { i2c::ErrorCode switch_to_channel(uint8_t channel); void disable_all_channels(); +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Addressing frequency, and the default for the ports + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#endif protected: friend class TCA9548AChannel; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{0}; +#endif }; } // namespace esphome::tca9548a diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 731c0d20171..d4aca85962f 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -137,6 +137,7 @@ #define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 diff --git a/tests/components/tca9548a/common.yaml b/tests/components/tca9548a/common.yaml index bfaf1e26fbb..eea1b9964db 100644 --- a/tests/components/tca9548a/common.yaml +++ b/tests/components/tca9548a/common.yaml @@ -2,9 +2,13 @@ tca9548a: - id: multiplex0 i2c_id: i2c_bus address: 0x70 + frequency: 400 kHz channels: - bus_id: multiplex0_chan0 channel: 0 + - bus_id: multiplex0_chan1 + channel: 1 + frequency: 1 MHz - id: multiplex1 i2c_id: i2c_bus address: 0x71 diff --git a/tests/components/tca9548a/test.nrf52-adafruit.yaml b/tests/components/tca9548a/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..a6641791371 --- /dev/null +++ b/tests/components/tca9548a/test.nrf52-adafruit.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + tca9548a: !include common.yaml From 78d3b9f5a1f255154f45ec9de26ac80c1a9f2296 Mon Sep 17 00:00:00 2001 From: Anunay Kulshrestha Date: Sat, 10 Oct 2026 21:17:01 +0000 Subject: [PATCH 18/97] [nrf52] Fix BLE OTA/log device discovery & transport selection on macOS (#17101) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/nrf52/__init__.py | 8 +- esphome/components/nrf52/ble_logger.py | 23 ++++- esphome/components/nrf52/ota.py | 21 ++--- tests/unit_tests/test_nrf52_ota.py | 121 +++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 tests/unit_tests/test_nrf52_ota.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index de9dc6401e4..f2a78259973 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -732,13 +732,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool: return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths - from .ble_logger import is_mac_address + from .ble_logger import is_ble_address from .ota import smpmgr_scan, smpmgr_upload if host == "BLE": mcumgr_device = asyncio.run(smpmgr_scan(CORE.name)) - if is_mac_address(host): + if is_ble_address(host): mcumgr_device = host if mcumgr_device: @@ -753,7 +753,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: def show_logs(config: ConfigType, args, devices: list[str]) -> bool: address = devices[0] - from .ble_logger import is_mac_address, logger_connect, logger_scan + from .ble_logger import is_ble_address, logger_connect, logger_scan if devices[0] == "BLE": ble_device = asyncio.run(logger_scan(CORE.name)) @@ -762,7 +762,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: else: return True - if is_mac_address(address): + if is_ble_address(address): asyncio.run(logger_connect(address)) return True return False diff --git a/esphome/components/nrf52/ble_logger.py b/esphome/components/nrf52/ble_logger.py index f74a49ea897..5edcddfd326 100644 --- a/esphome/components/nrf52/ble_logger.py +++ b/esphome/components/nrf52/ble_logger.py @@ -4,6 +4,7 @@ import re from typing import Final from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.backends.scanner import AdvertisementData from bleak.exc import ( BleakCharacteristicNotFoundError, BleakDBusError, @@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" MAC_ADDRESS_PATTERN: Final = re.compile( r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE ) +# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address +UUID_PATTERN: Final = re.compile( + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + flags=re.IGNORECASE, +) -def is_mac_address(value: str) -> bool: - return MAC_ADDRESS_PATTERN.match(value) +def is_ble_address(value: str) -> bool: + return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value)) + + +def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool: + # device.name can be a stale cached name on macOS; adv.local_name is what is on the air now + return name in (device.name, adv.local_name) + + +async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None: + return await BleakScanner.find_device_by_filter( + lambda device, adv: _name_matches(device, adv, name), timeout=timeout + ) async def logger_scan(name: str) -> BLEDevice | None: _LOGGER.info("Scanning bluetooth for %s...", name) - device = await BleakScanner.find_device_by_name(name) + device = await find_device_by_name(name) if not device: _LOGGER.error("%s Bluetooth LE device was not found!", name) return device diff --git a/esphome/components/nrf52/ota.py b/esphome/components/nrf52/ota.py index cafeda64785..9115e218747 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -4,7 +4,6 @@ import json import logging from pathlib import Path -from bleak import BleakScanner from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient @@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport from esphome.core import EsphomeError from esphome.espota2 import ProgressBar -from .ble_logger import is_mac_address +from .ble_logger import find_device_by_name, is_ble_address -SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84" BLE_SCAN_TIMEOUT = 10.0 # seconds RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute @@ -45,12 +43,12 @@ def _json_state(o: object) -> object: async def smpmgr_scan(name: str) -> str: _LOGGER.info("Scanning bluetooth for %s...", name) - for device in await BleakScanner.discover( - timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID] - ): - if device.name == name: - return device.address - raise EsphomeError(f"BLE device {name} with OTA service not found") + # No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on. + # The SMP service is checked on connect. + device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT) + if device is None: + raise EsphomeError(f"BLE device {name} not found") + return device.address async def smpmgr_upload(device: str, firmware: Path) -> None: @@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes: async def _smpmgr_upload(device: str, firmware: Path) -> None: image_tlv_sha256 = _get_image_tlv_sha256(firmware) - if is_mac_address(device): + if is_ble_address(device): smp_client = SMPClient(SMPBLETransport(), device) else: smp_client = SMPClient(SMPSerialTransport(), device) @@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: ) from exc raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: - raise EsphomeError(f"Connection error with {device}") from exc + # Reached when a device with the right name has no SMP service, among other causes + raise EsphomeError(f"Connection error with {device}: {exc}") from exc _LOGGER.info("Connected %s...", device) try: diff --git a/tests/unit_tests/test_nrf52_ota.py b/tests/unit_tests/test_nrf52_ota.py new file mode 100644 index 00000000000..c0b5e202f70 --- /dev/null +++ b/tests/unit_tests/test_nrf52_ota.py @@ -0,0 +1,121 @@ +"""Tests for nRF52 BLE device discovery and OTA transport selection.""" + +import asyncio +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from esphome.components.nrf52 import ble_logger, ota, show_logs +from esphome.core import EsphomeError + +MAC = "AA:BB:CC:DD:EE:FF" +# macOS/CoreBluetooth identifies peripherals by UUID instead of MAC address +UUID = "0FA1B2C3-D4E5-F607-1829-3A4B5C6D7E8F" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (MAC, True), + (MAC.lower(), True), + (UUID, True), + ("/dev/ttyACM0", False), + ("COM3", False), + ], +) +def test_is_ble_address(value: str, expected: bool) -> None: + assert ble_logger.is_ble_address(value) is expected + + +def _scan_result( + monkeypatch, devices: list[tuple[str, str | None, str | None]] +) -> AsyncMock: + """Stub the bleak scan so the filter is applied to the given (address, name, local_name) devices.""" + + async def find_device_by_filter(filterfunc, timeout): + for address, name, local_name in devices: + device = SimpleNamespace(address=address, name=name) + if filterfunc(device, SimpleNamespace(local_name=local_name)): + return device + return None + + mock = AsyncMock(side_effect=find_device_by_filter) + monkeypatch.setattr(ble_logger.BleakScanner, "find_device_by_filter", mock) + return mock + + +def test_scan_matches_advertised_local_name(monkeypatch) -> None: + """On macOS device.name is cached from an earlier connection; the live name is adv.local_name.""" + _scan_result(monkeypatch, [(UUID, "stale-old-name", "my-device")]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == UUID + + +def test_scan_matches_device_name(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "my-device", None)]) + assert asyncio.run(ota.smpmgr_scan("my-device")) == MAC + + +def test_scan_raises_when_no_device_found(monkeypatch) -> None: + _scan_result(monkeypatch, [(MAC, "someone-else", "someone-else")]) + with pytest.raises(EsphomeError, match="not found"): + asyncio.run(ota.smpmgr_scan("my-device")) + + +@pytest.mark.parametrize( + ("device", "transport"), + [("/dev/ttyACM0", "serial"), ("COM3", "serial"), (MAC, "ble"), (UUID, "ble")], +) +def test_upload_transport(monkeypatch, device: str, transport: str) -> None: + """Serial ports go to the serial transport; MAC addresses and macOS UUIDs to BLE.""" + captured: dict = {} + + class FakeClient: + def __init__(self, transport, address): + captured["transport"] = transport + captured["address"] = address + + async def connect(self) -> None: + pass + + async def disconnect(self) -> None: + pass + + monkeypatch.setattr(ota, "_get_image_tlv_sha256", lambda firmware: b"") + monkeypatch.setattr(ota, "_smpmgr_upload_connected", AsyncMock()) + monkeypatch.setattr(ota, "SMPSerialTransport", lambda: "serial") + monkeypatch.setattr(ota, "SMPBLETransport", lambda: "ble") + monkeypatch.setattr(ota, "SMPClient", FakeClient) + + asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"))) # pylint: disable=protected-access + assert captured == {"transport": transport, "address": device} + + +@pytest.mark.parametrize( + ("device", "connected_to"), + [("BLE", UUID), (MAC, MAC), (UUID, UUID), ("my-device.local", None)], +) +def test_show_logs_routing(monkeypatch, device: str, connected_to: str | None) -> None: + """A scanned device, a MAC or a UUID is handed to the BLE logger; anything else falls through.""" + scan = AsyncMock(return_value=SimpleNamespace(address=UUID)) + connect = AsyncMock(return_value=0) + monkeypatch.setattr(ble_logger, "logger_scan", scan) + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + handled = show_logs(config={}, args=None, devices=[device]) + + assert handled is (connected_to is not None) + if connected_to is None: + connect.assert_not_called() + else: + connect.assert_awaited_once_with(connected_to) + + +def test_show_logs_returns_when_the_scan_finds_nothing(monkeypatch) -> None: + monkeypatch.setattr(ble_logger, "logger_scan", AsyncMock(return_value=None)) + connect = AsyncMock() + monkeypatch.setattr(ble_logger, "logger_connect", connect) + + assert show_logs(config={}, args=None, devices=["BLE"]) is True + connect.assert_not_called() From 642c3b7a9ecf333b25ce71b39d28adede90144f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 11:43:21 -1000 Subject: [PATCH 19/97] [const] Add the milliampere hour unit to the shared constants (#20495) --- esphome/components/const/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index e93d508a3d8..eb999a05ff4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -73,3 +73,4 @@ KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" UNIT_COUNTS = "#" +UNIT_MILLIAMPERE_HOUR = "mAh" From 5f43b8168eac44bfef1d5490e0e5951fafc01d30 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:00:39 -1000 Subject: [PATCH 20/97] Bump bundled esphome-device-builder to 1.23.1 (#20497) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 9edbd6e4f9b..c9aa3dba62c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 RUN \ platformio settings set enable_telemetry No \ From 924d7364ba9c419b5dbf1c027a256291679f706e Mon Sep 17 00:00:00 2001 From: Paul Traina Date: Sat, 10 Oct 2026 16:22:56 -0700 Subject: [PATCH 21/97] [mipi_spi] ESP32-2432S028-9342 panel is RGB, not BGR (#19419) --- esphome/components/mipi_spi/models/cyd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index 0a35cb4feea..f8a96739608 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,3 +1,4 @@ +from esphome.components.mipi import MODE_RGB from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER from .ili import GC9A01A, ILI9341, ILI9342, ST7789V @@ -21,8 +22,10 @@ ST7789V.extend( # fmt: off ILI9342.extend( - # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller + # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller. + # The panel on this board is wired RGB, not the BGR default. "ESP32-2432S028-9342", + color_order=MODE_RGB, data_rate="40MHz", cs_pin={"number": 15, "ignore_strapping_warning": True}, dc_pin={"number": 2, "ignore_strapping_warning": True}, From 1763adf526b338b52d775140b9128e253bcef0e6 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:24:13 +0200 Subject: [PATCH 22/97] [zigbee] add support for SED on esp32 (#20487) --- esphome/components/zigbee/__init__.py | 28 +++++++++++++--- esphome/components/zigbee/const.py | 1 + esphome/components/zigbee/const_zephyr.py | 1 - esphome/components/zigbee/zigbee_esp32.cpp | 37 ++++++++++++++-------- esphome/components/zigbee/zigbee_esp32.h | 5 ++- esphome/components/zigbee/zigbee_esp32.py | 7 ++++ esphome/components/zigbee/zigbee_zephyr.py | 2 +- tests/components/zigbee/common_esp32.yaml | 5 +-- 8 files changed, 63 insertions(+), 23 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 0dc3b4b6f71..d01eddb9ff7 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -13,7 +13,14 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S31, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START +from esphome.const import ( + CONF_ID, + CONF_INTERNAL, + CONF_MODEL, + CONF_NAME, + CONF_ON_START, + CONF_POLLING_INTERVAL, +) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType @@ -25,6 +32,7 @@ from .const import ( CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, @@ -35,7 +43,6 @@ from .const import ( from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER_ZEPHYR, - CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, ) @@ -128,8 +135,11 @@ NUMBER_SCHEMA = ( def _validate_router_sleepy(config: ConfigType) -> ConfigType: - if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY): - raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_ROUTER): + if config.get(CONF_SLEEPY): + raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_POLLING_INTERVAL): + raise cv.Invalid(f"{CONF_POLLING_INTERVAL} is only valid for end devices.") return config @@ -160,9 +170,17 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), - cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All( + cv.Optional(CONF_SLEEPY, default=False): cv.All( cv.boolean, ), + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.requires_component(ESP32_DOMAIN), + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriodMilliseconds(milliseconds=200), + max=cv.TimePeriodMinutes(minutes=60), # 1 hour + ), + ), } ).extend(cv.COMPONENT_SCHEMA), _validate_router_sleepy, diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index 86cac665b43..aad2b5c0e39 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -72,6 +72,7 @@ CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_SLEEPY = "sleepy" CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN diff --git a/esphome/components/zigbee/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index bf8e8287c4b..7177b9fc798 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -4,7 +4,6 @@ CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" -CONF_SLEEPY = "sleepy" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 89417e748d2..7c197133c2b 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -319,7 +319,7 @@ void ZigbeeComponent::setup() { #else esp_zigbee_zed_config_s zb_zed_cfg = { .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, + .keep_alive = this->keep_alive_, }; device_config.zed_config = zb_zed_cfg; #endif @@ -339,6 +339,8 @@ void ZigbeeComponent::setup() { #ifdef CONFIG_ZB_ZCZR ezb_bdb_set_router_rejoin_required(true); +#else + ezb_nwk_set_rx_on_when_idle(!this->sleepy_); // if sleepy, disable RX when idle #endif ezb_aps_secur_enable_distributed_security(false); @@ -419,18 +421,27 @@ void ZigbeeComponent::loop() { void ZigbeeComponent::dump_config() { if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n" - " Device is joined to the network: %s\n" - " Current channel: %d\n" - " Short addr: 0x%04X\n" - " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), - ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + ESP_LOGCONFIG( + TAG, + "Zigbee\n" + " Model: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); esp_zigbee_lock_release(); } else { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 093ae959390..28ea64c8cea 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -20,7 +20,6 @@ namespace esphome::zigbee { /* Zigbee configuration */ -static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; @@ -43,6 +42,8 @@ class ZigbeeComponent final : public Component { void loop() override; void dump_config() override; + void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } + void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); @@ -73,6 +74,8 @@ class ZigbeeComponent final : public Component { bool is_joined() { return this->joined_; } protected: + bool sleepy_{false}; + uint32_t keep_alive_{3000}; struct { uint8_t *model; uint8_t *manufacturer; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 103e901b13b..68136d57192 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -24,6 +24,7 @@ from esphome.const import ( CONF_MAX_LENGTH, CONF_MODEL, CONF_NAME, + CONF_POLLING_INTERVAL, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, @@ -45,6 +46,7 @@ from .const import ( CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, POWER_SOURCE, @@ -412,6 +414,11 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config.get(CONF_SLEEPY): + cg.add(var.set_sleepy(True)) + if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: + cg.add(var.set_keep_alive(keep_alive)) + cg.add( var.set_basic_cluster( config[CONF_MODEL], diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 3f863140e69..8197796b6db 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -24,6 +24,7 @@ from .const import ( BACNET_UNITS, CONF_POWER_SOURCE, CONF_ROUTER, + CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, POWER_SOURCE, @@ -35,7 +36,6 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_SLEEPY, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 3d19180c064..84b79bb84f0 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -58,8 +58,9 @@ switch: zigbee: model: zigbee_test - router: false - power_source: MAINS_SINGLE_PHASE + sleepy: true + polling_interval: 2s + power_source: BATTERY on_join: then: - logger.log: "Joined network" From 3be8c5b6b8f7bd769d1f6a81963021668a1661d1 Mon Sep 17 00:00:00 2001 From: fakuivan Date: Sat, 10 Oct 2026 22:07:36 -0300 Subject: [PATCH 23/97] [canbus] Fix high frame rates causing crashes due to unbounded iteration (#14481) 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/canbus/__init__.py | 20 ++++++++++++++++---- esphome/components/canbus/canbus.cpp | 22 +++++++++++++++------- esphome/components/canbus/canbus.h | 6 ++++-- tests/components/canbus/common.yaml | 1 + 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 51bda0c75df..23dc41d5092 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -20,6 +20,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request" CONF_CANBUS_ID = "canbus_id" CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" +CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" +DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h +DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h def validate_id(config: ConfigType) -> ConfigType: @@ -87,8 +90,13 @@ CANBUS_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(CanbusComponent), cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True), + cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum( + CAN_SPEEDS, upper=True + ), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, + cv.Optional( + CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP + ): cv.positive_not_null_int, cv.Optional(CONF_ON_FRAME): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger), @@ -109,9 +117,13 @@ CANBUS_SCHEMA.add_extra(validate_id) async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - cg.add(var.set_can_id([config[CONF_CAN_ID]])) - cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) - cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]])) + cg.add(var.set_can_id(config[CONF_CAN_ID])) + if config[CONF_USE_EXTENDED_ID]: + cg.add(var.set_use_extended_id(True)) + if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE: + cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate])) + if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP: + cg.add(var.set_max_frames_per_loop(max_frames)) for conf in config.get(CONF_ON_FRAME, []): can_id = conf[CONF_CAN_ID] diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 828eb77d1ca..88eed2246fd 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -66,16 +66,18 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { void Canbus::loop() { struct CanFrame can_message; - // read all messages until queue is empty - int message_counter = 0; - while (this->read_message(&can_message) == canbus::ERROR_OK) { - message_counter++; + uint32_t message_counter = 0; + for (; message_counter < this->max_frames_per_loop_; message_counter++) { + if (this->read_message(&can_message) != canbus::ERROR_OK) { + break; + } + if (can_message.use_extended_id) { - ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter, + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1, can_message.can_id, can_message.can_data_length_code); } else { - ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id, - can_message.can_data_length_code); + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1, + can_message.can_id, can_message.can_data_length_code); } std::vector data; @@ -99,6 +101,12 @@ void Canbus::loop() { } } } + + if (message_counter == this->max_frames_per_loop_) { + ESP_LOGW(TAG, + "Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped", + this->max_frames_per_loop_); + } } } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 8685c026b0b..432eb77e13d 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -82,6 +82,7 @@ class Canbus : public Component { void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } + void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } void add_trigger(CanbusTrigger *trigger); /** @@ -100,8 +101,9 @@ class Canbus : public Component { template friend class CanbusSendAction; std::vector triggers_{}; uint32_t can_id_; - bool use_extended_id_; - CanSpeed bit_rate_; + uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py + CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py + bool use_extended_id_{false}; CallbackManager &data)> callback_manager_{}; diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 57ee25bfc16..14e0d6bf5a6 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -5,6 +5,7 @@ canbus: tx_pin: 5 can_id: 4 bit_rate: 50kbps + max_frames_per_loop: 100 on_frame: - can_id: 500 then: From 8af91c966304aed12af8d33374ec4e0afd5d5b70 Mon Sep 17 00:00:00 2001 From: Achilleas Kotsis Date: Sun, 11 Oct 2026 04:23:31 +0300 Subject: [PATCH 24/97] [feedback] Support for overshoot_duration (#13186) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/feedback/cover.py | 22 ++++++++++++++++ .../components/feedback/feedback_cover.cpp | 26 +++++++++++++++++-- esphome/components/feedback/feedback_cover.h | 9 +++++++ esphome/core/defines.h | 1 + tests/components/feedback/common.yaml | 13 ++++++++++ 5 files changed, 69 insertions(+), 2 deletions(-) diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 032d01c8e51..9fbda9a9737 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -24,6 +24,7 @@ CONF_HAS_BUILT_IN_ENDSTOP = "has_built_in_endstop" CONF_INFER_ENDSTOP_FROM_MOVEMENT = "infer_endstop_from_movement" CONF_DIRECTION_CHANGE_WAIT_TIME = "direction_change_wait_time" CONF_ACCELERATION_WAIT_TIME = "acceleration_wait_time" +CONF_OVERSHOOT_DURATION = "overshoot_duration" CONF_OBSTACLE_ROLLBACK = "obstacle_rollback" endstop_ns = cg.esphome_ns.namespace("feedback") @@ -82,6 +83,9 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( cv.Optional( CONF_ACCELERATION_WAIT_TIME, "0s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_OVERSHOOT_DURATION, "0s" + ): cv.positive_time_period_milliseconds, cv.Optional(CONF_OBSTACLE_ROLLBACK, default="10%"): cv.percentage, }, ) @@ -89,10 +93,25 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( ) +def validate_overshoot(config: ConfigType) -> ConfigType: + if not config[CONF_OVERSHOOT_DURATION].total_milliseconds: + return config + if config[CONF_HAS_BUILT_IN_ENDSTOP]: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect with {CONF_HAS_BUILT_IN_ENDSTOP}" + ) + if CONF_OPEN_ENDSTOP in config and CONF_CLOSE_ENDSTOP in config: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect when both endstop sensors are supplied" + ) + return config + + CONFIG_SCHEMA = cv.All( CONFIG_FEEDBACK_COVER_BASE_SCHEMA, cv.has_none_or_all_keys(CONF_OPEN_SENSOR, CONF_CLOSE_SENSOR), validate_infer_endstop, + validate_overshoot, ) @@ -160,4 +179,7 @@ async def to_code(config: ConfigType) -> None: var.set_direction_change_waittime(config[CONF_DIRECTION_CHANGE_WAIT_TIME]) ) cg.add(var.set_acceleration_wait_time(config[CONF_ACCELERATION_WAIT_TIME])) + if (overshoot := config[CONF_OVERSHOOT_DURATION]).total_milliseconds: + cg.add_define("USE_FEEDBACK_COVER_OVERSHOOT") + cg.add(var.set_overshoot_duration(overshoot)) cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK])) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index fb6c3929b04..8fa56bd370e 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -79,6 +79,11 @@ void FeedbackCover::dump_config() { if (this->acceleration_wait_time_) { ESP_LOGCONFIG(TAG, " Acceleration wait time: %.1fs", this->acceleration_wait_time_ / 1e3f); } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + if (this->overshoot_duration_) { + ESP_LOGCONFIG(TAG, " Overshoot duration: %.1fs", this->overshoot_duration_ / 1e3f); + } +#endif #ifdef USE_BINARY_SENSOR if (this->obstacle_rollback_ && (this->open_obstacle_ != nullptr || this->close_obstacle_ != nullptr)) { ESP_LOGCONFIG(TAG, " Obstacle rollback: %.1f%%", this->obstacle_rollback_ * 100); @@ -234,10 +239,22 @@ void FeedbackCover::loop() { // (stoping from endstop sensor is handled in callback) if (this->current_trigger_operation_ != COVER_OPERATION_IDLE) { if (this->is_at_target_()) { - if (this->has_built_in_endstop_ && - (this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED)) { + const bool at_end = this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED; + if (this->has_built_in_endstop_ && at_end) { // Don't trigger stop, let the cover stop by itself. this->set_current_operation_(COVER_OPERATION_IDLE, true); +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + } else if (at_end && this->overshoot_duration_) { + // The define is global, so a cover without the option still lands here with a zero duration + // Keep driving past the end position for a while so accumulated timing error is squeezed out + if (!this->start_overshoot_time_) { + this->start_overshoot_time_ = now; + } else if (now - this->start_overshoot_time_ > this->overshoot_duration_ || + now - this->start_dir_time_ > this->max_duration_) { + ESP_LOGD(TAG, "'%s' - Overshoot duration reached. Stopping cover.", this->name_.c_str()); + this->start_direction_(COVER_OPERATION_IDLE); + } +#endif } else { this->start_direction_(COVER_OPERATION_IDLE); } @@ -340,6 +357,11 @@ void FeedbackCover::start_direction_(CoverOperation dir) { binary_sensor::BinarySensor *obstacle{nullptr}; #endif +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + // Any new command ends a running overshoot + this->start_overshoot_time_ = 0; +#endif + switch (dir) { case COVER_OPERATION_IDLE: trig = &this->stop_trigger_; diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 3e4600acd2f..b641f6e7163 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -33,6 +33,9 @@ class FeedbackCover final : public cover::Cover, public Component { void set_has_built_in_endstop(bool value) { this->has_built_in_endstop_ = value; } void set_assumed_state(bool value) { this->assumed_state_ = value; } void set_max_duration(uint32_t max_duration) { this->max_duration_ = max_duration; } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + void set_overshoot_duration(uint32_t overshoot_duration) { this->overshoot_duration_ = overshoot_duration; } +#endif void set_obstacle_rollback(float obstacle_rollback) { this->obstacle_rollback_ = obstacle_rollback; } void set_update_interval(uint32_t interval) { this->update_interval_ = interval; } void set_infer_endstop(bool infer_endstop) { this->infer_endstop_ = infer_endstop; } @@ -67,6 +70,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t open_duration_{0}; uint32_t close_duration_{0}; uint32_t max_duration_{UINT32_MAX}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t overshoot_duration_{0}; +#endif optional direction_change_waittime_{}; uint32_t acceleration_wait_time_{0}; bool has_built_in_endstop_{false}; @@ -80,6 +86,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t last_recompute_time_{0}; uint32_t start_dir_time_{0}; uint32_t last_publish_time_{0}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t start_overshoot_time_{0}; +#endif float target_position_{0}; uint32_t update_interval_{1000}; }; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d4aca85962f..0e5e2f4f292 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -73,6 +73,7 @@ #define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN +#define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH diff --git a/tests/components/feedback/common.yaml b/tests/components/feedback/common.yaml index f93d54e8b63..a892aa614fc 100644 --- a/tests/components/feedback/common.yaml +++ b/tests/components/feedback/common.yaml @@ -37,3 +37,16 @@ cover: - logger.log: Close Action stop_action: - logger.log: Stop Action + # Time-only cover with overshoot next to one without: the define is global, so both paths compile + - platform: feedback + name: Feedback Cover Plain + id: feedback_plain + open_duration: 10s + close_duration: 10s + overshoot_duration: 2s + open_action: + - logger.log: Plain Open + close_action: + - logger.log: Plain Close + stop_action: + - logger.log: Plain Stop From eeaa8c78d84082fc7c3cf7f68e2a327759c81bef Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:33:18 +1100 Subject: [PATCH 25/97] [cli] Add line format error message option (#20470) Co-authored-by: Claude Opus 5.5 --- esphome/__main__.py | 36 ++- esphome/components/substitutions/__init__.py | 17 +- esphome/config.py | 103 +++++- esphome/config_validation.py | 18 +- esphome/const.py | 9 + esphome/core/__init__.py | 4 + esphome/voluptuous_schema.py | 10 +- esphome/vscode.py | 13 +- tests/unit_tests/test_config_error_format.py | 312 +++++++++++++++++++ tests/unit_tests/test_main.py | 57 ++++ tests/unit_tests/test_vscode.py | 72 +++++ 11 files changed, 618 insertions(+), 33 deletions(-) create mode 100644 tests/unit_tests/test_config_error_format.py diff --git a/esphome/__main__.py b/esphome/__main__.py index 905339d2bcc..77c1c9f1ade 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -51,6 +51,7 @@ from esphome.const import ( KEY_ESP32, KEY_VARIANT, SECRETS_FILES, + ErrorFormat, Toolchain, ) from esphome.core import CORE, EsphomeError, coroutine @@ -2205,6 +2206,16 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None: ) +def _error_format(value: str) -> ErrorFormat: + try: + return ErrorFormat(value.lower()) + except ValueError: + choices = ", ".join(ErrorFormat) + raise argparse.ArgumentTypeError( + f"invalid choice: '{value}' (choose from {choices})" + ) from None + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2267,6 +2278,17 @@ def parse_args(argv): ), ) + options_parser.add_argument( + "--error-format", + type=_error_format, + metavar="{" + ",".join(f.value for f in ErrorFormat) + "}", + help=( + "How configuration errors are shown. 'yaml' shows them inside the " + "failing YAML; 'line' prints one 'file:line:column: error: message' " + "line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'." + ), + ) + parser = argparse.ArgumentParser( description=f"ESPHome {const.__version__}", parents=[options_parser] ) @@ -2570,9 +2592,18 @@ def parse_args(argv): args, unknown_args = parser.parse_known_args(arguments) if unknown_args: _LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args) - return args + else: + args = parser.parse_args(arguments) - return parser.parse_args(arguments) + # Read here rather than as the option default, so a bad value is reported + # against the environment variable instead of --error-format + if args.error_format is None: + env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value) + try: + args.error_format = _error_format(env_value) + except argparse.ArgumentTypeError as err: + parser.error(f"ESPHOME_ERROR_FORMAT: {err}") + return args def _warn_if_source_tree_mismatch() -> None: @@ -2645,6 +2676,7 @@ def run_esphome(argv): args = parse_args(argv) CORE.dashboard = args.dashboard CORE.testing_mode = args.testing_mode + CORE.error_format = args.error_format # Create address cache from command-line arguments CORE.address_cache = AddressCache.from_cli_args( diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 93cf588e02a..711dd9e8157 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -68,8 +68,9 @@ def raise_first_undefined( for e, p_path, _ in errors[1:] ) _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) - raise cv.Invalid( - f"Undefined variable in {context_label}: {err.message}\n{format_path(err_path, err_value)}" + raise cv.SourceTraceInvalid( + f"Undefined variable in {context_label}: {err.message}", + format_path(err_path, err_value), ) @@ -223,13 +224,13 @@ def _expand_substitutions( errors=errors, ) except JinjaError as err: - raise cv.Invalid( + raise cv.SourceTraceInvalid( f"{err.error_name()} Error evaluating jinja expression" f" '{value}': {str(err.parent())}." f"\nEvaluation stack: (most recent evaluation last)" f"\n{err.stack_trace_str()}" - f"\nRelevant context:\n{err.context_trace_str()}" - f"\n{format_path(path, orig_value)}", + f"\nRelevant context:\n{err.context_trace_str()}", + format_path(path, orig_value), path, ) from err else: @@ -389,9 +390,9 @@ def resolve_include( return include.load() if include.should_load() else {} except (esphome.core.EsphomeError, cv.Invalid) as err: resolved = f" (expanded from '{original}')" if substituted else "" - raise cv.Invalid( - f"Error including file '{filename}'{resolved}: {err}" - f"\n{format_path(path, original)}", + raise cv.SourceTraceInvalid( + f"Error including file '{filename}'{resolved}: {err}", + format_path(path, original), path + [f"<{filename}>"], ) from err diff --git a/esphome/config.py b/esphome/config.py index 28a688f15ab..682557ecfc9 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -12,6 +12,7 @@ import re from typing import TYPE_CHECKING, Any import voluptuous as vol +import yaml from esphome import core, loader, pins, yaml_util from esphome.components.substitutions import do_substitution_pass @@ -26,8 +27,9 @@ from esphome.const import ( CONF_PACKAGES, CONF_PLATFORM, CONF_SUBSTITUTIONS, + ErrorFormat, ) -from esphome.core import CORE, DocumentRange, EsphomeError +from esphome.core import CORE, DocumentLocation, DocumentRange, EsphomeError # `esphome.core.config` is imported lazily at its two use sites below. # It pulls in `esphome.automation` and `esphome.config_validation`, which @@ -38,7 +40,7 @@ from esphome.loader import ComponentManifest, get_component, get_platform from esphome.log import AnsiFore, color from esphome.types import ConfigFragmentType, ConfigType from esphome.util import OrderedDict, safe_print -from esphome.voluptuous_schema import ExtraKeysInvalid +from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret if TYPE_CHECKING: @@ -285,12 +287,8 @@ class Config(OrderedDict, fv.FinalValidateConfig): last_root = max( i for i, v in enumerate(error.path) if v is cv.ROOT_CONFIG_PATH ) - # can't change the path so re-create the error - error = vol.Invalid( - message=error.error_message, - path=error.path[last_root + 1 :], - error_type=error.error_type, - ) + # Trim in place, as Invalid.prepend() does, to keep the subclass + error._path = error.path[last_root + 1 :] # pylint: disable=protected-access self.errors.append(error) def add_validation_step(self, step: ConfigValidationStep): @@ -1513,6 +1511,7 @@ class InvalidYAMLError(EsphomeError): message = f"Invalid YAML syntax:\n\n{base}" super().__init__(message) self.base_exc = base_exc + self.base_message = base def _load_config( @@ -1555,6 +1554,86 @@ def load_config( raise EsphomeError(f"Error while parsing config: {err}") from err +def get_invalid_range(res: Config, invalid: vol.Invalid) -> DocumentRange | None: + """Return the source range of the YAML that caused *invalid*.""" + return res.get_deepest_document_range_for_path( + invalid.path, isinstance(invalid, KeyInvalid) + ) + + +def _format_location(location: DocumentLocation | None) -> str: + """Return a 1-based `file:line:column`, or the main config file if unknown.""" + if location is None: + return str(CORE.config_path) + return f"{location.document}:{location.line + 1}:{location.column + 1}" + + +def _print_line_message(location: str, kind: str, message: object) -> None: + """Print *message* as `location: kind: text`, one line per message line. + + Lines after the first become `note:` lines, so tools that read one + message per line still see every line with its location. + """ + lines = [line.strip() for line in str(message).splitlines() if line.strip()] + first, *rest = lines or [""] + safe_print(f"{location}: {kind}: {first}") + for line in rest: + safe_print(f"{location}: note: {line}") + + +def _find_marked_yaml_error(err: BaseException) -> yaml.MarkedYAMLError | None: + cause: BaseException | None = err + while cause is not None and not isinstance(cause, yaml.MarkedYAMLError): + cause = cause.__cause__ + return cause + + +def _print_marked_yaml_error(err: yaml.MarkedYAMLError) -> None: + # Errors raised as MarkedYAMLError(message, mark) put the message in context + mark = err.problem_mark or err.context_mark + location = DocumentLocation.from_mark(mark) if mark is not None else None + if not (message := err.problem or err.context): + # str() includes the note, so it is not printed separately + _print_line_message(_format_location(location), "error", str(err)) + return + _print_line_message(_format_location(location), "error", message) + if err.problem and err.context and err.context_mark is not None: + context_location = DocumentLocation.from_mark(err.context_mark) + _print_line_message(_format_location(context_location), "note", err.context) + if err.note: + _print_line_message(_format_location(location), "note", err.note) + + +def _print_line_errors(res: Config) -> None: + for err in res.errors: + doc_range = get_invalid_range(res, err) + location = _format_location( + doc_range.start_mark if doc_range is not None else None + ) + # A YAML error in an included file is wrapped in a validation error + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + _print_line_message(location, "note", "included from here") + elif isinstance(err, cv.SourceTraceInvalid): + # The trace only repeats the location this line already starts with + _print_line_message(location, "error", humanize_error(res, err.detail)) + else: + _print_line_message(location, "error", _format_vol_invalid(err, res)) + if doc_range is None and err.path: + # No source position, so give the config path to help find it + path = "->".join(str(item) for item in err.path) + _print_line_message(location, "note", f"In: {path}") + + +def _print_line_load_error(err: EsphomeError) -> None: + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + else: + # Drop the "Invalid YAML syntax" heading; it is wrong for unreadable files + message = err.base_message if isinstance(err, InvalidYAMLError) else err + _print_line_message(_format_location(None), "error", message) + + def line_info(config, path, highlight=True): """Display line config source.""" if not highlight: @@ -1703,7 +1782,13 @@ def read_config( snapshot_user_config=snapshot_user_config, ) except EsphomeError as err: - _LOGGER.error("Error while reading config: %s", err) + if CORE.error_format == ErrorFormat.LINE: + _print_line_load_error(err) + else: + _LOGGER.error("Error while reading config: %s", err) + return None + if res.errors and CORE.error_format == ErrorFormat.LINE: + _print_line_errors(res) return None if res.errors: if not CORE.verbose: diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 79d7068838a..64d22f9a143 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -113,7 +113,7 @@ from esphome.schema_extractors import ( # Deprecated re-export for external components; remove before 2027.2.0 # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 -from esphome.voluptuous_schema import _Schema +from esphome.voluptuous_schema import KeyInvalid, _Schema from esphome.yaml_util import SensitiveStr, make_data_base if typing.TYPE_CHECKING: @@ -452,6 +452,20 @@ def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schem return schema.extend(overrides) +class SourceTraceInvalid(Invalid): + """An error whose message is followed by where the bad value came from. + + ``detail`` is the message without that trace, for output that already + gives the location. + """ + + def __init__( + self, detail: str, source_trace: str, path: list[typing.Hashable] | None = None + ) -> None: + super().__init__(f"{detail}\n{source_trace}", path) + self.detail = detail + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" @@ -2293,7 +2307,7 @@ def validate_registry_entry(name, registry): if key is None: raise Invalid(f"Key missing from {name}! Got {value}") if key not in registry: - raise Invalid(f"Unable to find {name} with the name '{key}'", [key]) + raise KeyInvalid(f"Unable to find {name} with the name '{key}'", [key]) key2 = next((x for x in value if x != key and x not in ignore_keys), None) if key2 is not None: raise Invalid( diff --git a/esphome/const.py b/esphome/const.py index 7774722795d..115ccad7470 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -27,6 +27,15 @@ class Toolchain(StrEnum): HOST = "host" +class ErrorFormat(StrEnum): + """How configuration errors are printed.""" + + # Errors embedded in a dump of the failing YAML + YAML = "yaml" + # One `file:line:col: error: message` line per error + LINE = "line" + + # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1668263e337..61092ec4272 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( PLATFORM_NRF52, PLATFORM_RP2, PLATFORM_RTL87XX, + ErrorFormat, Toolchain, ) @@ -681,6 +682,8 @@ class EsphomeCore: self.verbose = False # Whether ESPHome was started in quiet mode self.quiet = False + # How configuration errors are printed + self.error_format: ErrorFormat = ErrorFormat.YAML # A list of all known ID classes self.id_classes = {} # The current component being processed during validation @@ -734,6 +737,7 @@ class EsphomeCore: self._config_hash = None self.skip_external_update = False self.toolchain = None + self.error_format = ErrorFormat.YAML PIN_SCHEMA_REGISTRY.reset() @contextmanager diff --git a/esphome/voluptuous_schema.py b/esphome/voluptuous_schema.py index 904963ba4e8..a31f3252ce3 100644 --- a/esphome/voluptuous_schema.py +++ b/esphome/voluptuous_schema.py @@ -6,10 +6,14 @@ import voluptuous as vol from esphome.schema_extractors import schema_extractor_extended -class ExtraKeysInvalid(vol.Invalid): +class KeyInvalid(vol.Invalid): + """An error caused by a mapping key itself rather than by its value.""" + + +class ExtraKeysInvalid(KeyInvalid): def __init__(self, *arg, **kwargs): self.candidates = kwargs.pop("candidates") - vol.Invalid.__init__(self, *arg, **kwargs) + super().__init__(*arg, **kwargs) def ensure_multiple_invalid(err): @@ -192,7 +196,7 @@ class _Schema(vol.Schema): ) else: errors.append( - vol.Invalid("extra keys not allowed", key_path) + KeyInvalid("extra keys not allowed", key_path) ) # for any required keys left that weren't found and don't have defaults: diff --git a/esphome/vscode.py b/esphome/vscode.py index ba7b4e727b5..e4ff0bb6ea6 100644 --- a/esphome/vscode.py +++ b/esphome/vscode.py @@ -7,19 +7,13 @@ import sys import traceback from typing import Any -from esphome.config import Config, _format_vol_invalid, validate_config +from esphome.config import _format_vol_invalid, get_invalid_range, validate_config import esphome.config_validation as cv from esphome.const import __version__ as ESPHOME_VERSION from esphome.core import CORE, DocumentRange, EsphomeError from esphome.yaml_util import parse_yaml -def _get_invalid_range(res: Config, invalid: cv.Invalid) -> DocumentRange | None: - return res.get_deepest_document_range_for_path( - invalid.path, invalid.error_message == "extra keys not allowed" - ) - - def _dump_range(range: DocumentRange | None) -> dict | None: if range is None: return None @@ -155,8 +149,9 @@ def read_config(args): else: for err in res.errors: try: - range_ = _get_invalid_range(res, err) + range_ = get_invalid_range(res, err) vs.add_validation_error(range_, _format_vol_invalid(err, res)) except Exception: # noqa: BLE001 # pylint: disable=broad-except - continue + # Report the error without a range rather than drop it + vs.add_validation_error(None, str(err)) print(vs.dump()) diff --git a/tests/unit_tests/test_config_error_format.py b/tests/unit_tests/test_config_error_format.py new file mode 100644 index 00000000000..395253dd708 --- /dev/null +++ b/tests/unit_tests/test_config_error_format.py @@ -0,0 +1,312 @@ +"""Tests for the `--error-format line` output of read_config.""" + +from pathlib import Path + +import pytest +import voluptuous as vol +import yaml + +from esphome import config_validation as cv +from esphome.config import ( + Config, + InvalidYAMLError, + _print_line_errors, + _print_line_load_error, + _print_line_message, + _print_marked_yaml_error, + read_config, +) +from esphome.const import ErrorFormat +from esphome.core import CORE +from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid + + +def _read( + tmp_path: Path, files: dict[str, str], capsys: pytest.CaptureFixture[str] +) -> list[str]: + for name, content in files.items(): + (tmp_path / name).write_text(content) + CORE.config_path = tmp_path / "test.yaml" + CORE.error_format = ErrorFormat.LINE + assert read_config({}) is None + return capsys.readouterr().out.splitlines() + + +def test_validation_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read( + tmp_path, + {"test.yaml": "esphome:\n name: test\nhost:\nlogger:\n levl: DEBUG\n"}, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:5:3: error: [levl] is an invalid option for " + "[logger]. Did you mean [level]?" + ) + ] + + +def test_validation_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n" + ), + "pkg.yaml": "logger:\n levl: DEBUG\n", + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'pkg.yaml'}:2:3: error: [levl] is an invalid option for " + "[logger]. Did you mean [level]?" + ) + ] + + +def test_unknown_action_anchors_on_the_key( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # validate_registry_entry raises KeyInvalid for an unknown action, so the + # location is the key, not the action's value. + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\nlogger:\nbutton:\n" + ' - platform: template\n on_press:\n - loggr.log: "msg"\n' + ) + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:8:9: error: Unable to find action " + "with the name 'loggr.log'." + ) + ] + + +def test_yaml_syntax_error(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read(tmp_path, {"test.yaml": "esphome:\n name: [1, 2\nhost:\n"}, capsys) + assert lines == [ + f"{tmp_path / 'test.yaml'}:3:5: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'test.yaml'}:2:9: note: while parsing a flow sequence", + ] + + +def test_yaml_syntax_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n pkg: !include pkg.yaml\n" + ), + "pkg.yaml": "logger:\n level: [1, 2\nwifi:\n", + }, + capsys, + ) + assert lines == [ + f"{tmp_path / 'pkg.yaml'}:3:5: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'pkg.yaml'}:2:10: note: while parsing a flow sequence", + f"{tmp_path / 'test.yaml'}:5:3: note: included from here", + ] + + +def test_secret_syntax_error_in_package( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "sub").mkdir() + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\npackages:\n p: !include sub/pkg.yaml\n" + ), + "sub/pkg.yaml": "logger:\n level: !secret lvl\n", + "secrets.yaml": "lvl: [1, 2\nx: 1\n", + }, + capsys, + ) + assert lines == [ + f"{tmp_path / 'secrets.yaml'}:2:2: error: expected ',' or ']', but got ':'", + f"{tmp_path / 'secrets.yaml'}:1:6: note: while parsing a flow sequence", + f"{tmp_path / 'test.yaml'}:5:3: note: included from here", + ] + + +def test_undefined_secret(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + lines = _read( + tmp_path, + { + "test.yaml": "esphome:\n name: !secret nope\nhost:\n", + "secrets.yaml": "other: 1\n", + }, + capsys, + ) + assert lines == [f"{tmp_path / 'test.yaml'}:2:9: error: Secret 'nope' not defined"] + + +def test_missing_file(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + CORE.config_path = tmp_path / "missing.yaml" + CORE.error_format = ErrorFormat.LINE + assert read_config({}) is None + lines = capsys.readouterr().out.splitlines() + assert len(lines) == 1 + assert lines[0].startswith( + f"{tmp_path / 'missing.yaml'}: error: Error reading file " + ) + + +def test_source_trace_omitted( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\n" + "packages:\n p: !include ${ undefined_var }.yaml\n" + ), + }, + capsys, + ) + # The "In: packages->p in test.yaml 5:3" trace would repeat the location + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:5:3: error: Error including file " + "'${ undefined_var }.yaml': Cannot load include with unresolved " + "substitutions: ${ undefined_var }.yaml." + ), + ] + + +def test_print_line_message_skips_blank_lines( + capsys: pytest.CaptureFixture[str], +) -> None: + _print_line_message("a.yaml:1:1", "error", "first\n\n second \n") + assert capsys.readouterr().out.splitlines() == [ + "a.yaml:1:1: error: first", + "a.yaml:1:1: note: second", + ] + + +def test_reset_restores_default_format() -> None: + CORE.error_format = ErrorFormat.LINE + CORE.reset() + assert CORE.error_format is ErrorFormat.YAML + + +@pytest.mark.parametrize( + "error", + [ + KeyInvalid("Unable to find action", ["a", cv.ROOT_CONFIG_PATH, "b"]), + ExtraKeysInvalid( + "extra keys not allowed", + ["a", cv.ROOT_CONFIG_PATH, "b"], + candidates=["c"], + ), + ], +) +def test_add_error_keeps_error_class(error: KeyInvalid) -> None: + res = Config() + res.add_error(error) + (added,) = res.errors + assert type(added) is type(error) + assert added.path == ["b"] + assert getattr(added, "candidates", None) == getattr(error, "candidates", None) + + +def test_default_format_logs_load_error( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + caplog: pytest.LogCaptureFixture, +) -> None: + CORE.config_path = tmp_path / "missing.yaml" + assert read_config({}) is None + assert capsys.readouterr().out == "" + assert "Error while reading config: Invalid YAML syntax" in caplog.text + + +def test_exception_as_error_message( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + # "ms" with no number makes the time period validator raise Invalid(ValueError) + lines = _read( + tmp_path, + { + "test.yaml": ( + "esphome:\n name: test\nhost:\nsensor:\n - platform: template\n" + " name: foo\n update_interval: ms\n" + ) + }, + capsys, + ) + assert lines == [ + ( + f"{tmp_path / 'test.yaml'}:7:22: error: " + "could not convert string to float: ''." + ) + ] + + +def _mark(line: int, column: int) -> yaml.Mark: + return yaml.Mark("a.yaml", 0, line, column, None, None) + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + ( + yaml.MarkedYAMLError( + problem="bad value", problem_mark=_mark(2, 1), note="a hint" + ), + ["a.yaml:3:2: error: bad value", "a.yaml:3:2: note: a hint"], + ), + # No problem or context: the whole error text is used, note included + ( + yaml.MarkedYAMLError(problem_mark=_mark(2, 1), note="a hint"), + [ + 'a.yaml:3:2: error: in "a.yaml", line 3, column 2', + "a.yaml:3:2: note: a hint", + ], + ), + ], +) +def test_marked_yaml_error_note( + capsys: pytest.CaptureFixture[str], + error: yaml.MarkedYAMLError, + expected: list[str], +) -> None: + _print_marked_yaml_error(error) + assert capsys.readouterr().out.splitlines() == expected + + +def test_error_without_range_gives_config_path( + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = Path("main.yaml") + res = Config() + res.add_error(vol.Invalid("bad value", ["sensor", 0, "name"])) + _print_line_errors(res) + assert capsys.readouterr().out.splitlines() == [ + "main.yaml: error: bad value.", + "main.yaml: note: In: sensor->0->name", + ] + + +def test_load_error_with_unprintable_cause( + capsys: pytest.CaptureFixture[str], +) -> None: + class Unprintable(Exception): + def __str__(self) -> str: + raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte") + + CORE.config_path = Path("main.yaml") + _print_line_load_error(InvalidYAMLError(Unprintable())) + assert capsys.readouterr().out.splitlines() == ["main.yaml: error: Unprintable()"] diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 37d8e7486f4..3b8330f1591 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -115,6 +115,7 @@ from esphome.const import ( PLATFORM_HOST, PLATFORM_NRF52, PLATFORM_RP2, + ErrorFormat, Toolchain, ) from esphome.core import CORE, EsphomeError @@ -6462,6 +6463,62 @@ def test_parse_args_logs_states() -> None: assert args.states is True +@pytest.mark.parametrize( + ("argv", "env_value", "expected"), + [ + ([], None, ErrorFormat.YAML), + (["--error-format", "line"], None, ErrorFormat.LINE), + ([], "line", ErrorFormat.LINE), + (["--error-format", "yaml"], "line", ErrorFormat.YAML), + (["--error-format", "LINE"], None, ErrorFormat.LINE), + ([], "Line", ErrorFormat.LINE), + # The command line wins, so a bad environment value is not an error + (["--error-format", "line"], "bogus", ErrorFormat.LINE), + ], +) +def test_parse_args_error_format( + monkeypatch: pytest.MonkeyPatch, + argv: list[str], + env_value: str | None, + expected: ErrorFormat, +) -> None: + """--error-format overrides ESPHOME_ERROR_FORMAT, which overrides the default.""" + if env_value is None: + monkeypatch.delenv("ESPHOME_ERROR_FORMAT", raising=False) + else: + monkeypatch.setenv("ESPHOME_ERROR_FORMAT", env_value) + + args = parse_args(["esphome", *argv, "config", "device.yaml"]) + assert args.error_format is expected + + +@pytest.mark.parametrize( + ("argv", "env_value", "expected_name"), + [ + ([], "bogus", "ESPHOME_ERROR_FORMAT"), + (["--error-format", "bogus"], None, "--error-format"), + ], +) +def test_parse_args_error_format_invalid( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + argv: list[str], + env_value: str | None, + expected_name: str, +) -> None: + """A bad value is reported against the setting that supplied it.""" + if env_value is None: + monkeypatch.delenv("ESPHOME_ERROR_FORMAT", raising=False) + else: + monkeypatch.setenv("ESPHOME_ERROR_FORMAT", env_value) + + with pytest.raises(SystemExit): + parse_args(["esphome", *argv, "config", "device.yaml"]) + err = capsys.readouterr().err + assert expected_name in err + assert "invalid choice: 'bogus' (choose from yaml, line)" in err + + @pytest.mark.parametrize("command", ["logs", "run"]) @pytest.mark.parametrize( ("env_value", "expected"), diff --git a/tests/unit_tests/test_vscode.py b/tests/unit_tests/test_vscode.py index 7d401eaa653..178fd840203 100644 --- a/tests/unit_tests/test_vscode.py +++ b/tests/unit_tests/test_vscode.py @@ -192,3 +192,75 @@ def test_invalid_stays_plain() -> None: def test_format_unexpected_error_without_traceback() -> None: message = vscode._format_unexpected_error(ValueError("boom")) assert message == "Unexpected error while validating: ValueError: boom" + + +def test_reports_error_with_exception_message(): + source_path = str(Path("dir_path", "x.yaml")) + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +sensor: + - platform: template + name: foo + update_interval: ms # no number, so the validator raises Invalid(ValueError) +"""), + ] + ) + + error = json.loads(output_lines[-1]) + validation_error = error["validation_errors"][0] + assert validation_error["message"] == "could not convert string to float: ''." + assert validation_error["range"]["start_line"] == 6 + + +def test_unknown_action_range_is_the_key(): + source_path = str(Path("dir_path", "x.yaml")) + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +button: + - platform: template + on_press: + - loggr.log: "msg" +"""), + ] + ) + + error = json.loads(output_lines[-1]) + validation_error = error["validation_errors"][0] + assert ( + validation_error["message"] + == "Unable to find action with the name 'loggr.log'." + ) + assert validation_error["range"]["start_line"] == 6 + assert validation_error["range"]["start_col"] == 8 + + +def test_error_kept_when_range_lookup_fails(): + source_path = str(Path("dir_path", "x.yaml")) + with patch.object(vscode, "get_invalid_range", side_effect=RuntimeError("boom")): + output_lines = _run_repl_test( + [ + _validate(source_path), + # read_file x.yaml + _file_response("""esphome: + name: test1 +host: +logger: + levl: DEBUG +"""), + ] + ) + + error = json.loads(output_lines[-1]) + (validation_error,) = error["validation_errors"] + assert validation_error["range"] is None + assert "extra keys not allowed" in validation_error["message"] From e6da33d3f011996889385474a8103a2c3f8e1147 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 16:49:54 -1000 Subject: [PATCH 26/97] [const] Move the verify_ssl constant to the shared constants (#20501) --- esphome/components/audio_http/media_source.py | 2 +- esphome/components/const/__init__.py | 1 + esphome/components/http_request/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 3c23517b92f..6c491379c89 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -3,6 +3,7 @@ from pathlib import Path import esphome.codegen as cg from esphome.components import audio, media_source, psram +from esphome.components.const import CONF_VERIFY_SSL import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM import esphome.final_validate as fv @@ -16,7 +17,6 @@ AUTO_LOAD = ["audio"] CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer" CONF_CA_CERTIFICATE_PATH = "ca_certificate_path" CONF_HTTP_REQUEST = "http_request" -CONF_VERIFY_SSL = "verify_ssl" audio_http_ns = cg.esphome_ns.namespace("audio_http") AudioHTTPMediaSource = audio_http_ns.class_( diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index eb999a05ff4..7160ef5f3a4 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -58,6 +58,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VERIFY_SSL = "verify_ssl" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 92359714025..ad51d5f39be 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -4,7 +4,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.const import CONF_REQUEST_HEADERS, CONF_VERIFY_SSL from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -53,7 +53,6 @@ HttpRequestResponseTrigger = http_request_ns.class_( CONF_HTTP_REQUEST_ID = "http_request_id" CONF_USERAGENT = "useragent" -CONF_VERIFY_SSL = "verify_ssl" CONF_FOLLOW_REDIRECTS = "follow_redirects" CONF_REDIRECT_LIMIT = "redirect_limit" CONF_BUFFER_SIZE_RX = "buffer_size_rx" From 70e4463644fd35c5aba9e0f833b9cc944507d7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 10 Oct 2026 16:56:35 -1000 Subject: [PATCH 27/97] [const] Move the adc_time and adc_averaging constants to the shared constants (#20503) --- esphome/components/const/__init__.py | 2 ++ esphome/components/ina226/sensor.py | 3 +-- esphome/components/ina2xx_base/__init__.py | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 7160ef5f3a4..51a5e012102 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -8,6 +8,8 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" +CONF_ADC_AVERAGING = "adc_averaging" +CONF_ADC_TIME = "adc_time" CONF_ALLOWED_IPS = "allowed_ips" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index af1793450c9..53427fc63e1 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/sensor.py @@ -2,6 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -24,8 +25,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_ADC_AVERAGING = "adc_averaging" -CONF_ADC_TIME = "adc_time" ina226_ns = cg.esphome_ns.namespace("ina226") INA226Component = ina226_ns.class_( diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index 7fdfd90bb6f..c6239ef6d2e 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -2,7 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import sensor -from esphome.components.const import UNIT_AMPERE_HOUR +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME, UNIT_AMPERE_HOUR import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -36,9 +36,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DOMAIN = "ina2xx_base" -CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_RANGE = "adc_range" -CONF_ADC_TIME = "adc_time" CONF_CHARGE = "charge" CONF_CHARGE_COULOMBS = "charge_coulombs" CONF_ENERGY_JOULES = "energy_joules" From 377a43c5be9889abce9d1c724713a38f15f62030 Mon Sep 17 00:00:00 2001 From: longerCZE <72692179+longerCZE@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:17:22 +0200 Subject: [PATCH 28/97] [toshiba] Add 'seiya' model support (#17830) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Signed-off-by: Petr Helebrant --- esphome/components/toshiba/climate.py | 1 + esphome/components/toshiba/toshiba.cpp | 285 ++++++++++++------ esphome/components/toshiba/toshiba.h | 20 +- tests/components/toshiba/__init__.py | 15 + tests/components/toshiba/benchmark.yaml | 4 + tests/components/toshiba/common_seiya.yaml | 6 + .../toshiba/test-seiya.esp32-idf.yaml | 4 + .../toshiba/test-seiya.esp8266-ard.yaml | 4 + tests/components/toshiba/toshiba_tests.cpp | 165 ++++++++++ 9 files changed, 411 insertions(+), 93 deletions(-) create mode 100644 tests/components/toshiba/__init__.py create mode 100644 tests/components/toshiba/benchmark.yaml create mode 100644 tests/components/toshiba/common_seiya.yaml create mode 100644 tests/components/toshiba/test-seiya.esp32-idf.yaml create mode 100644 tests/components/toshiba/test-seiya.esp8266-ard.yaml create mode 100644 tests/components/toshiba/toshiba_tests.cpp diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index e5f8544f2fe..962e6449f4a 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -16,6 +16,7 @@ MODELS = { "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, "RAS-2819T": Model.MODEL_RAS_2819T, + "SEIYA": Model.MODEL_SEIYA, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index f9f09a98bc4..c75496ad4ff 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1,5 +1,6 @@ #include "toshiba.h" #include "esphome/components/remote_base/toshiba_ac_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include @@ -163,6 +164,41 @@ const uint8_t RAS_2819T_AUTO_DRY_FAN_BYTE = 0x65; const uint8_t RAS_2819T_AUTO_DRY_SUFFIX = 0x3A; const uint8_t RAS_2819T_HEAT_SUFFIX = 0x3B; +// Bit timings differ per protocol family; the frame layout is shared +struct ToshibaTimings { + uint16_t header_mark; + uint16_t header_space; + uint16_t bit_mark; + uint16_t zero_space; + uint16_t one_space; + uint16_t gap_space; + bool gap_after_last; // Seiya ends on a bare mark and only puts the gap between repeats +}; +// Kept in flash on ESP8266, where plain const objects referenced through a function would land in RAM +static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK, + TOSHIBA_HEADER_SPACE, + TOSHIBA_BIT_MARK, + TOSHIBA_ZERO_SPACE, + TOSHIBA_ONE_SPACE, + TOSHIBA_GAP_SPACE, + true}; +static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false}; + +static ToshibaTimings timings_for(Model model) { + ToshibaTimings timings; + progmem_memcpy(&timings, model == MODEL_SEIYA ? &SEIYA_TIMINGS : &TOSHIBA_TIMINGS, sizeof(timings)); + return timings; +} + +// Seiya is the generic frame plus two bytes: a feature marker and a one-shot swing command +static constexpr uint8_t SEIYA_MESSAGE_LENGTH = 11; +static constexpr uint8_t SEIYA_FEATURE_MARKER = 0x21; +static constexpr uint8_t SEIYA_SWING_BOTH = 0x01; +static constexpr uint8_t SEIYA_SWING_OMNI = 0x02; // no ESPHome equivalent; treated as BOTH +static constexpr uint8_t SEIYA_SWING_VERTICAL = 0x08; +static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09; +static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame + // RAS-2819T temperature codes for 18-30°C static const uint8_t RAS_2819T_TEMP_CODES[] = { 0x10, // 18°C @@ -404,12 +440,15 @@ void ToshibaClimate::setup() { this->maximum_temperature_ = this->temperature_max_(); this->swing_modes_ = this->toshiba_swing_modes_(); - // Ensure swing mode is always initialized to a valid value + // Ensure swing mode is always initialized to a valid value. OFF also stands for "not yet + // commanded" on models that cannot switch the swing off, such as Seiya. if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) { - // No swing support for this model or current swing mode not supported, reset to OFF this->swing_mode = climate::CLIMATE_SWING_OFF; } + // A restored swing mode is already in effect, so it must not go out as a fresh swing command + this->last_swing_mode_ = this->swing_mode; + // Ensure mode is valid - ESPHome should only use standard climate modes if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_HEAT && this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT_COOL && @@ -451,8 +490,9 @@ void ToshibaClimate::transmit_state() { } void ToshibaClimate::transmit_generic_() { + const bool seiya = this->model_ == MODEL_SEIYA; uint8_t message[16] = {0}; - uint8_t message_length = 9; + const uint8_t message_length = seiya ? SEIYA_MESSAGE_LENGTH : 9; // Header message[0] = 0xf2; @@ -473,6 +513,40 @@ void ToshibaClimate::transmit_generic_() { message[5] = (temperature - static_cast(TOSHIBA_GENERIC_TEMP_C_MIN)) << 4; // Mode and fan + message[6] = this->encode_mode_fan_(); + + // Zero + message[7] = 0x00; + + if (seiya) { + // Captured OFF frames carry no fan bits + if (this->mode == climate::CLIMATE_MODE_OFF) { + message[6] = TOSHIBA_MODE_OFF; + } + message[8] = SEIYA_FEATURE_MARKER; + // Swing is a momentary command, sent only when the mode changes, like the remote does + message[9] = SEIYA_SWING_NEUTRAL; + if (this->swing_mode != this->last_swing_mode_) { + message[9] = this->seiya_swing_code_(); + this->last_swing_mode_ = this->swing_mode; + } + } + + // The last byte is the xor of all bytes from [4] + for (uint8_t i = 4; i < message_length - 1; i++) { + message[message_length - 1] ^= message[i]; + } + + // Transmit + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + this->encode_(data, message, message_length, 1); + + transmit.perform(); +} + +uint8_t ToshibaClimate::encode_mode_fan_() const { uint8_t mode; switch (this->mode) { case climate::CLIMATE_MODE_OFF: @@ -523,27 +597,20 @@ void ToshibaClimate::transmit_generic_() { fan = TOSHIBA_FAN_SPEED_AUTO; break; } - message[6] = fan | mode; + return fan | mode; +} - // Zero - message[7] = 0x00; - - // If timers bit in the command is set, two extra bytes are added here - - // If power bit is set in the command, one extra byte is added here - - // The last byte is the xor of all bytes from [4] - for (uint8_t i = 4; i < 8; i++) { - message[8] ^= message[i]; +uint8_t ToshibaClimate::seiya_swing_code_() const { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + return SEIYA_SWING_VERTICAL; + case climate::CLIMATE_SWING_HORIZONTAL: + return SEIYA_SWING_HORIZONTAL; + case climate::CLIMATE_SWING_BOTH: + return SEIYA_SWING_BOTH; + default: + return SEIYA_SWING_NEUTRAL; } - - // Transmit - auto transmit = this->transmitter_->transmit(); - auto *data = transmit.get_data(); - - this->encode_(data, message, message_length, 1); - - transmit.perform(); } void ToshibaClimate::transmit_rac_pt1411hwru_() { @@ -1085,14 +1152,16 @@ bool ToshibaClimate::process_ras_2819t_command_(const remote_base::ToshibaAcData bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { // Try modern ToshibaAcProtocol decoder first (handles RAS-2819T and potentially others) - remote_base::ToshibaAcProtocol toshiba_protocol; - auto decode_result = toshiba_protocol.decode(data); + if (this->model_ != MODEL_SEIYA) { + remote_base::ToshibaAcProtocol toshiba_protocol; + auto decode_result = toshiba_protocol.decode(data); - if (decode_result.has_value()) { - auto toshiba_data = decode_result.value(); - // Validate and process RAS-2819T commands - if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { - return this->process_ras_2819t_command_(toshiba_data); + if (decode_result.has_value()) { + auto toshiba_data = decode_result.value(); + // Validate and process RAS-2819T commands + if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { + return this->process_ras_2819t_command_(toshiba_data); + } } } @@ -1101,7 +1170,8 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { uint8_t message_length = TOSHIBA_HEADER_LENGTH, temperature_code = 0; // Validate header - if (!data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE)) { + const ToshibaTimings timings = timings_for(this->model_); + if (!data.expect_item(timings.header_mark, timings.header_space)) { return false; } // Read incoming bits into buffer @@ -1117,6 +1187,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else { // First checksum was valid so continue receiving the remaining bits + // The shortest frame is 9 bytes (length byte 3); longer ones must still fit the buffer + if (message[2] < 3 || message[2] > sizeof(message) - TOSHIBA_HEADER_LENGTH - 2) { + return false; + } message_length = message[2] + 2; } // Decode the remaining bytes @@ -1276,59 +1350,11 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - // Get the mode - switch (message[6] & 0x0F) { - case TOSHIBA_MODE_OFF: - this->mode = climate::CLIMATE_MODE_OFF; - break; - - case TOSHIBA_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - - case TOSHIBA_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - - case TOSHIBA_MODE_FAN_ONLY: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - - case TOSHIBA_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - - case TOSHIBA_MODE_AUTO: - default: - this->mode = climate::CLIMATE_MODE_HEAT_COOL; + this->decode_mode_fan_temperature_(message); + // The marker byte tells a Seiya frame from another checksum-valid 11 byte frame within receiver tolerance + if (this->model_ == MODEL_SEIYA && message_length == SEIYA_MESSAGE_LENGTH && message[8] == SEIYA_FEATURE_MARKER) { + this->seiya_decode_swing_(message[9]); } - - // Get the fan mode - switch (message[6] & 0xF0) { - case TOSHIBA_FAN_SPEED_QUIET: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - - case TOSHIBA_FAN_SPEED_1: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - - case TOSHIBA_FAN_SPEED_3: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - - case TOSHIBA_FAN_SPEED_5: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - - case TOSHIBA_FAN_SPEED_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } - - // Get the target temperature - this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; } this->publish_state(); @@ -1337,31 +1363,36 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { void ToshibaClimate::encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, const uint8_t nbytes, const uint8_t repeat) { + const ToshibaTimings timings = timings_for(this->model_); data->set_carrier_frequency(TOSHIBA_CARRIER_FREQUENCY); for (uint8_t copy = 0; copy <= repeat; copy++) { - data->item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE); + data->item(timings.header_mark, timings.header_space); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - data->mark(TOSHIBA_BIT_MARK); + data->mark(timings.bit_mark); if (message[byte] & (1 << (7 - bit))) { - data->space(TOSHIBA_ONE_SPACE); + data->space(timings.one_space); } else { - data->space(TOSHIBA_ZERO_SPACE); + data->space(timings.zero_space); } } } - data->item(TOSHIBA_BIT_MARK, TOSHIBA_GAP_SPACE); + data->mark(timings.bit_mark); + if (copy < repeat || timings.gap_after_last) { + data->space(timings.gap_space); + } } } bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *message, const uint8_t nbytes) { + const ToshibaTimings timings = timings_for(this->model_); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ONE_SPACE)) { + if (data->expect_item(timings.bit_mark, timings.one_space)) { message[byte] |= 1 << (7 - bit); - } else if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ZERO_SPACE)) { + } else if (data->expect_item(timings.bit_mark, timings.zero_space)) { message[byte] &= static_cast(~(1 << (7 - bit))); } else { return false; @@ -1371,4 +1402,80 @@ bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *mess return true; } +void ToshibaClimate::decode_mode_fan_temperature_(const uint8_t *message) { + switch (message[6] & 0x0F) { + case TOSHIBA_MODE_OFF: + this->mode = climate::CLIMATE_MODE_OFF; + break; + + case TOSHIBA_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + + case TOSHIBA_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + + case TOSHIBA_MODE_FAN_ONLY: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + + case TOSHIBA_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + + case TOSHIBA_MODE_AUTO: + default: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + } + + // Speeds 2 and 4 are not exposed; report the nearest mode + switch (message[6] & 0xF0) { + case TOSHIBA_FAN_SPEED_QUIET: + this->fan_mode = climate::CLIMATE_FAN_QUIET; + break; + + case TOSHIBA_FAN_SPEED_1: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + + case TOSHIBA_FAN_SPEED_2: + case TOSHIBA_FAN_SPEED_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + + case TOSHIBA_FAN_SPEED_4: + case TOSHIBA_FAN_SPEED_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + + case TOSHIBA_FAN_SPEED_AUTO: + default: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; +} + +void ToshibaClimate::seiya_decode_swing_(uint8_t code) { + // A momentary command: the neutral value on an ordinary update means no change, not swing off + switch (code) { + case SEIYA_SWING_BOTH: + case SEIYA_SWING_OMNI: + this->swing_mode = climate::CLIMATE_SWING_BOTH; + break; + case SEIYA_SWING_VERTICAL: + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + break; + case SEIYA_SWING_HORIZONTAL: + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + break; + default: + return; + } + // Keep the tracker in sync so the received command is not sent back out + this->last_swing_mode_ = this->swing_mode; +} + } // namespace esphome::toshiba diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index a853730f31a..f291077e525 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -11,6 +11,7 @@ enum Model { MODEL_RAC_PT1411HWRU_C = 1, // Temperature range is from 16 to 30 MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 + MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte }; // Supported temperature ranges @@ -50,7 +51,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; private: - // RAS-2819T state tracking for swing mode optimization + // Last swing mode sent or received, for models whose swing is a one-shot command climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF}; climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF}; optional last_fan_mode_{}; @@ -71,13 +72,24 @@ class ToshibaClimate final : public climate_ir::ClimateIR { return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } climate::ClimateSwingModeMask toshiba_swing_modes_() { - return (this->model_ == MODEL_GENERIC) - ? climate::ClimateSwingModeMask() - : climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; + if (this->model_ == MODEL_GENERIC) + return climate::ClimateSwingModeMask(); + if (this->model_ == MODEL_SEIYA) { + // No captured code stops the swing, so OFF is not offered; the remote only selects a direction + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}; + } + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; } void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); + // Shared by every model that uses the F2 0D frame + uint8_t encode_mode_fan_() const; + void decode_mode_fan_temperature_(const uint8_t *message); + uint8_t seiya_swing_code_() const; + void seiya_decode_swing_(uint8_t code); + Model model_; }; diff --git a/tests/components/toshiba/__init__.py b/tests/components/toshiba/__init__.py new file mode 100644 index 00000000000..8a083978ece --- /dev/null +++ b/tests/components/toshiba/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] + + async def to_code_testing(config): + # on_receive decodes the generic frame through the shared protocol class + cg.add_define("USE_REMOTE_PROTOCOL_TOSHIBA_AC") + + manifest.to_code = to_code_testing diff --git a/tests/components/toshiba/benchmark.yaml b/tests/components/toshiba/benchmark.yaml new file mode 100644 index 00000000000..ad4129e4979 --- /dev/null +++ b/tests/components/toshiba/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The toshiba, 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/toshiba/common_seiya.yaml b/tests/components/toshiba/common_seiya.yaml new file mode 100644 index 00000000000..8cfd65301c4 --- /dev/null +++ b/tests/components/toshiba/common_seiya.yaml @@ -0,0 +1,6 @@ +climate: + - platform: toshiba + name: "Seiya Climate" + model: seiya + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/toshiba/test-seiya.esp32-idf.yaml b/tests/components/toshiba/test-seiya.esp32-idf.yaml new file mode 100644 index 00000000000..cc4956ffee5 --- /dev/null +++ b/tests/components/toshiba/test-seiya.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 + toshiba: !include common_seiya.yaml diff --git a/tests/components/toshiba/test-seiya.esp8266-ard.yaml b/tests/components/toshiba/test-seiya.esp8266-ard.yaml new file mode 100644 index 00000000000..4664f0913b9 --- /dev/null +++ b/tests/components/toshiba/test-seiya.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 + toshiba: !include common_seiya.yaml diff --git a/tests/components/toshiba/toshiba_tests.cpp b/tests/components/toshiba/toshiba_tests.cpp new file mode 100644 index 00000000000..528af888544 --- /dev/null +++ b/tests/components/toshiba/toshiba_tests.cpp @@ -0,0 +1,165 @@ +#include +#include +#include +#include +#include + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/components/toshiba/toshiba.h" + +namespace esphome::toshiba::testing { + +struct Timings { + uint32_t header_mark; + uint32_t header_space; + uint32_t bit_mark; + uint32_t zero_space; + uint32_t one_space; + uint32_t gap_space; + bool gap_after_last; +}; + +static constexpr Timings GENERIC{4380, 4370, 540, 540, 1620, 5480, true}; +static constexpr Timings SEIYA{4630, 4450, 625, 490, 1570, 5830, false}; + +/// The waveform the component must produce for a frame: two copies, MSB first +static remote_base::RawTimings waveform(const std::vector &bytes, const Timings &t) { + remote_base::RawTimings raw; + for (int copy = 0; copy < 2; copy++) { + raw.push_back(t.header_mark); + raw.push_back(-static_cast(t.header_space)); + for (uint8_t byte : bytes) { + for (int bit = 7; bit >= 0; bit--) { + raw.push_back(t.bit_mark); + raw.push_back(-static_cast((byte >> bit) & 1 ? t.one_space : t.zero_space)); + } + } + raw.push_back(t.bit_mark); + if (copy == 0 || t.gap_after_last) { + raw.push_back(-static_cast(t.gap_space)); + } + } + return raw; +} + +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; + ToshibaClimate climate; + + explicit Fixture(Model model) { + this->climate.set_model(model); + this->climate.set_transmitter(&this->transmitter); + this->climate.setup(); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + optional swing = {}) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + if (swing.has_value()) { + call.set_swing_mode(*swing); + } + call.perform(); + } + + const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); } +}; + +/// Decodes the first copy of a frame back into bytes so a mismatch is readable +static std::string frame_bytes(const remote_base::RawTimings &raw, size_t nbytes) { + std::string out; + for (size_t byte = 0; byte < nbytes; byte++) { + unsigned value = 0; + for (size_t bit = 0; bit < 8; bit++) { + const size_t space = 3 + 2 * (byte * 8 + bit); + value = (value << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0); + } + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", value); + out += buf; + } + return out; +} + +// Reports the frame bytes instead of gtest's truncated vector dump +static void expect_frame(const remote_base::RawTimings &actual, const remote_base::RawTimings &expected, + size_t nbytes) { + EXPECT_EQ(actual, expected) << "actual " << frame_bytes(actual, nbytes) << "\nexpected " + << frame_bytes(expected, nbytes); +} + +TEST(ToshibaSeiya, EncodesCapturedFrames) { + Fixture f(MODEL_SEIYA); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x70, 0x01, 0x00, 0x21, 0x17, 0x46}, SEIYA), 11); + + f.command(climate::CLIMATE_MODE_OFF, 23, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x07, 0x00, 0x21, 0x17, 0x50}, SEIYA), 11); + + f.command(climate::CLIMATE_MODE_HEAT, 22, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x03, 0x00, 0x21, 0x17, 0x64}, SEIYA), 11); + + // The swing code goes out once, when the swing mode changes + f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x08, 0x7A}, SEIYA), 11); + f.command(climate::CLIMATE_MODE_DRY, 23, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x02, 0x00, 0x21, 0x17, 0x55}, SEIYA), 11); +} + +TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) { + Fixture f(MODEL_GENERIC); + + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x01, 0x00, 0x70}, GENERIC), 9); + + f.command(climate::CLIMATE_MODE_HEAT, 20, climate::CLIMATE_FAN_HIGH); + expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9); +} + +static bool receive(ToshibaClimate &climate, const std::vector &bytes, const Timings &t) { + const remote_base::RawTimings raw = waveform(bytes, t); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + // The receiver calls through the listener interface, where on_receive is public + return static_cast(climate).on_receive(data); +} + +TEST(ToshibaSeiya, DecodesStateAndKeepsSwingOnNeutralByte) { + Fixture f(MODEL_SEIYA); + f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL); + + // An ordinary update carries the neutral swing byte and must not touch the swing mode + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x17, 0x65}, SEIYA)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + // A direction code switches the swing mode + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x09, 0x7B}, SEIYA)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); + + // Without the feature marker the swing byte is not a Seiya swing command + EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x00, 0x01, 0x52}, SEIYA)); + EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL); +} + +TEST(ToshibaSeiya, RejectsBadLengthByte) { + Fixture f(MODEL_SEIYA); + // Length byte 0x01 passes the header checksum but describes a frame shorter than any Toshiba frame + EXPECT_FALSE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x01, 0x50, 0x02}, SEIYA)); +} + +} // namespace esphome::toshiba::testing From fdf17523079b1a3beddaa4e5b5d04b40e4564913 Mon Sep 17 00:00:00 2001 From: RUEMUE Date: Sun, 11 Oct 2026 05:35:42 +0200 Subject: [PATCH 29/97] [toshiba] Add RAS-B10N3KV2 model support (#17550) Co-authored-by: Claude Fable 5 Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Petr Helebrant Co-authored-by: longerCZE <72692179+longerCZE@users.noreply.github.com> Co-authored-by: J. Nick Koston Signed-off-by: Petr Helebrant --- esphome/components/toshiba/climate.py | 1 + esphome/components/toshiba/toshiba.cpp | 15 ++++++++++++++- esphome/components/toshiba/toshiba.h | 3 ++- tests/components/toshiba/common.yaml | 4 ++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 962e6449f4a..69fbb210305 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -16,6 +16,7 @@ MODELS = { "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, "RAS-2819T": Model.MODEL_RAS_2819T, + "RAS-B10N3KV2": Model.MODEL_RAS_B10N3KV2, "SEIYA": Model.MODEL_SEIYA, } diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index c75496ad4ff..57361a4a587 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -183,10 +183,23 @@ static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK, TOSHIBA_GAP_SPACE, true}; static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false}; +// The RAS-B13 remote family does not accept the generic timings; these are IRremoteESP8266's TOSHIBA_AC values +static const ToshibaTimings RAS_B10N3KV2_TIMINGS PROGMEM = {4400, 4300, 580, 490, 1600, 7400, true}; static ToshibaTimings timings_for(Model model) { + const ToshibaTimings *source = &TOSHIBA_TIMINGS; + switch (model) { + case MODEL_SEIYA: + source = &SEIYA_TIMINGS; + break; + case MODEL_RAS_B10N3KV2: + source = &RAS_B10N3KV2_TIMINGS; + break; + default: + break; + } ToshibaTimings timings; - progmem_memcpy(&timings, model == MODEL_SEIYA ? &SEIYA_TIMINGS : &TOSHIBA_TIMINGS, sizeof(timings)); + progmem_memcpy(&timings, source, sizeof(timings)); return timings; } diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index f291077e525..799f690f54c 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -12,6 +12,7 @@ enum Model { MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte + MODEL_RAS_B10N3KV2 = 5, // RAS-B13 remote family, generic frame with IRremoteESP8266's TOSHIBA_AC timings }; // Supported temperature ranges @@ -72,7 +73,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR { return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } climate::ClimateSwingModeMask toshiba_swing_modes_() { - if (this->model_ == MODEL_GENERIC) + if (this->model_ == MODEL_GENERIC || this->model_ == MODEL_RAS_B10N3KV2) return climate::ClimateSwingModeMask(); if (this->model_ == MODEL_SEIYA) { // No captured code stops the swing, so OFF is not offered; the remote only selects a direction diff --git a/tests/components/toshiba/common.yaml b/tests/components/toshiba/common.yaml index ba96c0628d8..2b37e989465 100644 --- a/tests/components/toshiba/common.yaml +++ b/tests/components/toshiba/common.yaml @@ -2,3 +2,7 @@ climate: - platform: toshiba name: Toshiba Climate transmitter_id: xmitr + - platform: toshiba + name: RAS-B10N3KV2 Climate + model: RAS-B10N3KV2 + transmitter_id: xmitr From 6bc19d2a1aafc38f0650d70d4ab0b22dc4b3aac9 Mon Sep 17 00:00:00 2001 From: Mathew Winstone Date: Sat, 10 Oct 2026 23:45:03 -0400 Subject: [PATCH 30/97] [espnow] Add support for ESPNow with ESP8266 (#14407) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/espnow/__init__.py | 31 +++- esphome/components/espnow/automation.h | 4 +- .../components/espnow/espnow_component.cpp | 167 +++++++----------- esphome/components/espnow/espnow_component.h | 35 ++-- esphome/components/espnow/espnow_err.h | 19 +- esphome/components/espnow/espnow_packet.h | 70 ++------ esphome/components/espnow/espnow_platform.h | 34 ++++ .../espnow/espnow_platform_esp32.cpp | 96 ++++++++++ .../espnow/espnow_platform_esp8266.cpp | 105 +++++++++++ esphome/components/espnow/espnow_types.h | 77 ++++++++ .../packet_transport/espnow_transport.cpp | 14 +- .../packet_transport/espnow_transport.h | 5 +- esphome/core/event_pool.h | 3 +- tests/components/espnow/common-esp32.yaml | 9 + tests/components/espnow/common.yaml | 11 +- .../espnow/test-wifi.esp8266-ard.yaml | 2 + tests/components/espnow/test.esp32-idf.yaml | 4 +- tests/components/espnow/test.esp8266-ard.yaml | 2 + tests/unit_tests/components/test_espnow.py | 53 +++++- 19 files changed, 523 insertions(+), 218 deletions(-) create mode 100644 esphome/components/espnow/espnow_platform.h create mode 100644 esphome/components/espnow/espnow_platform_esp32.cpp create mode 100644 esphome/components/espnow/espnow_platform_esp8266.cpp create mode 100644 esphome/components/espnow/espnow_types.h create mode 100644 tests/components/espnow/common-esp32.yaml create mode 100644 tests/components/espnow/test-wifi.esp8266-ard.yaml create mode 100644 tests/components/espnow/test.esp8266-ard.yaml diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index aef8fd17b56..e5abb16b1c3 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -17,6 +17,8 @@ from esphome.const import ( CONF_ON_ERROR, CONF_TRIGGER_ID, CONF_WIFI, + PLATFORM_ESP32, + PLATFORM_ESP8266, ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, TemplateArgsType @@ -24,7 +26,14 @@ import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] -AUTO_LOAD = ["network"] + + +def AUTO_LOAD() -> list[str]: + # network initialises esp_netif and the default event loop on the ESP32. The ESP8266 needs neither, and + # loading it there would pull in mdns, which requires wifi + return ["network"] if CORE.is_esp32 else [] + + DOMAIN = "espnow" peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) @@ -121,15 +130,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ADDRESS): cv.mac_address, } ), - cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastTrigger), - cv.Optional(CONF_ADDRESS): cv.mac_address, - } + # The ESP8266 SDK does not report the destination address, so broadcasts are indistinguishable there + cv.Optional(CONF_ON_BROADCAST): cv.All( + cv.only_on_esp32, + automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + OnBroadcastTrigger + ), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), ), }, ).extend(cv.COMPONENT_SCHEMA), - cv.only_on_esp32, + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), ) @@ -138,6 +153,8 @@ def _validate_variant(config: ConfigType) -> ConfigType: # ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that # supplies the esp_now_* symbols. Fail here with a clear message instead of # letting the build reach an "undefined reference to esp_now_*" link error. + if not CORE.is_esp32: + return config variant = get_esp32_variant() if wifi.variant_has_wifi(variant): return config diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 851b8345c94..88e240a646a 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_component.h" @@ -142,4 +142,4 @@ class OnBroadcastTrigger final : public Trigger #include +#include +#include #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include -#include -#include - #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif @@ -49,7 +43,7 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_ERR_ESPNOW_INTERNAL: return LOG_STR("Internal Error"); case ESP_ERR_ESPNOW_NO_MEM: - return LOG_STR("Our of memory"); + return LOG_STR("Out of memory"); case ESP_ERR_ESPNOW_NOT_FOUND: return LOG_STR("Peer not found"); case ESP_ERR_ESPNOW_IF: @@ -57,96 +51,91 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_OK: return LOG_STR("OK"); case ESP_NOW_SEND_FAIL: + case ESP_FAIL: return LOG_STR("Failed"); default: return LOG_STR("Unknown Error"); } } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) -void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) -#else -void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) -#endif -{ +// The analyzer traces a leak on the failed-push path, which cannot happen: the +// pools are sized to the queue capacity (SIZE-1), so allocate() returns nullptr +// before push() can find the ring full. +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void ESPNowComponent::send_reported(const uint8_t *mac_addr, esp_now_send_status_t status) { // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } -// Load new packet data (replaces previous packet) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - packet->load_sent_data(info->des_addr, status); -#else packet->load_sent_data(mac_addr, status); -#endif - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW send event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } -void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { +void ESPNowComponent::packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, + int8_t rssi, uint32_t timestamp) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // but the receive buffer only fits frames up to ``max_payload_size``; copying a // larger frame would overflow packet_.receive.data. - if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + if (size < 0 || static_cast(size) > ESPNOW_MAX_DATA_LEN) { + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } - // Load new packet data (replaces previous packet) - packet->load_received_data(info, data, size); + packet->load_received_data(src_addr, des_addr, data, size, rssi, timestamp); - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW receive event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } +// NOLINTEND(clang-analyzer-unix.Malloc) ESPNowComponent::ESPNowComponent() { global_esp_now = this; } void ESPNowComponent::dump_config() { ESP_LOGCONFIG(TAG, "espnow:"); // Only report driver details once enabled; with enable_on_boot: false the - // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // Wi-Fi driver is not initialized yet and the version call would crash, // and after a failed enable_() the values would be meaningless. if (this->state_ != ESPNOW_STATE_ENABLED) { // OFF here means enable_() failed; the core logs the FAILED marker separately ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } - uint32_t version = 0; - esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, " Own address: %s\n" - " Version: v%" PRIu32 "\n" " Wi-Fi channel: %d", - own_addr_buf, version, this->wifi_channel_); + own_addr_buf, this->wifi_channel_); + uint32_t version = platform::get_version(); + if (version != 0) { + ESP_LOGCONFIG(TAG, " Version: v%" PRIu32, version); + } #ifdef USE_WIFI ESP_LOGCONFIG(TAG, " Wi-Fi enabled: %s", YESNO(this->is_wifi_enabled())); #endif @@ -183,6 +172,10 @@ void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::spanwifi_channel_ != old_channel) { ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_); } + // Peers added while the station was scanning were bound to the channel of that moment, so rebind on every connect + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } #endif @@ -198,41 +191,19 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); - ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); - ESP_ERROR_CHECK(esp_wifi_start()); - ESP_ERROR_CHECK(esp_wifi_disconnect()); - + platform::init_radio(); this->apply_wifi_channel(); } this->get_wifi_channel(); - esp_err_t err = esp_now_init(); + esp_err_t err = platform::init(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Init failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - err = esp_now_register_recv_cb(on_data_received); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = esp_now_register_send_cb(on_send_report); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); + platform::read_mac(this->own_address_); this->state_ = ESPNOW_STATE_ENABLED; @@ -248,12 +219,9 @@ void ESPNowComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = ESPNOW_STATE_DISABLED; - esp_now_unregister_recv_cb(); - esp_now_unregister_send_cb(); - - esp_err_t err = esp_now_deinit(); + esp_err_t err = platform::deinit(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); + ESP_LOGE(TAG, "Deinit failed: %s", esp_err_to_name(err)); } } @@ -271,9 +239,10 @@ void ESPNowComponent::apply_wifi_channel() { } ESP_LOGI(TAG, "Channel set to %d.", this->wifi_channel_); - esp_wifi_set_promiscuous(true); - esp_wifi_set_channel(this->wifi_channel_, WIFI_SECOND_CHAN_NONE); - esp_wifi_set_promiscuous(false); + platform::set_channel(this->wifi_channel_); + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } void ESPNowComponent::loop() { @@ -283,7 +252,7 @@ void ESPNowComponent::loop() { switch (packet->type_) { case ESPNowPacket::RECEIVED: { const ESPNowRecvInfo info = packet->get_receive_info(); - if (!esp_now_is_peer_exist(info.src_addr)) { + if (!platform::peer_exists(info.src_addr)) { bool handled = false; for (auto *handler : this->unknown_peer_handlers_) { if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) { @@ -296,7 +265,7 @@ void ESPNowComponent::loop() { } } // Intentionally left as if instead of else in case the peer is added above - if (esp_now_is_peer_exist(info.src_addr)) { + if (platform::peer_exists(info.src_addr)) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -309,7 +278,8 @@ void ESPNowComponent::loop() { format_hex_pretty_to(hex_buf, packet->packet_.receive.data, std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif - if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + // Without a destination address every packet goes to the receive handlers + if (ESPNOW_REPORTS_DESTINATION && memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { if (handler->on_broadcast(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers @@ -374,24 +344,25 @@ void ESPNowComponent::loop() { } uint8_t ESPNowComponent::get_wifi_channel() { - wifi_second_chan_t dummy; - esp_wifi_get_channel(&this->wifi_channel_, &dummy); + this->wifi_channel_ = platform::get_channel(); return this->wifi_channel_; } +// Same analyzer false positive as above: the send pool is sized to the send queue's capacity +// NOLINTBEGIN(clang-analyzer-unix.Malloc) esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback) { + send_callback_t callback) { if (this->state_ != ESPNOW_STATE_ENABLED) { return ESP_ERR_ESPNOW_NOT_INIT; } else if (this->is_failed()) { return ESP_ERR_ESPNOW_FAILED; - } else if (peer_address == 0ULL) { + } else if (peer_address == nullptr) { return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; - } else if (!esp_now_is_peer_exist(peer_address)) { + } else if (!platform::peer_exists(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { esp_err_t err = this->add_peer(peer_address); if (err != ESP_OK) { @@ -410,7 +381,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl return ESP_ERR_ESPNOW_NO_MEM; } // Load the packet data - packet->load_data(peer_address, payload, size, callback); + packet->load_data(peer_address, payload, size, std::move(callback)); // Push the packet to the send queue this->send_packet_queue_.push(packet); // Loop may be disabled while idle; re-enable it to send the packet @@ -418,6 +389,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl this->enable_loop_soon_any_context(); return ESP_OK; } +// NOLINTEND(clang-analyzer-unix.Malloc) void ESPNowComponent::send_() { ESPNowSendPacket *packet = this->send_packet_queue_.pop(); @@ -426,7 +398,7 @@ void ESPNowComponent::send_() { } this->current_send_packet_ = packet; - esp_err_t err = esp_now_send(packet->address_, packet->data_, packet->size_); + esp_err_t err = platform::send(packet->address_, packet->data_, packet->size_); if (err != ESP_OK) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(packet->address_, addr_buf); @@ -451,13 +423,8 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { return ESP_ERR_INVALID_MAC; } - if (!esp_now_is_peer_exist(peer)) { - esp_now_peer_info_t peer_info = {}; - memset(&peer_info, 0, sizeof(esp_now_peer_info_t)); - peer_info.ifidx = WIFI_IF_STA; - memcpy(peer_info.peer_addr, peer, ESP_NOW_ETH_ALEN); - esp_err_t err = esp_now_add_peer(&peer_info); - + if (!platform::peer_exists(peer)) { + esp_err_t err = platform::add_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); @@ -486,8 +453,8 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { return ESP_ERR_ESPNOW_NOT_INIT; } - if (esp_now_is_peer_exist(peer)) { - esp_err_t err = esp_now_del_peer(peer); + if (platform::peer_exists(peer)) { + esp_err_t err = platform::del_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); @@ -507,4 +474,4 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index 375bd4faefe..b912233eed0 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -4,21 +4,17 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/event_pool.h" #include "esphome/core/lock_free_queue.h" #include "espnow_packet.h" +#include "espnow_types.h" #if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) #include "esphome/components/wifi/wifi_component.h" #endif -#include - -#include -#include - #include #include #include @@ -27,10 +23,6 @@ namespace esphome::espnow { -// Maximum size of the ESPNow event queue - must be power of 2 for lock-free queue -static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; -static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; - using peer_address_t = std::array; enum class ESPNowTriggers : uint8_t { @@ -152,12 +144,10 @@ class ESPNowComponent final : public Component { /// @param payload Data payload to send /// @param callback Callback to call when the send operation is complete /// @return ESP_OK on success, or an error code on failure - esp_err_t send(const uint8_t *peer_address, const std::vector &payload, - const send_callback_t &callback = nullptr) { - return this->send(peer_address, payload.data(), payload.size(), callback); + esp_err_t send(const uint8_t *peer_address, const std::vector &payload, send_callback_t callback = nullptr) { + return this->send(peer_address, payload.data(), payload.size(), std::move(callback)); } - esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback = nullptr); + esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t callback = nullptr); void register_receive_handler(ESPNowReceivedPacketHandler *handler) { this->receive_handlers_.push_back(handler); } void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { @@ -165,14 +155,13 @@ class ESPNowComponent final : public Component { } void register_broadcast_handler(ESPNowBroadcastHandler *handler) { this->broadcast_handlers_.push_back(handler); } - protected: - friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - friend void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status); -#else - friend void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status); -#endif + // Entry points for the radio driver callbacks in espnow_platform_*.cpp; they run outside the main loop and + // only queue the event + void packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, int8_t rssi, + uint32_t timestamp); + void send_reported(const uint8_t *mac_addr, esp_now_send_status_t status); + protected: void enable_(); void send_(); @@ -205,4 +194,4 @@ extern ESPNowComponent *global_esp_now; // NOLINT(cppcoreguidelines-avoid-non-c } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_err.h b/esphome/components/espnow/espnow_err.h index ceda1b7683e..8db38b800ef 100644 --- a/esphome/components/espnow/espnow_err.h +++ b/esphome/components/espnow/espnow_err.h @@ -1,19 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) -#include -#include +#include "espnow_types.h" namespace esphome::espnow { -static const esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); -static const esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); -static const esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); -static const esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); +static constexpr esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); +static constexpr esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); +static constexpr esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); +static constexpr esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index fb125864fb6..d9f4a73f931 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -1,19 +1,17 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_err.h" +#include "espnow_types.h" #include #include #include #include +#include #include -#include -#include -#include - namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; @@ -36,16 +34,10 @@ static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); #endif -struct WifiPacketRxControl { - int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm - uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or - // light sleep is not enabled -}; - struct ESPNowRecvInfo { uint8_t src_addr[ESP_NOW_ETH_ALEN]; /**< Source address of ESPNOW packet */ uint8_t des_addr[ESP_NOW_ETH_ALEN]; /**< Destination address of ESPNOW packet */ - wifi_pkt_rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ + rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ }; using send_callback_t = std::function; @@ -58,34 +50,28 @@ class ESPNowPacket { SENT, }; - // Constructor for received data - ESPNowPacket(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - this->init_received_data_(info, data, size); - }; - -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - // Constructor for sent data - ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { - this->init_sent_data_(info->src_addr, status); - } -#else - // Constructor for sent data - ESPNowPacket(const uint8_t *mac_addr, esp_now_send_status_t status) { this->init_sent_data_(mac_addr, status); } -#endif - // Default constructor for pre-allocation in pool ESPNowPacket() {} void release() {} - void load_received_data(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + void load_received_data(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, uint16_t size, + int8_t rssi, uint32_t timestamp) { this->type_ = RECEIVED; - this->init_received_data_(info, data, size); + memcpy(this->packet_.receive.info.src_addr, src_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.info.des_addr, des_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.data, data, size); + this->packet_.receive.size = size; + + this->packet_.receive.rx_ctrl.rssi = rssi; + this->packet_.receive.rx_ctrl.timestamp = timestamp; + this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); } void load_sent_data(const uint8_t *mac_addr, esp_now_send_status_t status) { this->type_ = SENT; - this->init_sent_data_(mac_addr, status); + memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); + this->packet_.sent.status = status; } // Disable copy to prevent double-delete @@ -112,24 +98,6 @@ class ESPNowPacket { esp_now_packet_type_t type() const { return this->type_; } const ESPNowRecvInfo &get_receive_info() const { return this->packet_.receive.info; } - - private: - void init_received_data_(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - memcpy(this->packet_.receive.info.src_addr, info->src_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.info.des_addr, info->des_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.data, data, size); - this->packet_.receive.size = size; - - this->packet_.receive.rx_ctrl.rssi = info->rx_ctrl->rssi; - this->packet_.receive.rx_ctrl.timestamp = info->rx_ctrl->timestamp; - - this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); - } - - void init_sent_data_(const uint8_t *mac_addr, esp_now_send_status_t status) { - memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); - this->packet_.sent.status = status; - } }; class ESPNowSendPacket { @@ -151,9 +119,9 @@ class ESPNowSendPacket { ESPNowSendPacket(const ESPNowSendPacket &) = delete; ESPNowSendPacket &operator=(const ESPNowSendPacket &) = delete; - void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t &&callback) { this->init_data_(peer_address, payload, size); - this->callback_ = callback; + this->callback_ = std::move(callback); } void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size) { @@ -180,4 +148,4 @@ class ESPNowSendPacket { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform.h b/esphome/components/espnow/espnow_platform.h new file mode 100644 index 00000000000..bc19f34a7cd --- /dev/null +++ b/esphome/components/espnow/espnow_platform.h @@ -0,0 +1,34 @@ +#pragma once + +#if defined(USE_ESP32) || defined(USE_ESP8266) + +#include "espnow_types.h" + +#include + +// The radio and ESP-NOW driver calls that differ between ESP-IDF and the ESP8266 NONOS SDK. Each platform +// implements the whole set in its own source file, so the component itself carries no platform checks. +namespace esphome::espnow::platform { + +// Bring up the station interface when no wifi component owns the radio +void init_radio(); +void set_channel(uint8_t channel); +uint8_t get_channel(); +void read_mac(uint8_t *mac); + +// Start the driver and route its callbacks to the component +esp_err_t init(); +esp_err_t deinit(); +uint32_t get_version(); // 0 where the SDK has no version call + +bool peer_exists(const uint8_t *mac); +esp_err_t add_peer(const uint8_t *mac); +esp_err_t del_peer(const uint8_t *mac); +// Move the registered peers to the radio's current channel; only the NONOS SDK binds peers to a channel +void rebind_peers(); + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size); + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform_esp32.cpp b/esphome/components/espnow/espnow_platform_esp32.cpp new file mode 100644 index 00000000000..b45c09b072e --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp32.cpp @@ -0,0 +1,96 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP32 + +#include "espnow_component.h" + +#include +#include +#include + +#include + +namespace esphome::espnow::platform { + +static void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + global_esp_now->packet_received(info->src_addr, info->des_addr, data, size, info->rx_ctrl->rssi, + info->rx_ctrl->timestamp); +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +static void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) { + global_esp_now->send_reported(info->des_addr, status); +} +#else +static void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) { + global_esp_now->send_reported(mac_addr, status); +} +#endif + +void init_radio() { + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_disconnect()); +} + +void set_channel(uint8_t channel) { + esp_wifi_set_promiscuous(true); + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + esp_wifi_set_promiscuous(false); +} + +uint8_t get_channel() { + uint8_t channel = 0; + wifi_second_chan_t second; + esp_wifi_get_channel(&channel, &second); + return channel; +} + +void read_mac(uint8_t *mac) { esp_wifi_get_mac(WIFI_IF_STA, mac); } + +esp_err_t init() { + esp_err_t err = esp_now_init(); + if (err == ESP_OK) { + err = esp_now_register_recv_cb(on_data_received); + } + if (err == ESP_OK) { + err = esp_now_register_send_cb(on_send_report); + } + return err; +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return esp_now_deinit(); +} + +uint32_t get_version() { + uint32_t version = 0; + esp_now_get_version(&version); + return version; +} + +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(mac); } + +esp_err_t add_peer(const uint8_t *mac) { + esp_now_peer_info_t peer_info = {}; + peer_info.ifidx = WIFI_IF_STA; + memcpy(peer_info.peer_addr, mac, ESP_NOW_ETH_ALEN); + return esp_now_add_peer(&peer_info); +} + +esp_err_t del_peer(const uint8_t *mac) { return esp_now_del_peer(mac); } + +// Peers are added on channel 0, which ESP-IDF treats as whatever channel the radio is on +void rebind_peers() {} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { return esp_now_send(mac, data, size); } + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_platform_esp8266.cpp b/esphome/components/espnow/espnow_platform_esp8266.cpp new file mode 100644 index 00000000000..736e8788375 --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp8266.cpp @@ -0,0 +1,105 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP8266 + +#include "espnow_component.h" + +namespace esphome::espnow::platform { + +// The SDK reports 0 for success and a non-zero value for any failure +static esp_err_t sdk_result(int result) { return result == 0 ? ESP_OK : ESP_FAIL; } + +// Stands in for the destination address, which the SDK does not report +static const uint8_t UNKNOWN_ADDR[ESP_NOW_ETH_ALEN] = {0}; + +static void on_data_received(uint8_t *mac_addr, uint8_t *data, uint8_t size) { + global_esp_now->packet_received(mac_addr, UNKNOWN_ADDR, data, size, 0, 0); +} + +static void on_send_report(uint8_t *mac_addr, uint8_t status) { + global_esp_now->send_reported(mac_addr, status == 0 ? ESP_NOW_SEND_SUCCESS : ESP_NOW_SEND_FAIL); +} + +void init_radio() { + // The NONOS SDK only delivers broadcast frames to ESP-NOW while the station is associated or the + // soft-AP interface is up. Without Wi-Fi, run a hidden, open soft-AP that accepts no clients so + // broadcasts (for example from a WizMote style remote) are received. + softap_config ap{}; + ap.channel = 1; + ap.authmode = AUTH_OPEN; + ap.ssid_hidden = 1; + ap.max_connection = 0; + ap.beacon_interval = 60000; + wifi_set_opmode_current(STATIONAP_MODE); + wifi_softap_set_config_current(&ap); + wifi_softap_dhcps_stop(); + wifi_set_sleep_type(NONE_SLEEP_T); + wifi_station_disconnect(); +} + +void set_channel(uint8_t channel) { + wifi_promiscuous_enable(true); + wifi_set_channel(channel); + wifi_promiscuous_enable(false); + // Keep the soft-AP from init_radio() on the same channel so the SDK never retunes the radio to its own + softap_config ap{}; + if (wifi_get_opmode() == STATIONAP_MODE && wifi_softap_get_config(&ap) && ap.channel != channel) { + ap.channel = channel; + wifi_softap_set_config_current(&ap); + } +} + +uint8_t get_channel() { return wifi_get_channel(); } + +void read_mac(uint8_t *mac) { + // Frames leave from the station interface unless the device runs as a soft-AP only + wifi_get_macaddr(wifi_get_opmode() == SOFTAP_MODE ? SOFTAP_IF : STATION_IF, mac); +} + +esp_err_t init() { + int result = esp_now_init(); + if (result == 0) { + // CONTROLLER sends from the station interface; the other roles send from the soft-AP MAC whenever + // that interface is up, so peers would no longer recognise the sender. + result = esp_now_set_self_role(ESP_NOW_ROLE_CONTROLLER); + } + if (result == 0) { + result = esp_now_register_recv_cb(on_data_received); + } + if (result == 0) { + result = esp_now_register_send_cb(on_send_report); + } + return sdk_result(result); +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return sdk_result(esp_now_deinit()); +} + +uint32_t get_version() { return 0; } + +// The SDK returns 1 when the peer exists, 0 when it does not and a negative value on error +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(const_cast(mac)) > 0; } + +esp_err_t add_peer(const uint8_t *mac) { + return sdk_result(esp_now_add_peer(const_cast(mac), ESP_NOW_ROLE_COMBO, wifi_get_channel(), nullptr, 0)); +} + +esp_err_t del_peer(const uint8_t *mac) { return sdk_result(esp_now_del_peer(const_cast(mac))); } + +void rebind_peers() { + const uint8_t channel = wifi_get_channel(); + for (uint8_t *mac = esp_now_fetch_peer(true); mac != nullptr; mac = esp_now_fetch_peer(false)) { + esp_now_set_peer_channel(mac, channel); + } +} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { + return sdk_result(esp_now_send(const_cast(mac), const_cast(data), size)); +} + +} // namespace esphome::espnow::platform + +#endif // USE_ESP8266 diff --git a/esphome/components/espnow/espnow_types.h b/esphome/components/espnow/espnow_types.h new file mode 100644 index 00000000000..4b9c65fecf3 --- /dev/null +++ b/esphome/components/espnow/espnow_types.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include + +#if defined(USE_ESP32) + +#include +#include + +#elif defined(USE_ESP8266) + +extern "C" { +#include +#include +#include +} + +#endif + +namespace esphome::espnow { + +struct WifiPacketRxControl { + int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm + uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or + // light sleep is not enabled +}; + +#if defined(USE_ESP32) + +// Handlers read rx_ctrl through the ESP-IDF type so existing lambdas keep compiling +using rx_ctrl_t = wifi_pkt_rx_ctrl_t; +static constexpr bool ESPNOW_REPORTS_DESTINATION = true; + +// Maximum size of the ESPNow event queues - must be a power of 2 for the lock-free queue +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; + +#elif defined(USE_ESP8266) + +// The NONOS SDK has no esp_err_t vocabulary. These are the ESP-IDF names and values the shared espnow code +// uses, so it reads the same on both platforms. +using esp_err_t = int32_t; + +static constexpr esp_err_t ESP_OK = 0; +static constexpr esp_err_t ESP_FAIL = -1; +static constexpr esp_err_t ESP_ERR_INVALID_MAC = 0x10B; +static constexpr esp_err_t ESP_ERR_ESPNOW_BASE = 0x3064; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_INIT = ESP_ERR_ESPNOW_BASE + 1; +static constexpr esp_err_t ESP_ERR_ESPNOW_ARG = ESP_ERR_ESPNOW_BASE + 2; +static constexpr esp_err_t ESP_ERR_ESPNOW_NO_MEM = ESP_ERR_ESPNOW_BASE + 3; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_FOUND = ESP_ERR_ESPNOW_BASE + 5; +static constexpr esp_err_t ESP_ERR_ESPNOW_INTERNAL = ESP_ERR_ESPNOW_BASE + 6; +static constexpr esp_err_t ESP_ERR_ESPNOW_IF = ESP_ERR_ESPNOW_BASE + 8; + +inline const char *esp_err_to_name(esp_err_t err) { return err == ESP_OK ? "ESP_OK" : "ESP_FAIL"; } + +static constexpr uint8_t ESP_NOW_ETH_ALEN = 6; +static constexpr uint8_t ESP_NOW_MAX_DATA_LEN = 250; + +// NOLINTNEXTLINE(readability-identifier-naming) +enum esp_now_send_status_t : uint8_t { + ESP_NOW_SEND_SUCCESS = 0, + ESP_NOW_SEND_FAIL = 1, +}; + +using rx_ctrl_t = WifiPacketRxControl; +// The SDK reports neither the destination address nor the signal strength of a received frame +static constexpr bool ESPNOW_REPORTS_DESTINATION = false; + +// Half the ESP32 depth: the heap is far smaller and only one send is ever in flight +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 8; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 8; + +#endif + +} // namespace esphome::espnow diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index 7053229db42..93bc181a185 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -1,10 +1,12 @@ #include "espnow_transport.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/application.h" #include "esphome/core/log.h" +#include + namespace esphome::espnow { ESPHOME_LOG_TAG(TAG, "espnow.transport"); @@ -64,9 +66,7 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } @@ -79,12 +79,10 @@ bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *da return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 51069b64153..79938f8df2e 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -2,7 +2,7 @@ #include "../espnow_component.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/component.h" #include "esphome/components/packet_transport/packet_transport.h" @@ -33,9 +33,8 @@ class ESPNowTransport final : public packet_transport::PacketTransport, bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; - std::vector packet_buffer_; }; } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/core/event_pool.h b/esphome/core/event_pool.h index b53b8064a3d..77b47228a97 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) || \ + defined(USE_ESP8266) #include #include diff --git a/tests/components/espnow/common-esp32.yaml b/tests/components/espnow/common-esp32.yaml new file mode 100644 index 00000000000..a97a5ef8aeb --- /dev/null +++ b/tests/components/espnow/common-esp32.yaml @@ -0,0 +1,9 @@ +espnow: + max_payload_size: 1470 + on_broadcast: + - logger.log: + format: "Broadcast from: %s = '%s' RSSI: %d" + args: + - format_mac_address_pretty(info.src_addr).c_str() + - format_hex_pretty(data, size).c_str() + - info.rx_ctrl->rssi diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 973781fb68b..54b089116f7 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -2,7 +2,6 @@ espnow: id: espnow_component auto_add_peer: false channel: 1 - max_payload_size: 1470 peers: - 11:22:33:44:55:66 on_receive: @@ -50,13 +49,6 @@ espnow: - espnow.set_channel: 6 - espnow.set_channel: channel: !lambda 'return 6;' - on_broadcast: - - logger.log: - format: "Broadcast from: %s = '%s' RSSI: %d" - args: - - format_mac_address_pretty(info.src_addr).c_str() - - format_hex_pretty(data, size).c_str() - - info.rx_ctrl->rssi on_unknown_peer: - logger.log: format: "Unknown peer: %s = '%s' RSSI: %d" @@ -80,8 +72,9 @@ packet_transport: key: "0123456789abcdef0123456789abcdef" sensor: - - platform: internal_temperature + - platform: template id: espnow_temp_sensor + lambda: return 21.5; - platform: packet_transport transport_id: transport1 diff --git a/tests/components/espnow/test-wifi.esp8266-ard.yaml b/tests/components/espnow/test-wifi.esp8266-ard.yaml new file mode 100644 index 00000000000..c45547cd533 --- /dev/null +++ b/tests/components/espnow/test-wifi.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common-wifi.yaml diff --git a/tests/components/espnow/test.esp32-idf.yaml b/tests/components/espnow/test.esp32-idf.yaml index b47e39c3898..1b3ba941b6b 100644 --- a/tests/components/espnow/test.esp32-idf.yaml +++ b/tests/components/espnow/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + espnow: !include common.yaml + espnow_esp32: !include common-esp32.yaml diff --git a/tests/components/espnow/test.esp8266-ard.yaml b/tests/components/espnow/test.esp8266-ard.yaml new file mode 100644 index 00000000000..ea7d1f197e1 --- /dev/null +++ b/tests/components/espnow/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + espnow: !include common.yaml diff --git a/tests/unit_tests/components/test_espnow.py b/tests/unit_tests/components/test_espnow.py index 21305c2b332..762e660139c 100644 --- a/tests/unit_tests/components/test_espnow.py +++ b/tests/unit_tests/components/test_espnow.py @@ -7,16 +7,29 @@ from esphome.components.esp32.const import ( VARIANT_ESP32H2, VARIANT_ESP32P4, ) -from esphome.components.espnow import _validate_variant +from esphome.components.espnow import CONFIG_SCHEMA, _validate_variant import esphome.config_validation as cv +from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) +from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType def _run( - monkeypatch, variant: str, full_config: dict, config: ConfigType + monkeypatch, + variant: str, + full_config: dict, + config: ConfigType, + platform: str = PLATFORM_ESP32, ) -> ConfigType: monkeypatch.setattr("esphome.components.espnow.get_esp32_variant", lambda: variant) + monkeypatch.setitem(CORE.data, KEY_CORE, {KEY_TARGET_PLATFORM: platform}) token = fv.full_config.set(full_config) try: return _validate_variant(config) @@ -46,3 +59,39 @@ def test_p4_with_esp32_hosted_passes(monkeypatch) -> None: """The P4 with esp32_hosted present validates; config passes through.""" config = {"id": "espnow"} assert _run(monkeypatch, VARIANT_ESP32P4, {"esp32_hosted": {}}, config) is config + + +def test_esp8266_skips_variant_check(monkeypatch) -> None: + """The ESP8266 has one Wi-Fi radio and no variants, so the check does not run.""" + config = {"id": "espnow"} + assert _run(monkeypatch, "", {}, config, platform=PLATFORM_ESP8266) is config + + +def _esp8266_arduino(monkeypatch) -> None: + monkeypatch.setitem( + CORE.data, + KEY_CORE, + {KEY_TARGET_PLATFORM: PLATFORM_ESP8266, KEY_TARGET_FRAMEWORK: "arduino"}, + ) + + +def test_esp8266_accepts_the_v1_payload_size(monkeypatch) -> None: + _esp8266_arduino(monkeypatch) + assert ( + CONFIG_SCHEMA({"channel": 1, "max_payload_size": 250})["max_payload_size"] + == 250 + ) + + +def test_esp8266_rejects_v2_payload_sizes(monkeypatch) -> None: + """The NONOS SDK only speaks ESP-NOW v1, so larger frames are refused at validation.""" + _esp8266_arduino(monkeypatch) + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"channel": 1, "max_payload_size": 251}) + + +def test_esp8266_rejects_on_broadcast(monkeypatch) -> None: + """The NONOS SDK does not report a destination address, so broadcasts cannot be told apart.""" + _esp8266_arduino(monkeypatch) + with pytest.raises(cv.Invalid, match="only available on"): + CONFIG_SCHEMA({"channel": 1, "on_broadcast": []}) From 254690100c4768e592017c47550fbf8dbcce69ef Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Sat, 10 Oct 2026 20:52:24 -0700 Subject: [PATCH 31/97] [hub75] add set_rotation and get_rotation methods (#13921) Co-authored-by: J. Nick Koston --- esphome/components/display/display.h | 4 +- esphome/components/hub75/display.py | 6 +-- esphome/components/hub75/hub75.cpp | 40 +++++++++++++++++++ esphome/components/hub75/hub75_component.h | 5 ++- .../hub75/test.esp32-s3-idf-rotate.yaml | 7 ++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index cc75eb70b8f..74b5bcedd94 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -294,7 +294,7 @@ using display_writer_t = DisplayWriter; prefix type "\n" \ "%s Rotations: %d °\n" \ "%s Dimensions: %dpx x %dpx", \ - prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \ + prefix, log_display_obj->get_rotation(), prefix, log_display_obj->get_width(), \ log_display_obj->get_height()); \ } @@ -710,7 +710,7 @@ class Display : public PollingComponent { // Internal method to set display auto clearing. void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } - DisplayRotation get_rotation() const { return this->rotation_; } + virtual DisplayRotation get_rotation() const { return this->rotation_; } /** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays, * returns the type the display is currently configured to. diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 08d068d0f03..823928176a1 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -601,10 +601,8 @@ async def to_code(config: ConfigType) -> None: pins_struct = _build_pins_struct(pin_expressions, e_pin_num) hub75_config = _build_config_struct(config, pins_struct, min_refresh) - # Rotation is handled by the hub75 driver (config_.rotation already set above). - # Force rotation to 0 for ESPHome's Display base class to avoid double-rotation. - if CONF_ROTATION in config: - config[CONF_ROTATION] = 0 + # The driver rotates the panel itself (config_.rotation above), so the base class must not rotate too + config.pop(CONF_ROTATION, None) # Create display and register var = cg.new_Pvariable(config[CONF_ID], hub75_config) diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index 674e23cb344..226b59a2912 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -202,6 +202,46 @@ void HUB75Display::set_brightness(uint8_t brightness) { } } +static Hub75Rotation to_hub75_rotation(display::DisplayRotation rotation) { + switch (rotation) { + case display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES: + return Hub75Rotation::ROTATE_90; + case display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES: + return Hub75Rotation::ROTATE_180; + case display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES: + return Hub75Rotation::ROTATE_270; + default: + return Hub75Rotation::ROTATE_0; + } +} + +static display::DisplayRotation to_display_rotation(Hub75Rotation rotation) { + switch (rotation) { + case Hub75Rotation::ROTATE_90: + return display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES; + case Hub75Rotation::ROTATE_180: + return display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES; + case Hub75Rotation::ROTATE_270: + return display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES; + default: + return display::DisplayRotation::DISPLAY_ROTATION_0_DEGREES; + } +} + +// Before setup() creates the driver the configuration holds the rotation, afterwards the driver does +void HUB75Display::set_rotation(display::DisplayRotation rotation) { + const Hub75Rotation hub75_rotation = to_hub75_rotation(rotation); + if (this->driver_ == nullptr) { + this->config_.rotation = hub75_rotation; + return; + } + this->driver_->set_rotation(hub75_rotation); +} + +display::DisplayRotation HUB75Display::get_rotation() const { + return to_display_rotation(this->driver_ == nullptr ? this->config_.rotation : this->driver_->get_rotation()); +} + } // namespace esphome::hub75 #endif diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index dbadc56c9df..122e8960fb0 100644 --- a/esphome/components/hub75/hub75_component.h +++ b/esphome/components/hub75/hub75_component.h @@ -36,6 +36,9 @@ class HUB75Display final : public display::Display { // Brightness control (runtime mutable) void set_brightness(uint8_t brightness); + void set_rotation(display::DisplayRotation rotation) override; + display::DisplayRotation get_rotation() const override; + protected: // Display internal methods int get_width_internal() override { return this->driver_ != nullptr ? this->driver_->get_width() : 0; } @@ -43,7 +46,7 @@ class HUB75Display final : public display::Display { // Member variables Hub75Driver *driver_{nullptr}; - Hub75Config config_; // Immutable configuration + Hub75Config config_; // Fixed after setup(); only the rotation can change before the driver exists // Runtime state (mutable) uint8_t brightness_{128}; diff --git a/tests/components/hub75/test.esp32-s3-idf-rotate.yaml b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml index 9855fcb4e69..1a08549a2fe 100644 --- a/tests/components/hub75/test.esp32-s3-idf-rotate.yaml +++ b/tests/components/hub75/test.esp32-s3-idf-rotate.yaml @@ -37,3 +37,10 @@ display: } <<: !include common.yaml + +script: + - id: rotate_hub75 + then: + - lambda: |- + id(my_hub75).set_rotation(display::DISPLAY_ROTATION_180_DEGREES); + ESP_LOGD("test", "rotation now %d", static_cast(id(my_hub75).get_rotation())); From cb54f6d6cc3ac77e023c36a2f3664ba64a79af57 Mon Sep 17 00:00:00 2001 From: opcod3 Date: Sun, 11 Oct 2026 11:13:21 +0200 Subject: [PATCH 32/97] [daikin] Add presets and half-degree temperature steps (#17891) Co-authored-by: James Nimmo Co-authored-by: J. Nick Koston --- esphome/components/daikin/daikin.cpp | 130 ++++- esphome/components/daikin/daikin.h | 40 +- tests/components/daikin/__init__.py | 8 + tests/components/daikin/benchmark.yaml | 4 + tests/components/daikin/common.yaml | 11 +- .../daikin/daikin_regression_tests.cpp | 477 ++++++++++++++++++ tests/components/daikin/daikin_tests.cpp | 440 ++++++++++++++++ tests/components/daikin/test.esp32-idf.yaml | 4 + tests/components/daikin/test.esp8266-ard.yaml | 4 +- 9 files changed, 1084 insertions(+), 34 deletions(-) create mode 100644 tests/components/daikin/__init__.py create mode 100644 tests/components/daikin/benchmark.yaml create mode 100644 tests/components/daikin/daikin_regression_tests.cpp create mode 100644 tests/components/daikin/daikin_tests.cpp create mode 100644 tests/components/daikin/test.esp32-idf.yaml diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 7a1310b9ac1..0194b34db09 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,25 +1,47 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" namespace esphome::daikin { ESPHOME_LOG_TAG(TAG, "daikin.climate"); +static constexpr uint32_t BOOST_TIMEOUT_ID = 0; + +static uint8_t sum_bytes(const uint8_t *data, size_t len) { + uint8_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum += data[i]; + } + return sum; +} + +void DaikinClimate::setup() { + climate_ir::ClimateIR::setup(); + // A boost preset restored from flash gets its timer too + this->update_boost_timer_(false); +} + +void DaikinClimate::control(const climate::ClimateCall &call) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + climate_ir::ClimateIR::control(call); + this->update_boost_timer_(was_boost); +} + void DaikinClimate::transmit_state() { - uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00, + uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0x00, 0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2, 0x11, 0xDA, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00}; + this->set_presets_(remote_state); remote_state[21] = this->operation_mode_(); remote_state[22] = this->temperature_(); uint16_t fan_speed = this->fan_speed_(); remote_state[24] = fan_speed >> 8; remote_state[25] = fan_speed & 0xff; - // Calculate checksum - for (int i = 16; i < 34; i++) { - remote_state[34] += remote_state[i]; - } + remote_state[7] = sum_bytes(remote_state, 7); + remote_state[34] = sum_bytes(remote_state + 16, 18); auto transmit = this->transmitter_->transmit(); auto *data = transmit.get_data(); @@ -136,18 +158,52 @@ uint8_t DaikinClimate::temperature_() const { case climate::CLIMATE_MODE_DRY: return 0xc0; default: - uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX)); - return temperature << 1; + return static_cast( + roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX) * 2.0f)); } } -bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; +void DaikinClimate::set_presets_(uint8_t *remote_state) const { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + remote_state[29] = DAIKIN_PRESET_POWERFUL_ON; + break; + case climate::CLIMATE_PRESET_SLEEP: + remote_state[29] = DAIKIN_PRESET_QUIET_ON; + break; + case climate::CLIMATE_PRESET_ECO: + remote_state[32] = DAIKIN_PRESET_ECONO_ON; + break; + case climate::CLIMATE_PRESET_ACTIVITY: + remote_state[32] = DAIKIN_PRESET_SENSOR_ON; + break; + case climate::CLIMATE_PRESET_COMFORT: + remote_state[6] = DAIKIN_PRESET_COMFORT_ON; // the only preset carried by the first frame + break; + default: + break; } - if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) - return false; +} + +void DaikinClimate::update_boost_timer_(bool was_boost) { + const bool is_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + if (is_boost && !was_boost) { + this->set_timeout(BOOST_TIMEOUT_ID, DAIKIN_BOOST_DURATION_MS, [this]() { this->cancel_boost_mode_(); }); + } else if (was_boost && !is_boost) { + this->cancel_timeout(BOOST_TIMEOUT_ID); + } +} + +void DaikinClimate::cancel_boost_mode_() { + if (this->preset != climate::CLIMATE_PRESET_BOOST) + return; + ESP_LOGD(TAG, "Boost ended"); + this->preset = climate::CLIMATE_PRESET_NONE; + this->publish_state(); +} + +bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; uint8_t mode = frame[5]; // Temperature is given in degrees celcius * 2 // only update for states that use the temperature @@ -206,16 +262,41 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { this->fan_mode = climate::CLIMATE_FAN_QUIET; break; } + + // Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid + // A comfort frame whose own state frame never came must not attach to a later transmission + const uint32_t now = App.get_loop_component_start_time(); + const PendingComfort comfort = + now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS ? this->pending_comfort_ : PendingComfort::UNKNOWN; + this->pending_comfort_ = PendingComfort::UNKNOWN; + if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (frame[13] & DAIKIN_PRESET_QUIET_ON) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (frame[16] & DAIKIN_PRESET_ECONO_ON) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) { + this->preset = climate::CLIMATE_PRESET_ACTIVITY; + } else if (comfort == PendingComfort::ON) { + this->preset = climate::CLIMATE_PRESET_COMFORT; + } else if (comfort == PendingComfort::OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { + // Without a comfort frame in this transmission a current comfort preset is left alone + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->update_boost_timer_(was_boost); + this->publish_state(); return true; } bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {}; + uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {}; if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { return false; } - for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) { + + uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE; + for (uint8_t pos = 0; pos < frame_size; pos++) { uint8_t byte = 0; for (int8_t bit = 0; bit < 8; bit++) { if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) { @@ -224,7 +305,7 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } } - state_frame[pos] = byte; + frame[pos] = byte; if (pos == 0) { // frame header if (byte != 0x11) @@ -243,11 +324,24 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else if (pos == 4) { // frame type - if (byte != 0x00) + if (byte == DAIKIN_FRAME_TYPE_COMFORT) { + frame_size = DAIKIN_COMFORT_FRAME_SIZE; + } else if (byte != DAIKIN_FRAME_TYPE_STATE) { return false; + } } } - return this->parse_state_frame_(state_frame); + if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) { + return false; + } + + if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) { + // Nothing changes yet: the state frame that follows applies it once it has passed its own checksum + this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::ON : PendingComfort::OFF; + this->pending_comfort_at_ = App.get_loop_component_start_time(); + return true; + } + return this->parse_state_frame_(frame); } } // namespace esphome::daikin diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index b7fa9923076..7f8638101d5 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -27,6 +27,17 @@ const uint8_t DAIKIN_FAN_3 = 0x50; const uint8_t DAIKIN_FAN_4 = 0x60; const uint8_t DAIKIN_FAN_5 = 0x70; +// Presets: bit flags in byte 6 (comfort), byte 29 (powerful, quiet) and byte 32 (sensor, econo) +const uint8_t DAIKIN_PRESET_COMFORT_ON = 0x10; +const uint8_t DAIKIN_PRESET_POWERFUL_ON = 0x01; +const uint8_t DAIKIN_PRESET_QUIET_ON = 0x20; +const uint8_t DAIKIN_PRESET_ECONO_ON = 0x04; +const uint8_t DAIKIN_PRESET_SENSOR_ON = 0x02; +// The unit leaves powerful mode by itself after 20 minutes; mirror that in the entity +const uint32_t DAIKIN_BOOST_DURATION_MS = 20 * 60 * 1000; +// The three frames of one transmission arrive well inside this window +const uint32_t DAIKIN_TRANSMISSION_MS = 1000; + // IR Transmission const uint32_t DAIKIN_IR_FREQUENCY = 38000; const uint32_t DAIKIN_HEADER_MARK = 3360; @@ -36,27 +47,44 @@ const uint32_t DAIKIN_ONE_SPACE = 1370; const uint32_t DAIKIN_ZERO_SPACE = 360; const uint32_t DAIKIN_MESSAGE_SPACE = 32300; -// State Frame size +// Frame types (byte 4) and sizes +const uint8_t DAIKIN_FRAME_TYPE_STATE = 0x00; +const uint8_t DAIKIN_FRAME_TYPE_COMFORT = 0xC5; const uint8_t DAIKIN_STATE_FRAME_SIZE = 19; +const uint8_t DAIKIN_COMFORT_FRAME_SIZE = 8; class DaikinClimate final : public climate_ir::ClimateIR { public: DaikinClimate() - : climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_QUIET, 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_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} + : climate_ir::ClimateIR( + DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true, + {climate::CLIMATE_FAN_QUIET, 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_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}, + {climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_COMFORT, + climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_ACTIVITY, climate::CLIMATE_PRESET_NONE}) {} protected: + void setup() override; + void control(const climate::ClimateCall &call) override; // Transmit via IR the state of this climate controller. void transmit_state() override; uint8_t operation_mode_() const; uint16_t fan_speed_() const; uint8_t temperature_() const; + void set_presets_(uint8_t *remote_state) const; + /// Arms or cancels the boost timer when the preset moves into or out of boost. + void update_boost_timer_(bool was_boost); + void cancel_boost_mode_(); // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); + + /// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame + enum class PendingComfort : uint8_t { UNKNOWN, OFF, ON }; + PendingComfort pending_comfort_{PendingComfort::UNKNOWN}; + uint32_t pending_comfort_at_{0}; }; } // namespace esphome::daikin diff --git a/tests/components/daikin/__init__.py b/tests/components/daikin/__init__.py new file mode 100644 index 00000000000..c0e24478320 --- /dev/null +++ b/tests/components/daikin/__init__.py @@ -0,0 +1,8 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness walks dependencies from the component manifest, not from the + # climate platform, so declare the platform's auto-load here. Otherwise the + # transmitter's code generation runs and the host build cannot link it. + manifest.auto_load = ["climate_ir", "climate"] diff --git a/tests/components/daikin/benchmark.yaml b/tests/components/daikin/benchmark.yaml new file mode 100644 index 00000000000..640cc34b136 --- /dev/null +++ b/tests/components/daikin/benchmark.yaml @@ -0,0 +1,4 @@ +# Registers the climate platform so USE_CLIMATE is defined for the host unit test +# build. The daikin, 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/daikin/common.yaml b/tests/components/daikin/common.yaml index fd738416862..acca04faf4a 100644 --- a/tests/components/daikin/common.yaml +++ b/tests/components/daikin/common.yaml @@ -1,9 +1,4 @@ climate: - - platform: heatpumpir - protocol: daikin - horizontal_default: middle - vertical_default: middle - name: HeatpumpIR Climate - min_temperature: 18 - max_temperature: 30 - transmitter_id: xmitr + - platform: daikin + name: Daikin AC + receiver_id: rcvr diff --git a/tests/components/daikin/daikin_regression_tests.cpp b/tests/components/daikin/daikin_regression_tests.cpp new file mode 100644 index 00000000000..fd695b9ca48 --- /dev/null +++ b/tests/components/daikin/daikin_regression_tests.cpp @@ -0,0 +1,477 @@ +// Regression tests for the behaviour the daikin climate had before presets were added. +// Every expected byte is worked out by hand from the original daikin.cpp, and this file +// only uses the API that both the original and the current component provide. +#include +#include +#include +#include +#include + +#include "esphome/components/daikin/daikin.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::daikin::regression { + +static constexpr size_t MESSAGE_SIZE = 35; +using Message = std::array; + +static constexpr int32_t HEADER_MARK = 3360; +static constexpr int32_t HEADER_SPACE = 1760; +static constexpr int32_t BIT_MARK = 520; +static constexpr int32_t ONE_SPACE = 1370; +static constexpr int32_t ZERO_SPACE = 360; +static constexpr int32_t MESSAGE_SPACE = 32300; +static constexpr uint32_t CARRIER = 38000; + +// Sum of the constant bytes 16..33 of the third frame (11 DA 27 00 00 .. 06 60 00 00 C0 00 00), +// 0x238 truncated to 8 bits. Byte 34 is this plus bytes 21, 22, 24 and 25. +static constexpr uint8_t FRAME3_CONSTANT_SUM = 0x38; + +struct Capture { + remote_base::RawTimings raw; + uint32_t carrier; +}; + +class RecordingTransmitter : public remote_base::RemoteTransmitterBase { + public: + RecordingTransmitter() : RemoteTransmitterBase(nullptr) {} + std::vector sent; + + protected: + void send_internal(uint32_t send_times, uint32_t send_wait) override { + this->sent.push_back({this->temp_.get_data(), this->temp_.get_carrier_frequency()}); + } +}; + +static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) { + for (size_t byte = 0; byte < count; byte++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + EXPECT_EQ(raw[pos], BIT_MARK) << "bit mark at " << pos; + const int32_t space = -raw[pos + 1]; + if (space == ONE_SPACE) { + value |= 1 << bit; + } else { + EXPECT_EQ(space, ZERO_SPACE) << "bit space at " << pos + 1; + } + pos += 2; + } + out[byte] = value; + } +} + +static void expect_pair(const remote_base::RawTimings &raw, size_t &pos, int32_t mark, int32_t space) { + EXPECT_EQ(raw[pos], mark) << "mark at " << pos; + EXPECT_EQ(raw[pos + 1], -space) << "space at " << pos + 1; + pos += 2; +} + +/// Decodes the three frames (8, 8 and 19 bytes) and checks every header, bit and gap timing +static Message decode(const remote_base::RawTimings &raw) { + Message msg{}; + // 3 headers + 3 trailers of two timings each, plus two timings per bit + constexpr size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE; + EXPECT_EQ(raw.size(), expected_size); + if (raw.size() != expected_size) + return msg; + size_t pos = 0; + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data(), 8); + expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE); + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data() + 8, 8); + expect_pair(raw, pos, BIT_MARK, MESSAGE_SPACE); + expect_pair(raw, pos, HEADER_MARK, HEADER_SPACE); + read_bytes(raw, pos, msg.data() + 16, 19); + expect_pair(raw, pos, BIT_MARK, 0); + return msg; +} + +/// Receiver timings for a 19-byte frame +static remote_base::RawTimings timings_for(const std::vector &bytes) { + remote_base::RawTimings raw; + raw.push_back(HEADER_MARK); + raw.push_back(-HEADER_SPACE); + for (uint8_t byte : bytes) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(BIT_MARK); + raw.push_back(-((byte >> bit) & 1 ? ONE_SPACE : ZERO_SPACE)); + } + } + raw.push_back(BIT_MARK); + raw.push_back(-MESSAGE_SPACE); + return raw; +} + +/// A 19-byte state frame (type 0x00) with its checksum in byte 18 +static std::vector state_frame(uint8_t mode, uint8_t temperature, uint8_t fan, uint8_t swing) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0x00, mode, temperature, 0x00, fan, swing, + 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00}; + uint8_t total = 0; + for (size_t i = 0; i < 18; i++) + total += f[i]; + f[18] = total; + return f; +} + +struct Rig { + RecordingTransmitter transmitter; + DaikinClimate climate; + + Rig() { + // All instances share one preference key; clear it so setup starts from defaults + if (global_preferences != nullptr) + global_preferences->reset(); + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_daikin_regression"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + Message send(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan, + climate::ClimateSwingMode swing) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_fan_mode(fan); + call.set_swing_mode(swing); + call.perform(); + EXPECT_FALSE(this->transmitter.sent.empty()); + if (this->transmitter.sent.empty()) + return Message{}; + return decode(this->transmitter.sent.back().raw); + } + + bool receive(const std::vector &bytes) { + const remote_base::RawTimings raw = timings_for(bytes); + remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE); + return static_cast(this->climate).on_receive(data); + } +}; + +/// Checks every byte that the original encoder never changes, and both checksums +static void expect_fixed_bytes(const Message &m) { + const uint8_t frame1[8] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7}; + const uint8_t frame2[8] = {0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2}; + for (size_t i = 0; i < 8; i++) { + EXPECT_EQ(m[i], frame1[i]) << "byte " << i; + EXPECT_EQ(m[8 + i], frame2[i]) << "byte " << 8 + i; + } + EXPECT_EQ(m[16], 0x11); + EXPECT_EQ(m[17], 0xDA); + EXPECT_EQ(m[18], 0x27); + EXPECT_EQ(m[19], 0x00); + EXPECT_EQ(m[20], 0x00); + EXPECT_EQ(m[23], 0x00); + EXPECT_EQ(m[26], 0x00); + EXPECT_EQ(m[27], 0x06); + EXPECT_EQ(m[28], 0x60); + EXPECT_EQ(m[29], 0x00); + EXPECT_EQ(m[30], 0x00); + EXPECT_EQ(m[31], 0xC0); + EXPECT_EQ(m[32], 0x00); + EXPECT_EQ(m[33], 0x00); + // 0x11 + 0xDA + 0x27 + 0xC5 = 0x1D7 + EXPECT_EQ(m[7], 0xD7); + const uint8_t expected34 = FRAME3_CONSTANT_SUM + m[21] + m[22] + m[24] + m[25]; + EXPECT_EQ(m[34], expected34); +} + +struct ModeCase { + climate::ClimateMode mode; + uint8_t byte21; + uint8_t byte22; + uint8_t byte34; +}; + +// At 24 degrees, fan auto (0xA0, 0x00), swing off: +// OFF 0x00, 24 << 1 = 0x30, 0x38 + 0x00 + 0x30 + 0xA0 = 0x108 -> 0x08 +// COOL 0x31, 0x30, 0x38 + 0x31 + 0x30 + 0xA0 = 0x139 -> 0x39 +// HEAT 0x41, 0x30, 0x38 + 0x41 + 0x30 + 0xA0 = 0x149 -> 0x49 +// DRY 0x21, 0xC0, 0x38 + 0x21 + 0xC0 + 0xA0 = 0x1B9 -> 0xB9 +// FAN_ONLY 0x61, 0x32, 0x38 + 0x61 + 0x32 + 0xA0 = 0x16B -> 0x6B +// HEAT_COOL 0x01, 0x30, 0x38 + 0x01 + 0x30 + 0xA0 = 0x109 -> 0x09 +static const ModeCase MODE_CASES[] = { + {climate::CLIMATE_MODE_OFF, 0x00, 0x30, 0x08}, {climate::CLIMATE_MODE_COOL, 0x31, 0x30, 0x39}, + {climate::CLIMATE_MODE_HEAT, 0x41, 0x30, 0x49}, {climate::CLIMATE_MODE_DRY, 0x21, 0xC0, 0xB9}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x61, 0x32, 0x6B}, {climate::CLIMATE_MODE_HEAT_COOL, 0x01, 0x30, 0x09}, +}; + +TEST(DaikinRegression, EncodesEveryMode) { + for (const auto &c : MODE_CASES) { + Rig r; + const Message m = r.send(c.mode, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(static_cast(c.mode)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], c.byte21); + EXPECT_EQ(m[22], c.byte22); + EXPECT_EQ(m[24], 0xA0); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], c.byte34); + } +} + +struct FanCase { + climate::ClimateFanMode fan; + uint8_t b24; + uint8_t byte34; +}; + +// Cool at 24 (0x31, 0x30), swing off: b34 = 0x38 + 0x31 + 0x30 + b24 = 0x99 + b24 +static const FanCase FAN_CASES[] = { + {climate::CLIMATE_FAN_QUIET, 0xB0, 0x49}, // 0x99 + 0xB0 = 0x149 + {climate::CLIMATE_FAN_AUTO, 0xA0, 0x39}, // 0x99 + 0xA0 = 0x139 + {climate::CLIMATE_FAN_LOW, 0x30, 0xC9}, // 0x99 + 0x30 + {climate::CLIMATE_FAN_MEDIUM, 0x50, 0xE9}, // 0x99 + 0x50 + {climate::CLIMATE_FAN_HIGH, 0x70, 0x09}, // 0x99 + 0x70 = 0x109 +}; + +TEST(DaikinRegression, EncodesEveryFanMode) { + for (const auto &c : FAN_CASES) { + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_COOL, 24.0f, c.fan, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(static_cast(c.fan)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x31); + EXPECT_EQ(m[22], 0x30); + EXPECT_EQ(m[24], c.b24); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], c.byte34); + } +} + +struct SwingCase { + climate::ClimateSwingMode swing; + uint8_t b24; + uint8_t b25; + uint8_t byte34; +}; + +// Heat at 22 (0x41, 0x2C), fan medium (0x50): b34 = 0x38 + 0x41 + 0x2C + b24 + b25 = 0xA5 + ... +static const SwingCase SWING_CASES[] = { + {climate::CLIMATE_SWING_OFF, 0x50, 0x00, 0xF5}, // 0xA5 + 0x50 + {climate::CLIMATE_SWING_VERTICAL, 0x5F, 0x00, 0x04}, // 0xA5 + 0x5F = 0x104 + {climate::CLIMATE_SWING_HORIZONTAL, 0x50, 0x0F, 0x04}, // 0xA5 + 0x50 + 0x0F = 0x104 + {climate::CLIMATE_SWING_BOTH, 0x5F, 0x0F, 0x13}, // 0xA5 + 0x5F + 0x0F = 0x113 +}; + +TEST(DaikinRegression, EncodesEverySwingMode) { + for (const auto &c : SWING_CASES) { + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_HEAT, 22.0f, climate::CLIMATE_FAN_MEDIUM, c.swing); + SCOPED_TRACE(static_cast(c.swing)); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x41); + EXPECT_EQ(m[22], 0x2C); + EXPECT_EQ(m[24], c.b24); + EXPECT_EQ(m[25], c.b25); + EXPECT_EQ(m[34], c.byte34); + } +} + +TEST(DaikinRegression, EncodesSwingWithQuietAndHighFan) { + { + // Dry, quiet, vertical: 0x21, 0xC0, 0xB0 | 0x0F = 0xBF; 0x38 + 0x21 + 0xC0 + 0xBF = 0x1D8 -> 0xD8 + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_DRY, 24.0f, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_VERTICAL); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x21); + EXPECT_EQ(m[22], 0xC0); + EXPECT_EQ(m[24], 0xBF); + EXPECT_EQ(m[25], 0x00); + EXPECT_EQ(m[34], 0xD8); + } + { + // Fan only, low, horizontal: 0x61, 0x32, 0x30, 0x0F; 0x38 + 0x61 + 0x32 + 0x30 + 0x0F = 0x10A -> 0x0A + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_FAN_ONLY, 24.0f, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_HORIZONTAL); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x61); + EXPECT_EQ(m[22], 0x32); + EXPECT_EQ(m[24], 0x30); + EXPECT_EQ(m[25], 0x0F); + EXPECT_EQ(m[34], 0x0A); + } + { + // Heat at 30, high, both: 0x41, 0x3C, 0x7F, 0x0F; 0x38 + 0x41 + 0x3C + 0x7F + 0x0F = 0x143 -> 0x43 + Rig r; + const Message m = r.send(climate::CLIMATE_MODE_HEAT, 30.0f, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x41); + EXPECT_EQ(m[22], 0x3C); + EXPECT_EQ(m[24], 0x7F); + EXPECT_EQ(m[25], 0x0F); + EXPECT_EQ(m[34], 0x43); + } +} + +struct TemperatureCase { + float target; + uint8_t byte22; +}; + +// Clamped to 10..30, rounded, then doubled. The call layer does not clamp, so 5 and 35 reach the encoder. +static const TemperatureCase TEMPERATURE_CASES[] = { + {5.0f, 0x14}, {10.0f, 0x14}, {11.0f, 0x16}, {18.0f, 0x24}, {21.0f, 0x2A}, + {24.0f, 0x30}, {29.0f, 0x3A}, {30.0f, 0x3C}, {35.0f, 0x3C}, +}; + +TEST(DaikinRegression, EncodesWholeDegreesWithClamp) { + for (const auto &c : TEMPERATURE_CASES) { + for (auto mode : {climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_HEAT_COOL}) { + Rig r; + const Message m = r.send(mode, c.target, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + SCOPED_TRACE(c.target); + SCOPED_TRACE(static_cast(mode)); + expect_fixed_bytes(m); + EXPECT_EQ(m[22], c.byte22); + } + } +} + +TEST(DaikinRegression, HeatCoolAt18Checksum) { + // 0x01, 18 << 1 = 0x24, medium 0x50: 0x38 + 0x01 + 0x24 + 0x50 = 0xAD + Rig r; + const Message m = + r.send(climate::CLIMATE_MODE_HEAT_COOL, 18.0f, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_SWING_OFF); + expect_fixed_bytes(m); + EXPECT_EQ(m[21], 0x01); + EXPECT_EQ(m[22], 0x24); + EXPECT_EQ(m[24], 0x50); + EXPECT_EQ(m[34], 0xAD); +} + +TEST(DaikinRegression, FrameTimingsAndCarrier) { + Rig r; + r.send(climate::CLIMATE_MODE_COOL, 24.0f, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF); + ASSERT_EQ(r.transmitter.sent.size(), 1u); + const Capture &c = r.transmitter.sent.back(); + EXPECT_EQ(c.carrier, CARRIER); + ASSERT_EQ(c.raw.size(), 572u); + // Header, then 64 bits of frame 1, its trailer and the 32300 gap, then frame 2's header + EXPECT_EQ(c.raw[0], 3360); + EXPECT_EQ(c.raw[1], -1760); + EXPECT_EQ(c.raw[2 + 128], 520); + EXPECT_EQ(c.raw[2 + 128 + 1], -32300); + EXPECT_EQ(c.raw[2 + 128 + 2], 3360); + EXPECT_EQ(c.raw[2 + 128 + 3], -1760); + // Frame 2's trailer and gap, then frame 3's header + EXPECT_EQ(c.raw[134 + 128], 520); + EXPECT_EQ(c.raw[134 + 128 + 1], -32300); + EXPECT_EQ(c.raw[134 + 128 + 2], 3360); + EXPECT_EQ(c.raw[134 + 128 + 3], -1760); + // Frame 3 ends with a bit mark and a zero space + EXPECT_EQ(c.raw[570], 520); + EXPECT_EQ(c.raw[571], 0); + // Byte 0 is 0x11, LSB first: bit 0 is a one (1370), bit 1 a zero (360) + EXPECT_EQ(c.raw[2], 520); + EXPECT_EQ(c.raw[3], -1370); + EXPECT_EQ(c.raw[4], 520); + EXPECT_EQ(c.raw[5], -360); +} + +struct ReceiveCase { + float target; + uint8_t mode_byte; + uint8_t temperature; + uint8_t fan_byte; + uint8_t swing_byte; + climate::ClimateMode mode; + climate::ClimateFanMode fan; + climate::ClimateSwingMode swing; +}; + +// Byte 5 mode (low bit power), byte 6 temperature doubled, byte 8 fan high nibble and vertical swing +// low nibble, byte 9 horizontal swing low nibble. Fan nibbles: 3 and 4 low, 5 medium, 6 and 7 high, +// A auto, B quiet. +static const ReceiveCase RECEIVE_CASES[] = { + {24.0f, 0x31, 0x30, 0xA0, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_OFF}, + {22.0f, 0x41, 0x2C, 0x3F, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_LOW, + climate::CLIMATE_SWING_VERTICAL}, + {20.0f, 0x01, 0x28, 0x50, 0x0F, climate::CLIMATE_MODE_HEAT_COOL, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_SWING_HORIZONTAL}, + {10.0f, 0x31, 0x14, 0x7F, 0x0F, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_BOTH}, + {30.0f, 0x41, 0x3C, 0xB0, 0x00, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_FAN_QUIET, climate::CLIMATE_SWING_OFF}, + {18.0f, 0x31, 0x24, 0x40, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_LOW, climate::CLIMATE_SWING_OFF}, + {18.0f, 0x31, 0x24, 0x60, 0x00, climate::CLIMATE_MODE_COOL, climate::CLIMATE_FAN_HIGH, climate::CLIMATE_SWING_OFF}, +}; + +TEST(DaikinRegression, DecodesStateFrames) { + for (const auto &c : RECEIVE_CASES) { + Rig r; + SCOPED_TRACE(static_cast(c.mode_byte)); + SCOPED_TRACE(static_cast(c.fan_byte)); + EXPECT_TRUE(r.receive(state_frame(c.mode_byte, c.temperature, c.fan_byte, c.swing_byte))); + EXPECT_EQ(r.climate.mode, c.mode); + EXPECT_FLOAT_EQ(r.climate.target_temperature, c.target); + EXPECT_EQ(r.climate.fan_mode, c.fan); + EXPECT_EQ(r.climate.swing_mode, c.swing); + } +} + +TEST(DaikinRegression, DecodesDryAndFanOnlyWithoutTouchingTemperature) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + ASSERT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + // Dry carries 0xC0 in the temperature byte; the target stays at 24 + EXPECT_TRUE(r.receive(state_frame(0x21, 0xC0, 0x7F, 0x0F))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_DRY); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_BOTH); + // Fan only carries 0x32; the target still stays at 24 + EXPECT_TRUE(r.receive(state_frame(0x61, 0x32, 0xBF, 0x00))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_QUIET); + EXPECT_EQ(r.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL); +} + +TEST(DaikinRegression, DecodesPowerOffAsOff) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + ASSERT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + // Cool with the power bit clear, at 26: mode becomes off and the temperature is not applied + EXPECT_TRUE(r.receive(state_frame(0x30, 0x34, 0x50, 0x00))); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_MEDIUM); +} + +TEST(DaikinRegression, RejectsBadChecksum) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + auto frame = state_frame(0x41, 0x2C, 0x50, 0x00); + frame[18] ^= 0x01; + EXPECT_FALSE(r.receive(frame)); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + EXPECT_EQ(r.climate.fan_mode, climate::CLIMATE_FAN_AUTO); +} + +TEST(DaikinRegression, RejectsWrongHeaderByte) { + for (size_t index = 0; index < 5; index++) { + Rig r; + ASSERT_TRUE(r.receive(state_frame(0x31, 0x30, 0xA0, 0x00))); + auto frame = state_frame(0x41, 0x2C, 0x50, 0x00); + frame[index] ^= 0x01; + // Recompute the checksum so only the header byte is wrong + uint8_t total = 0; + for (size_t i = 0; i < 18; i++) + total += frame[i]; + frame[18] = total; + SCOPED_TRACE(index); + EXPECT_FALSE(r.receive(frame)); + EXPECT_EQ(r.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(r.climate.target_temperature, 24.0f); + } +} + +} // namespace esphome::daikin::regression diff --git a/tests/components/daikin/daikin_tests.cpp b/tests/components/daikin/daikin_tests.cpp new file mode 100644 index 00000000000..5a71cf41b7f --- /dev/null +++ b/tests/components/daikin/daikin_tests.cpp @@ -0,0 +1,440 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "esphome/components/daikin/daikin.h" +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/preferences.h" + +namespace esphome::daikin::testing { + +static constexpr size_t MESSAGE_SIZE = 35; +using Message = std::array; +static constexpr uint32_t BOOST_TIMEOUT_ID = 0; + +// The host scheduler reads the real clock, so the 20 minute boost timer cannot elapse in a test. +// Explicit instantiation skips access checks, which lets the test run the timer's callback itself. +template struct BoostCallback { + friend void run_boost_callback(DaikinClimate &climate) { (climate.*M)(); } +}; +void run_boost_callback(DaikinClimate &climate); +template struct BoostCallback<&DaikinClimate::cancel_boost_mode_>; + +static uint8_t sum(const uint8_t *data, size_t len) { + uint8_t total = 0; + for (size_t i = 0; i < len; i++) + total += data[i]; + return total; +} + +static std::string hex(const uint8_t *data, size_t len) { + std::string out; + for (size_t i = 0; i < len; i++) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02X ", data[i]); + out += buf; + } + return out; +} + +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()); + } +}; + +/// Reads `count` LSB-first bytes starting at raw[pos], advancing pos past them +static void read_bytes(const remote_base::RawTimings &raw, size_t &pos, uint8_t *out, size_t count) { + for (size_t byte = 0; byte < count; byte++) { + uint8_t value = 0; + for (int bit = 0; bit < 8; bit++) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_BIT_MARK)) << "bit mark at " << pos; + const int32_t space = -raw[pos + 1]; + if (space == static_cast(DAIKIN_ONE_SPACE)) { + value |= 1 << bit; + } else { + EXPECT_EQ(space, static_cast(DAIKIN_ZERO_SPACE)) << "bit space at " << pos + 1; + } + pos += 2; + } + out[byte] = value; + } +} + +static void expect_header(const remote_base::RawTimings &raw, size_t &pos) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_HEADER_MARK)) << "header mark at " << pos; + EXPECT_EQ(raw[pos + 1], -static_cast(DAIKIN_HEADER_SPACE)) << "header space at " << pos + 1; + pos += 2; +} + +static void expect_trailer(const remote_base::RawTimings &raw, size_t &pos, uint32_t space) { + EXPECT_EQ(raw[pos], static_cast(DAIKIN_BIT_MARK)) << "trailer mark at " << pos; + EXPECT_EQ(raw[pos + 1], -static_cast(space)) << "trailer space at " << pos + 1; + pos += 2; +} + +/// Decodes a captured transmission back into its 35 bytes, checking headers and frame gaps +static Message decode(const remote_base::RawTimings &raw) { + Message msg{}; + // Three frames of 8, 8 and 19 bytes, each a header, two timings per bit and a trailer + const size_t expected_size = 3 * 4 + 2 * 8 * MESSAGE_SIZE; + EXPECT_EQ(raw.size(), expected_size); + if (raw.size() != expected_size) + return msg; + size_t pos = 0; + expect_header(raw, pos); + read_bytes(raw, pos, msg.data(), 8); + expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE); + expect_header(raw, pos); + read_bytes(raw, pos, msg.data() + 8, 8); + expect_trailer(raw, pos, DAIKIN_MESSAGE_SPACE); + expect_header(raw, pos); + read_bytes(raw, pos, msg.data() + 16, 19); + expect_trailer(raw, pos, 0); + return msg; +} + +/// Timings for one received frame (8 or 19 bytes) as the receiver would deliver it +static remote_base::RawTimings frame_timings(const std::vector &bytes) { + remote_base::RawTimings raw; + raw.push_back(DAIKIN_HEADER_MARK); + raw.push_back(-static_cast(DAIKIN_HEADER_SPACE)); + for (uint8_t byte : bytes) { + for (int bit = 0; bit < 8; bit++) { + raw.push_back(DAIKIN_BIT_MARK); + raw.push_back(-static_cast((byte >> bit) & 1 ? DAIKIN_ONE_SPACE : DAIKIN_ZERO_SPACE)); + } + } + raw.push_back(DAIKIN_BIT_MARK); + raw.push_back(-static_cast(DAIKIN_MESSAGE_SPACE)); + return raw; +} + +/// The 0xC5 frame, with a valid checksum unless one is given +static std::vector comfort_frame(uint8_t comfort) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, comfort, 0x00}; + f[7] = sum(f.data(), 7); + return f; +} + +/// The 19-byte state frame: cool at `temperature` (half degrees), fan auto, no swing +static std::vector state_frame(uint8_t byte13, uint8_t byte16, uint8_t temperature = 44) { + std::vector f{0x11, 0xDA, 0x27, 0x00, 0x00, DAIKIN_MODE_ON | DAIKIN_MODE_COOL, + temperature, 0x00, 0xA0, 0x00, 0x00, 0x06, + 0x60, byte13, 0x00, 0xC0, byte16, 0x00, + 0x00}; + f[18] = sum(f.data(), 18); + return f; +} + +struct Fixture { + CapturingTransmitter transmitter; + DaikinClimate climate; + + /// Pass keep_preferences to restore what an earlier fixture saved + explicit Fixture(bool keep_preferences = false) { + // Every fixture shares one preference key, so clear what an earlier test saved or setup restores it + if (!keep_preferences && global_preferences != nullptr) + global_preferences->reset(); + // The test main does not construct App as generated code does; the boost timer needs its scheduler + static const bool APP_CONSTRUCTED = (new (&App) Application(), true); + (void) APP_CONSTRUCTED; + static char name[] = "test_daikin"; + static char friendly_name[] = ""; + App.pre_setup(name, sizeof(name) - 1, friendly_name, 0); + this->climate.set_transmitter(&this->transmitter); + static_cast(this->climate).setup(); + } + + ~Fixture() { + // Drop a pending boost timer while the climate is alive; App outlives each test + App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID); + App.scheduler.call(millis()); + } + + void command(climate::ClimateMode mode, float temperature, climate::ClimatePreset preset) { + auto call = this->climate.make_call(); + call.set_mode(mode); + call.set_target_temperature(temperature); + call.set_preset(preset); + call.perform(); + } + + Message last_message() const { return decode(this->transmitter.frames.back()); } + + bool receive(const std::vector &bytes) { + const remote_base::RawTimings raw = frame_timings(bytes); + remote_base::RemoteReceiveData data(raw, 25, 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); + } + + /// Fires the boost timer as the scheduler would if it is pending; returns whether it was + bool fire_boost_timer() { + if (!App.scheduler.cancel_timeout(&this->climate, BOOST_TIMEOUT_ID)) + return false; + run_boost_callback(this->climate); + return true; + } +}; + +static void expect_checksums(const Message &m) { + EXPECT_EQ(m[7], sum(m.data(), 7)) << hex(m.data(), MESSAGE_SIZE); + EXPECT_EQ(m[34], sum(m.data() + 16, 18)) << hex(m.data(), MESSAGE_SIZE); +} + +TEST(Daikin, EncodesHalfDegree) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_NONE); + ASSERT_EQ(f.transmitter.frames.size(), 1u); + const Message m = f.last_message(); + EXPECT_EQ(m[16 + 5], DAIKIN_MODE_ON | DAIKIN_MODE_COOL); + EXPECT_EQ(m[22], 45); + EXPECT_EQ(m[6], 0x00); + EXPECT_EQ(m[29], 0x00); + EXPECT_EQ(m[32], 0x00); + expect_checksums(m); +} + +TEST(Daikin, EncodesEachPresetInItsOwnByte) { + struct Case { + climate::ClimatePreset preset; + uint8_t byte6, byte29, byte32; + }; + const Case cases[] = { + {climate::CLIMATE_PRESET_NONE, 0x00, 0x00, 0x00}, {climate::CLIMATE_PRESET_BOOST, 0x00, 0x01, 0x00}, + {climate::CLIMATE_PRESET_SLEEP, 0x00, 0x20, 0x00}, {climate::CLIMATE_PRESET_ECO, 0x00, 0x00, 0x04}, + {climate::CLIMATE_PRESET_ACTIVITY, 0x00, 0x00, 0x02}, {climate::CLIMATE_PRESET_COMFORT, 0x10, 0x00, 0x00}, + }; + Fixture f; + uint8_t plain_first_checksum = 0; + for (const Case &c : cases) { + SCOPED_TRACE(static_cast(c.preset)); + f.command(climate::CLIMATE_MODE_COOL, 22.5f, c.preset); + const Message m = f.last_message(); + EXPECT_EQ(m[6], c.byte6); + EXPECT_EQ(m[29], c.byte29); + EXPECT_EQ(m[32], c.byte32); + EXPECT_EQ(m[22], 45); + expect_checksums(m); + if (c.preset == climate::CLIMATE_PRESET_NONE) { + plain_first_checksum = m[7]; + } else if (c.preset == climate::CLIMATE_PRESET_COMFORT) { + EXPECT_NE(m[7], plain_first_checksum); + EXPECT_EQ(static_cast(m[7] - plain_first_checksum), 0x10); + } else { + EXPECT_EQ(m[7], plain_first_checksum); + } + } +} + +TEST(Daikin, ReceivedComfortFrameSetsComfort) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); +} + +TEST(Daikin, ReceivedPowerfulGivesBoost) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); +} + +TEST(Daikin, ReceivedStateWithoutBitsClearsEco) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, ReceivedOtherPresets) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x20, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x02))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ACTIVITY); +} + +TEST(Daikin, ReceivedHalfDegree) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00, 45))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f); +} + +TEST(Daikin, ReceivedFrameRoundTripsTransmittedState) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.5f, climate::CLIMATE_PRESET_COMFORT); + const Message m = f.last_message(); + f.command(climate::CLIMATE_MODE_HEAT, 18.0f, climate::CLIMATE_PRESET_NONE); + EXPECT_TRUE(f.receive(std::vector(m.begin(), m.begin() + 8))); + EXPECT_TRUE(f.receive(std::vector(m.begin() + 16, m.end()))); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 22.5f); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, BoostTimerCancelledWhenRemoteLeavesBoost) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + // No boost timer may remain to end a boost that is already over + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); +} + +TEST(Daikin, BoostTimerCancelledWhenEntityLeavesBoost) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_SLEEP); + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); +} + +TEST(Daikin, ReceivedBoostEndsWhenTimerFires) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, RepeatedBoostFrameKeepsOneTimer) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_TRUE(f.receive(state_frame(0x01, 0x00))); + EXPECT_TRUE(f.fire_boost_timer()); + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, BoostToComfortTimerDoesNotChangePreset) { + Fixture f; + f.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + // The state frame saw boost as the previous preset and cancelled the timer + EXPECT_FALSE(f.fire_boost_timer()); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, ComfortFrameAloneChangesNothing) { + Fixture f; + EXPECT_TRUE(f.receive(state_frame(0x00, 0x04))); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + + // The comfort frame is only recorded until a valid state frame follows + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + std::vector bad = state_frame(0x00, 0x00); + bad[18] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_ECO); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, LostStateFrameKeepsComfortUntilNextTransmission) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + ASSERT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + + // Comfort off arrives but its state frame is lost: nothing changes + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); + + // The next transmission says comfort off again and its state frame arrives + EXPECT_TRUE(f.receive(comfort_frame(0x00))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, PendingComfortExpiresAfterOneSecond) { + Fixture f; + // LoopBlockingGuard publishes the dispatch time the component reads as the loop clock + { LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000); } + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + // The state frame of that transmission is lost; the next one arrives after the window + { LoopBlockingGuard clock(&f.climate, LOG_STR("test"), 1000 + DAIKIN_TRANSMISSION_MS + 1); } + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +TEST(Daikin, StateFrameWithoutComfortFrameKeepsComfort) { + Fixture f; + EXPECT_TRUE(f.receive(comfort_frame(0x10))); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + // A state frame whose 0xC5 frame was lost leaves a current comfort preset alone + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_COMFORT); +} + +TEST(Daikin, RestoredBoostArmsTheTimer) { + { + Fixture first; + first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + ASSERT_EQ(first.climate.preset, climate::CLIMATE_PRESET_BOOST); + } + Fixture restored(true); + EXPECT_EQ(restored.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_EQ(restored.climate.mode, climate::CLIMATE_MODE_COOL); + EXPECT_TRUE(App.scheduler.cancel_timeout(&restored.climate, BOOST_TIMEOUT_ID)); +} + +TEST(Daikin, FreshClimateHasNoBoostTimer) { + { + Fixture first; + first.command(climate::CLIMATE_MODE_COOL, 22.0f, climate::CLIMATE_PRESET_BOOST); + } + // A fixture that clears the saved state restores nothing and arms no timer + Fixture fresh; + EXPECT_NE(fresh.climate.preset, climate::CLIMATE_PRESET_BOOST); + EXPECT_FALSE(App.scheduler.cancel_timeout(&fresh.climate, BOOST_TIMEOUT_ID)); +} + +TEST(Daikin, RejectsBadStateChecksum) { + Fixture f; + f.command(climate::CLIMATE_MODE_HEAT, 20.0f, climate::CLIMATE_PRESET_SLEEP); + std::vector bad = state_frame(0x01, 0x00, 45); + bad[18] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_HEAT); + EXPECT_FLOAT_EQ(f.climate.target_temperature, 20.0f); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_SLEEP); + EXPECT_FALSE(f.fire_boost_timer()); +} + +TEST(Daikin, RejectsBadComfortChecksum) { + Fixture f; + std::vector bad = comfort_frame(0x10); + bad[7] ^= 0x01; + EXPECT_FALSE(f.receive(bad)); + EXPECT_TRUE(f.receive(state_frame(0x00, 0x00))); + EXPECT_EQ(f.climate.preset, climate::CLIMATE_PRESET_NONE); +} + +} // namespace esphome::daikin::testing diff --git a/tests/components/daikin/test.esp32-idf.yaml b/tests/components/daikin/test.esp32-idf.yaml new file mode 100644 index 00000000000..62ac9ac7ab9 --- /dev/null +++ b/tests/components/daikin/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 + daikin: !include common.yaml diff --git a/tests/components/daikin/test.esp8266-ard.yaml b/tests/components/daikin/test.esp8266-ard.yaml index 4bed2f03e55..6d9ed4c4360 100644 --- a/tests/components/daikin/test.esp8266-ard.yaml +++ b/tests/components/daikin/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml - -<<: !include common.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml + daikin: !include common.yaml From 84694b602a74cf190fac54370e3dec4a3e116228 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 11 Oct 2026 02:14:38 -0700 Subject: [PATCH 33/97] [ld2412] Fix crash when only some gate thresholds are configured (#18194) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: J. Nick Koston --- esphome/components/ld2412/ld2412.cpp | 117 +++-- esphome/components/ld2412/ld2412.h | 27 +- .../ld2412/number/gate_threshold_number.cpp | 2 +- .../uart_mock_ld2412_footer_in_payload.yaml | 74 +++ ...uart_mock_ld2412_requery_after_giveup.yaml | 61 +++ .../uart_mock_ld2412_truncated_answer.yaml | 72 +++ tests/integration/test_uart_mock_ld2412.py | 425 +++++++++++++++--- 7 files changed, 690 insertions(+), 88 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml create mode 100644 tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index bd43d06cc27..c6137f29f7c 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -10,6 +10,9 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include +#include + namespace esphome::ld2412 { ESPHOME_LOG_TAG(TAG, "ld2412"); @@ -73,8 +76,10 @@ enum PeriodicDataValue : uint8_t { }; enum AckData : uint8_t { + FRAME_LENGTH = 4, // little-endian count of the bytes between it and the footer COMMAND = 6, COMMAND_STATUS = 7, + ACK_PAYLOAD = 10, }; // Memory-efficient lookup tables @@ -191,6 +196,9 @@ static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0x static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5}; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE; +// The length field counts the command word, the status word and the gates +static constexpr uint16_t GATE_SENS_ACK_LENGTH = GATE_SENS_ACK_SIZE - COMMAND - HEADER_FOOTER_SIZE; static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; @@ -240,10 +248,10 @@ void LD2412Component::dump_config() { LOG_NUMBER(" ", "MaxDistanceGate", this->max_distance_gate_number_); LOG_NUMBER(" ", "MinDistanceGate", this->min_distance_gate_number_); LOG_NUMBER(" ", "Timeout", this->timeout_number_); - for (number::Number *n : this->gate_move_threshold_numbers_) { + for (number::Number *n : this->gate_move_thresholds_.numbers) { LOG_NUMBER(" ", "Move Thresholds", n); } - for (number::Number *n : this->gate_still_threshold_numbers_) { + for (number::Number *n : this->gate_still_thresholds_.numbers) { LOG_NUMBER(" ", "Still Thresholds", n); } #endif @@ -460,6 +468,13 @@ void set_number_value(number::Number *n, float value) { n->publish_state(value); } } + +// NaN marks a value the module never received +static inline bool has_value(number::Number *n) { return n != nullptr && n->has_state() && !std::isnan(n->state); } + +// Timeout ids 0 (still) and 1 (motion) for a write waiting on a re-query +static constexpr uint32_t GATE_QUERY_TIMEOUT_ID = 0; +static constexpr uint32_t GATE_QUERY_TIMEOUT_MS = 2000; #endif bool LD2412Component::handle_ack_data_() { @@ -595,19 +610,34 @@ bool LD2412Component::handle_ack_data_() { ESP_LOGV(TAG, "Handled set light control command"); break; - case CMD_QUERY_MOTION_GATE_SENS: { -#ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[10 + i]); - } -#endif - break; - } - + case CMD_QUERY_MOTION_GATE_SENS: case CMD_QUERY_STATIC_GATE_SENS: { #ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[10 + i]); + const uint16_t declared = encode_uint16(this->buffer_data_[FRAME_LENGTH + 1], this->buffer_data_[FRAME_LENGTH]); + if (declared != GATE_SENS_ACK_LENGTH || this->buffer_pos_ > GATE_SENS_ACK_SIZE) { + // A truncated answer merged with the next frame, or a length this driver does not know + ESP_LOGW(TAG, "Dropping gate threshold answer of %u bytes", this->buffer_pos_); + return true; + } + if (this->buffer_pos_ < GATE_SENS_ACK_SIZE) { + return false; // the footer pattern appeared inside the payload, keep reading + } + const bool motion = this->buffer_data_[COMMAND] == CMD_QUERY_MOTION_GATE_SENS; + GateThresholds &group = this->gate_thresholds_(motion); + // True only while a held-back write waits for this answer + const bool pending = this->cancel_timeout(GATE_QUERY_TIMEOUT_ID + motion); + for (size_t i = 0; i < TOTAL_GATES; i++) { + group.last_read[i] = this->buffer_data_[ACK_PAYLOAD + i]; + // The pending write's user values must survive the answer + if (!pending || !has_value(group.numbers[i])) { + set_number_value(group.numbers[i], group.last_read[i]); + } + } + group.read = true; + if (pending) { + this->set_config_mode_(true); + this->send_gate_thresholds_(motion); + this->set_config_mode_(false); } #endif break; @@ -747,6 +777,11 @@ void LD2412Component::set_engineering_mode(bool enable) { void LD2412Component::factory_reset() { this->set_config_mode_(true); this->send_command_(CMD_FACTORY_RESET, nullptr, 0); +#ifdef USE_NUMBER + // Reset discards the thresholds; the next write must wait for the re-read + this->gate_move_thresholds_.read = false; + this->gate_still_thresholds_.read = false; +#endif this->set_timeout(2000, [this]() { this->restart_and_read_all_info(); }); } @@ -805,24 +840,48 @@ void LD2412Component::set_basic_config() { } #ifdef USE_NUMBER -void LD2412Component::set_gate_threshold() { - if (this->gate_move_threshold_numbers_.empty() && this->gate_still_threshold_numbers_.empty()) { - return; // No gate threshold numbers set; nothing to do here +void LD2412Component::send_gate_thresholds_(bool motion) { + GateThresholds &group = this->gate_thresholds_(motion); + const uint8_t command = motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS; + uint8_t value[TOTAL_GATES]; + uint8_t configured = 0; + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + number::Number *n = group.numbers[i]; + if (has_value(n)) { + value[i] = static_cast(n->state); + configured++; + } else { + value[i] = group.last_read[i]; + } } - uint8_t value[TOTAL_GATES] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + if (configured < TOTAL_GATES && !group.read) { + // Unread gates would go out as zero, which is maximum sensitivity + ESP_LOGW(TAG, "Command %02X held back until the module reports its gate thresholds", command); + this->send_command_(motion ? CMD_QUERY_MOTION_GATE_SENS : CMD_QUERY_STATIC_GATE_SENS, nullptr, 0); + this->set_timeout(GATE_QUERY_TIMEOUT_ID + motion, GATE_QUERY_TIMEOUT_MS, + [this, motion]() { this->gate_query_timeout_(motion); }); + return; + } + this->send_command_(command, value, sizeof(value)); +} + +void LD2412Component::gate_query_timeout_(bool motion) { + ESP_LOGE(TAG, "Command %02X not sent, the module did not report its gate thresholds", + motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS); + // The values on hand never reached the module or are stale, so forgetting them loses nothing + for (number::Number *n : this->gate_thresholds_(motion).numbers) { + if (n != nullptr) { + n->publish_state(NAN); + } + } +} + +void LD2412Component::set_gate_threshold(number::Number *n) { + // Found by pointer: 14 compares per write beat a flag per number + const auto &motion_numbers = this->gate_move_thresholds_.numbers; + const bool motion = std::find(motion_numbers.begin(), motion_numbers.end(), n) != motion_numbers.end(); this->set_config_mode_(true); - if (!this->gate_move_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_move_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_MOTION_GATE_SENS, value, sizeof(value)); - } - if (!this->gate_still_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_still_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_STATIC_GATE_SENS, value, sizeof(value)); - } + this->send_gate_thresholds_(motion); this->set_config_mode_(false); } diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index a52402c2ea3..c427d024f39 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -85,11 +85,13 @@ class LD2412Component final : public Component, public uart::UARTDevice { void set_light_out_control(); void set_basic_config(); #ifdef USE_NUMBER - void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { this->gate_move_threshold_numbers_[gate] = n; } - void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { - this->gate_still_threshold_numbers_[gate] = n; + void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { + this->gate_move_thresholds_.numbers[gate] = n; } - void set_gate_threshold(); + void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { + this->gate_still_thresholds_.numbers[gate] = n; + } + void set_gate_threshold(number::Number *n); void get_gate_threshold(); #endif #ifdef USE_SENSOR @@ -118,6 +120,19 @@ class LD2412Component final : public Component, public uart::UARTDevice { void query_light_control_(); void restart_(); void query_dynamic_background_correction_(); +#ifdef USE_NUMBER + // One command carries all 14 gates, so unconfigured gates get the module's last value + struct GateThresholds { + std::array numbers{}; + std::array last_read{}; + bool read{false}; // answered since boot or the last factory reset + }; + GateThresholds &gate_thresholds_(bool motion) { + return motion ? this->gate_move_thresholds_ : this->gate_still_thresholds_; + } + void send_gate_thresholds_(bool motion); + void gate_query_timeout_(bool motion); +#endif uint8_t light_function_ = 0; uint8_t light_threshold_ = 0; @@ -129,8 +144,8 @@ class LD2412Component final : public Component, public uart::UARTDevice { bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; #ifdef USE_NUMBER - std::array gate_move_threshold_numbers_{}; - std::array gate_still_threshold_numbers_{}; + GateThresholds gate_move_thresholds_; + GateThresholds gate_still_thresholds_; #endif #ifdef USE_SENSOR std::array, TOTAL_GATES> gate_move_sensors_{}; diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index a0a525a8107..1af7b1e7f20 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2412 { void GateThresholdNumber::control(float value) { this->publish_state(value); - this->parent_->set_gate_threshold(); + this->parent_->set_gate_threshold(this); } } // namespace esphome::ld2412 diff --git a/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml b/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml new file mode 100644 index 00000000000..ae3c2e45d54 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_footer_in_payload.yaml @@ -0,0 +1,74 @@ +esphome: + name: uart-mock-ld2412-footer + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412'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: 256000 + # Both answers hold the footer 04 03 02 01 inside the gate bytes, so the parser must read on to the real footer + responses: + # Gates 5 to 8 hold 04 03 02 01 + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x12, 0x00, + 0x13, 0x01, + 0x00, 0x00, + 0x00, 0x23, 0x23, 0x23, 0x23, 0x04, 0x03, + 0x02, 0x01, 0x19, 0x19, 0x19, 0x19, 0x19, + 0x04, 0x03, 0x02, 0x01, + ] + # Gates 10 to 13 hold 04 03 02 01, right before the real footer + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x14, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, + 0x12, 0x00, + 0x14, 0x01, + 0x00, 0x00, + 0x00, 0x23, 0x23, 0x23, 0x23, 0x19, 0x19, + 0x19, 0x19, 0x19, 0x04, 0x03, 0x02, 0x01, + 0x04, 0x03, 0x02, 0x01, + ] + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +# Only 2 of the 14 gates have a number; gate 5 holds a footer byte in the motion answer +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" + +button: + - platform: ld2412 + ld2412_id: ld2412_dev + factory_reset: + name: "Factory Reset" diff --git a/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml b/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml new file mode 100644 index 00000000000..82a3c01de70 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_requery_after_giveup.yaml @@ -0,0 +1,61 @@ +esphome: + name: uart-mock-ld2412-regiveup + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +# Only the third motion query is answered: the setup query and the first write's re-query go unanswered, +# so that write is given up. The still query is never answered +uart_mock: + id: mock_uart + baud_rate: 256000 + on_tx: + - then: + - lambda: |- + // The length and command word arrive as one write of their own + static const std::vector MOTION_QUERY = {0x02, 0x00, 0x13, 0x00}; + static int motion_queries = 0; + if (data != MOTION_QUERY || ++motion_queries != 3) { + return; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0xFD, 0xFC, 0xFB, 0xFA, // header + 0x12, 0x00, // length 18 + 0x13, 0x01, // answer to command 0x0013 + 0x00, 0x00, // status: success + 0x19, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, // gates 0 to 6 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, // gates 7 to 13 + 0x04, 0x03, 0x02, 0x01, // footer + }); + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" diff --git a/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml b/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml new file mode 100644 index 00000000000..9950e78042a --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ld2412_truncated_answer.yaml @@ -0,0 +1,72 @@ +esphome: + name: uart-mock-ld2412-truncated + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ld2412'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: 256000 + responses: + # The motion answer is two gate bytes short, so the parser reads on into the next acknowledgement + - expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x13, 0x00, 0x04, 0x03, 0x02, 0x01] + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, # motion header + 0x12, 0x00, # length 18, but only 16 bytes follow + 0x13, 0x01, # answer to command 0x0013 + 0x00, 0x00, # status: success + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, # gates 0 to 5 + 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, # gates 6 to 11, gates 12 and 13 are missing + 0x04, 0x03, 0x02, 0x01, # motion footer + 0xFD, 0xFC, 0xFB, 0xFA, # next frame: the config-mode end acknowledgement + 0x04, 0x00, # length 4 + 0xFE, 0x01, # answer to command 0x00FE + 0x00, 0x00, # status: success + 0x04, 0x03, 0x02, 0x01, # its footer; the merged 40-byte frame gets dropped + ] + # A complete answer after setup proves the parser recovered + injections: + - delay: 1500ms + inject_rx: + [ + 0xFD, 0xFC, 0xFB, 0xFA, # header + 0x12, 0x00, # length 18 + 0x13, 0x01, # answer to command 0x0013 + 0x00, 0x00, # status: success + 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, # gates 0 to 6 + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, # gates 7 to 13 + 0x04, 0x03, 0x02, 0x01, # footer + ] + +ld2412: + id: ld2412_dev + uart_id: mock_uart + +number: + - platform: ld2412 + ld2412_id: ld2412_dev + gate_0: + move_threshold: + name: "Gate 0 Move Threshold" + still_threshold: + name: "Gate 0 Still Threshold" + gate_5: + move_threshold: + name: "Gate 5 Move Threshold" + still_threshold: + name: "Gate 5 Still Threshold" diff --git a/tests/integration/test_uart_mock_ld2412.py b/tests/integration/test_uart_mock_ld2412.py index ea2ec38b2a3..e9a69ca870a 100644 --- a/tests/integration/test_uart_mock_ld2412.py +++ b/tests/integration/test_uart_mock_ld2412.py @@ -20,19 +20,120 @@ test_uart_mock_ld2412_engineering_truncated (truncated engineering mode): 2. Truncated engineering frame (24 bytes) is rejected — gate/light sensors must not receive garbage from stale buffer data or frame footer bytes 3. Recovery frame with different values proves the component survived + +The gate threshold tests (footer_in_payload, truncated_answer, requery_after_giveup) cover +answers that hold the frame footer or are truncated, writes that fill the gates without a +number from the module's values, and writes held back until the module reports them. """ from __future__ import annotations import asyncio -from pathlib import Path +from collections.abc import Callable +import math -from aioesphomeapi import ButtonInfo +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + NumberInfo, + NumberState, +) import pytest -from .state_utils import InitialStateHelper, SensorStateCollector, find_entity +from .state_utils import ( + InitialStateHelper, + SensorStateCollector, + StateWaiter, + find_entity, + require_entity, +) from .types import APIClientConnectedFactory, RunCompiledFunction +# Length and command word of each frame as the uart mock logs it; the payload is logged on its own TX line +MOTION_COMMAND = "10:00:03:00" +STILL_COMMAND = "10:00:04:00" +MOTION_QUERY = "02:00:13:00" +STILL_QUERY = "02:00:14:00" +CONFIG_MODE_LEFT = "02:00:FE:00" +FACTORY_RESET = "02:00:A2:00" +# Logged in place of the motion command when the write is held back or given up +MOTION_HELD_BACK = "Command 03 held back" +MOTION_GIVEN_UP = "Command 03 not sent, the module did not report" + +THRESHOLD_NUMBERS = ( + "gate_0_move_threshold", + "gate_0_still_threshold", + "gate_5_move_threshold", + "gate_5_still_threshold", +) + + +def _is_tx(line: str) -> bool: + return "uart_mock" in line and "TX " in line + + +def _threshold_numbers(entities: list[EntityInfo]) -> dict[str, NumberInfo]: + """Return the gate 0 and gate 5 threshold numbers by name.""" + return { + name: require_entity(entities, name, NumberInfo) for name in THRESHOLD_NUMBERS + } + + +async def _wait_initial( + client: APIClient, + entities: list[EntityInfo], + on_state: Callable[[EntityState], None] = lambda s: None, +) -> InitialStateHelper: + """Subscribe to states and wait until every entity has sent its initial state.""" + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + return initial_state_helper + + +def _number_state_of(key: int) -> Callable[[EntityState], bool]: + """Return a predicate that matches a number state with a value for ``key``.""" + return lambda s: isinstance(s, NumberState) and not s.missing_state and s.key == key + + +def _is_nan_of(key: int) -> Callable[[EntityState], bool]: + """Return a predicate that matches the number ``key`` being marked unknown.""" + return lambda s: _number_state_of(key)(s) and math.isnan(s.state) + + +class _LogEvents: + """Record the TX lines and the log lines holding one of ``texts``, in the order they were logged.""" + + def __init__(self, *texts: str) -> None: + self._texts = texts + self.lines: list[str] = [] + self._awaited: dict[str, asyncio.Future[None]] = {} + + def on_line(self, line: str) -> None: + if _is_tx(line) or any(text in line for text in self._texts): + self.lines.append(line) + for text, future in self._awaited.items(): + if text in line and not future.done(): + future.set_result(None) + + def next_line_with(self, text: str) -> asyncio.Future[None]: + future: asyncio.Future[None] = asyncio.get_running_loop().create_future() + self._awaited[text] = future + return future + + def index_after(self, start: int, text: str) -> int: + return next( + i for i, line in enumerate(self.lines) if i > start and text in line + ) + + def count(self, text: str) -> int: + return sum(text in line for line in self.lines) + @pytest.mark.asyncio async def test_uart_mock_ld2412( @@ -41,14 +142,6 @@ async def test_uart_mock_ld2412( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2412 data parsing with happy path, garbage, overflow, and recovery.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - loop = asyncio.get_running_loop() # Track overflow warning in logs @@ -61,7 +154,7 @@ async def test_uart_mock_ld2412( if "Max command length exceeded" in line and not overflow_seen.done(): overflow_seen.set_result(True) # Capture all TX log lines from uart_mock - if "uart_mock" in line and "TX " in line: + if _is_tx(line): tx_log_lines.append(line) collector = SensorStateCollector( @@ -97,16 +190,7 @@ async def test_uart_mock_ld2412( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) # Start the UART mock scenario now that we're subscribed start_btn = find_entity(entities, "start_scenario", ButtonInfo) @@ -176,12 +260,6 @@ async def test_uart_mock_ld2412_engineering( api_client_connected: APIClientConnectedFactory, ) -> None: """Test LD2412 engineering mode with per-gate energy, light, and multi-byte distance.""" - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) collector = SensorStateCollector( sensor_names=[ @@ -220,15 +298,7 @@ async def test_uart_mock_ld2412_engineering( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) # Start the UART mock scenario now that we're subscribed start_btn = find_entity(entities, "start_scenario", ButtonInfo) @@ -288,12 +358,6 @@ async def test_uart_mock_ld2412_engineering_truncated( check but reads indices 17-45 from stale buffer data, publishing garbage values (e.g. frame footer bytes 0xF8=248 as gate energy). """ - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) loop = asyncio.get_running_loop() @@ -341,15 +405,7 @@ async def test_uart_mock_ld2412_engineering_truncated( entities, _ = await client.list_entities_services() collector.build_key_mapping(entities) - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states( - initial_state_helper.on_state_wrapper(collector.on_state) - ) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + await _wait_initial(client, entities, collector.on_state) start_btn = find_entity(entities, "start_scenario", ButtonInfo) assert start_btn is not None, "Start Scenario button not found" @@ -397,3 +453,268 @@ async def test_uart_mock_ld2412_engineering_truncated( f"truncated frame likely leaked stale buffer data. " f"All values: {collector.sensor_states['gate_0_move_energy']}" ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_footer_in_payload( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test threshold answers that hold the frame footer 04 03 02 01, and the writes built from them.""" + # Gate 0 set to 77 (0x4D); the other 13 gates must match the motion answer byte for byte + expected_motion = "4D:23:23:23:23:04:03:02:01:19:19:19:19:19" + # Gate 5 set to 77, the rest matches the still answer + expected_still = "00:23:23:23:23:4D:19:19:19:19:04:03:02:01" + # Gate 0 set to 50 (0x32) after the reset + expected_motion_after_reset = "32:23:23:23:23:04:03:02:01:19:19:19:19:19" + events = _LogEvents(MOTION_HELD_BACK) + + async with ( + run_compiled(yaml_config, line_callback=events.on_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + initial_state_helper = await _wait_initial(client, entities) + numbers = _threshold_numbers(entities) + + # Gate 5 move is the first footer byte inside the motion answer + for name, value in { + "gate_0_move_threshold": 0.0, + "gate_5_move_threshold": 4.0, + "gate_0_still_threshold": 0.0, + "gate_5_still_threshold": 25.0, + }.items(): + state = initial_state_helper.initial_states[numbers[name].key] + assert state.state == pytest.approx(value), name + + motion_written = events.next_line_with(expected_motion) + client.number_command(numbers["gate_0_move_threshold"].key, 77.0) + try: + await asyncio.wait_for(motion_written, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for the motion threshold command.\n" + f" expected motion payload: {expected_motion}" + ) + + assert events.count(STILL_COMMAND) == 0, ( + "still threshold command was sent for a motion write" + ) + + still_written = events.next_line_with(expected_still) + client.number_command(numbers["gate_5_still_threshold"].key, 77.0) + try: + await asyncio.wait_for(still_written, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for the still threshold command.\n" + f" expected still payload: {expected_still}" + ) + + assert events.count(MOTION_COMMAND) == 1 + assert events.count(STILL_COMMAND) == 1 + assert events.count(MOTION_HELD_BACK) == 0 + + # The reset discards the module's thresholds, so the next write is held back and re-queried + reset_sent = events.next_line_with(FACTORY_RESET) + client.button_command(require_entity(entities, "factory_reset", ButtonInfo).key) + try: + await asyncio.wait_for(reset_sent, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for the factory reset command") + + held_back = events.next_line_with(MOTION_HELD_BACK) + written_after_reset = events.next_line_with(expected_motion_after_reset) + client.number_command(numbers["gate_0_move_threshold"].key, 50.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back, written_after_reset), timeout=5.0 + ) + except TimeoutError: + pytest.fail( + "Timeout waiting for the write after the factory reset.\n" + f" expected motion payload: {expected_motion_after_reset}\n" + f" logged: {events.lines}" + ) + reset_at = events.index_after(-1, FACTORY_RESET) + warning_at = events.index_after(reset_at, MOTION_HELD_BACK) + query_at = events.index_after(warning_at, MOTION_QUERY) + events.index_after(query_at, expected_motion_after_reset) + + entities_after, _ = await client.list_entities_services() + assert len(entities_after) == len(entities) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_truncated_answer( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a threshold answer merged with the next frame is dropped and the parser recovers.""" + loop = asyncio.get_running_loop() + + dropped = loop.create_future() + warnings: list[str] = [] + + def line_callback(line: str) -> None: + if "[W][ld2412" in line: + warnings.append(line) + if "Dropping gate threshold answer" in line and not dropped.done(): + dropped.set_result(True) + + # Gate bytes of the truncated answer + merged_values = set(range(0x30, 0x3C)) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + numbers = _threshold_numbers(entities) + + states: dict[int, list[float]] = {} + waiter = StateWaiter() + gate_0_read = waiter.expect( + _number_state_of(numbers["gate_0_move_threshold"].key), + label="gate 0 move threshold", + ) + gate_5_read = waiter.expect( + _number_state_of(numbers["gate_5_move_threshold"].key), + label="gate 5 move threshold", + ) + + def record(state: EntityState) -> None: + if isinstance(state, NumberState) and not state.missing_state: + states.setdefault(state.key, []).append(state.state) + waiter.on_state(state) + + initial_state_helper = await _wait_initial(client, entities, record) + + # The late answer may already have been parsed when the client connected + for state in initial_state_helper.initial_states.values(): + record(state) + + try: + _, gate_0_state, gate_5_state = await asyncio.gather( + asyncio.wait_for(dropped, timeout=5.0), gate_0_read, gate_5_read + ) + except TimeoutError: + pytest.fail( + "Timeout waiting for the threshold answers.\n" + f" ld2412 warnings: {warnings}\n" + f" states: {states}" + ) + + assert gate_0_state.state == pytest.approx(10.0) + assert gate_5_state.state == pytest.approx(15.0) + + for number in numbers.values(): + for value in states.get(number.key, []): + assert int(value) not in merged_values, ( + f"{number.object_id} took {value} from the merged frame" + ) + + +@pytest.mark.asyncio +async def test_uart_mock_ld2412_requery_after_giveup( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a write held back until the module reports its thresholds: given up once, then sent.""" + # Gate 0 takes the module value 25 (0x19), gate 5 the user value 30 (0x1E) + expected_motion = "19:0B:0C:0D:0E:1E:10:11:12:13:14:15:16:17" + events = _LogEvents(MOTION_HELD_BACK, MOTION_GIVEN_UP) + + async with ( + run_compiled(yaml_config, line_callback=events.on_line), + api_client_connected() as client, + ): + entities, _ = await client.list_entities_services() + numbers = _threshold_numbers(entities) + gate_0_key = numbers["gate_0_move_threshold"].key + gate_5_key = numbers["gate_5_move_threshold"].key + + gate_5_values: list[float] = [] + waiter = StateWaiter() + + def on_state(state: EntityState) -> None: + if _number_state_of(gate_5_key)(state): + gate_5_values.append(state.state) + waiter.on_state(state) + + initial_state_helper = await _wait_initial(client, entities, on_state) + + for key in (gate_0_key, gate_5_key): + assert initial_state_helper.initial_states[key].missing_state + + held_back = events.next_line_with(MOTION_HELD_BACK) + config_mode_left = events.next_line_with(CONFIG_MODE_LEFT) + given_up = events.next_line_with(MOTION_GIVEN_UP) + unknown = [ + waiter.expect(_is_nan_of(key), label="NaN") + for key in (gate_0_key, gate_5_key) + ] + client.number_command(gate_5_key, 20.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back, config_mode_left), timeout=5.0 + ) + except TimeoutError: + pytest.fail(f"Timeout waiting for the write to be handled: {events.lines}") + # The unanswered query times out after 2 s + try: + await asyncio.wait_for(given_up, timeout=3.0) + await asyncio.gather(*unknown) + except TimeoutError as err: + pytest.fail(f"Timeout waiting for the first write to be given up: {err}") + + warning_at = events.index_after(-1, MOTION_HELD_BACK) + left_at = events.index_after(warning_at, CONFIG_MODE_LEFT) + write_window = events.lines[warning_at:left_at] + assert any(MOTION_QUERY in line for line in write_window), ( + "motion thresholds were not queried again after the command was held back" + ) + assert not any(STILL_QUERY in line for line in write_window), ( + "still thresholds were queried for a motion write" + ) + assert events.count(MOTION_COMMAND) == 0, ( + "motion threshold command was sent without a value for the gates that have no number" + ) + + held_back_again = events.next_line_with(MOTION_HELD_BACK) + motion_written = events.next_line_with(expected_motion) + gate_0_refilled = waiter.expect( + lambda s: _number_state_of(gate_0_key)(s) and not math.isnan(s.state), + label="gate 0 move threshold from the module", + ) + gate_5_values.clear() + client.number_command(gate_5_key, 30.0) + try: + await asyncio.wait_for( + asyncio.gather(held_back_again, motion_written), timeout=5.0 + ) + gate_0_state = await gate_0_refilled + except TimeoutError as err: + pytest.fail( + f"Timeout waiting for the second write: {err}\n" + f" expected motion payload: {expected_motion}\n" + f" logged: {events.lines}" + ) + + warning_at = events.index_after(left_at, MOTION_HELD_BACK) + query_at = events.index_after(warning_at, MOTION_QUERY) + write_at = events.index_after(query_at, expected_motion) + assert MOTION_COMMAND in events.lines[write_at - 1] + + # One query each from setup, the given-up write and the second write + assert events.count(MOTION_QUERY) == 3 + assert events.count(STILL_COMMAND) == 0, ( + "still threshold command was sent for a motion write" + ) + + assert gate_0_state.state == pytest.approx(25.0) + assert gate_5_values + assert all(value == pytest.approx(30.0) for value in gate_5_values) From 681b48165432c78f61fe89f16f369d23f7557229 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 00:02:00 -1000 Subject: [PATCH 34/97] [tests] Reset shared App state before every host test (#20510) --- tests/components/main.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/main.cpp b/tests/components/main.cpp index aa0ceb5abe6..f1ceb732f91 100644 --- a/tests/components/main.cpp +++ b/tests/components/main.cpp @@ -1,6 +1,7 @@ #include #include "esphome/components/logger/logger.h" +#include "esphome/core/application.h" /* This special main.cpp replaces the default one. @@ -19,6 +20,11 @@ void original_setup() { // =========== AUTO GENERATED CODE END ============ } +// The loop clock and current component one test moves must not leak into the next, like CORE.reset() in Python +class ResetAppListener : public ::testing::EmptyTestEventListener { + void OnTestStart(const ::testing::TestInfo &) override { esphome::LoopBlockingGuard reset{nullptr, nullptr, 0}; } +}; + void setup() { // Log functions call global_logger->log_vprintf_() without a null check, // so we must set up a Logger before any test that triggers logging. @@ -27,6 +33,7 @@ void setup() { test_logger.pre_setup(); ::testing::InitGoogleTest(); + ::testing::UnitTest::GetInstance()->listeners().Append(new ResetAppListener); int exit_code = RUN_ALL_TESTS(); // A test folder that never reached the build would otherwise pass as an empty run if (::testing::UnitTest::GetInstance()->total_test_count() == 0) { From b6096c9437854e41f954a7c94745432e58dc2f6d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:04:57 +1100 Subject: [PATCH 35/97] [ci] Reset sdkconfig after ESP-IDF component discovery (#20511) Co-authored-by: Claude Opus 5.5 --- esphome/espidf/toolchain.py | 9 ++++++- tests/unit_tests/test_espidf_toolchain.py | 29 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/esphome/espidf/toolchain.py b/esphome/espidf/toolchain.py index 871e74d36d3..698080eebc2 100644 --- a/esphome/espidf/toolchain.py +++ b/esphome/espidf/toolchain.py @@ -353,7 +353,11 @@ def _clean_foreign_target_tree() -> None: clean_build(clear_pio_cache=False) if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"): return - # Restart sdkconfig from ESPHome's options + _reset_sdkconfig() + + +def _reset_sdkconfig() -> None: + """Restart the project sdkconfig from ESPHome's own options.""" internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal") if internal.is_file(): shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}")) @@ -656,6 +660,9 @@ def _configure_project(verbose: bool = False) -> int: if not discovered: _LOGGER.error("Component discovery found no built-in ESP-IDF components") return 1 + # The discovery configure builds every IDF component, and their Kconfig + # selects (e.g. esp-tls turning TLS on) would otherwise stay in sdkconfig + _reset_sdkconfig() if (rc := _write_project_and_reconfigure(discovered, verbose)) != 0: _LOGGER.error("Reconfigure with discovered components failed") return rc diff --git a/tests/unit_tests/test_espidf_toolchain.py b/tests/unit_tests/test_espidf_toolchain.py index c73858ed529..3cba1a7357d 100644 --- a/tests/unit_tests/test_espidf_toolchain.py +++ b/tests/unit_tests/test_espidf_toolchain.py @@ -608,6 +608,35 @@ def test_run_compile_cache_miss_discovers_and_saves(setup_core: Path) -> None: ] +def test_run_compile_discovery_resets_sdkconfig(setup_core: Path) -> None: + """Options the discovery configure forced on (e.g. esp-tls selecting TLS) + are dropped before the configure with the real component list.""" + _setup_build(setup_core) + internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal") + internal.parent.mkdir(parents=True) + internal.write_text("CONFIG_MBEDTLS_TLS_ENABLED=n\n") + sdkconfig = CORE.relative_build_path("sdkconfig.test") + seen: list[str] = [] + + def record_write(minimal: bool = False, builtin_components=None) -> None: + if minimal: + sdkconfig.write_text("CONFIG_MBEDTLS_TLS_ENABLED=y\n") + else: + seen.append(sdkconfig.read_text()) + + with ( + patch.object(toolchain, "load_cached_builtin_components", return_value=None), + patch.object(toolchain, "save_cached_builtin_components"), + patch( + "esphome.build_gen.espidf.get_available_components", return_value=["lwip"] + ), + patch("esphome.build_gen.espidf.write_project", side_effect=record_write), + patch.object(toolchain, "run_reconfigure", return_value=0), + ): + assert toolchain._configure_project() == 0 + assert seen == ["CONFIG_MBEDTLS_TLS_ENABLED=n\n"] + + def test_run_compile_discovery_failure_stops_before_full_write( setup_core: Path, ) -> None: From 3e7fafb7051bc84860db9d6cadca22d591b3eea7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 00:09:55 -1000 Subject: [PATCH 36/97] [daikin] Prefix the PendingComfort enumerators (#20512) --- esphome/components/daikin/daikin.cpp | 14 ++++++++------ esphome/components/daikin/daikin.h | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 0194b34db09..8b8d16677a1 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -266,9 +266,10 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { // Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid // A comfort frame whose own state frame never came must not attach to a later transmission const uint32_t now = App.get_loop_component_start_time(); - const PendingComfort comfort = - now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS ? this->pending_comfort_ : PendingComfort::UNKNOWN; - this->pending_comfort_ = PendingComfort::UNKNOWN; + const PendingComfort comfort = now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS + ? this->pending_comfort_ + : PendingComfort::PENDING_COMFORT_UNKNOWN; + this->pending_comfort_ = PendingComfort::PENDING_COMFORT_UNKNOWN; if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) { this->preset = climate::CLIMATE_PRESET_BOOST; } else if (frame[13] & DAIKIN_PRESET_QUIET_ON) { @@ -277,9 +278,9 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { this->preset = climate::CLIMATE_PRESET_ECO; } else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) { this->preset = climate::CLIMATE_PRESET_ACTIVITY; - } else if (comfort == PendingComfort::ON) { + } else if (comfort == PendingComfort::PENDING_COMFORT_ON) { this->preset = climate::CLIMATE_PRESET_COMFORT; - } else if (comfort == PendingComfort::OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { + } else if (comfort == PendingComfort::PENDING_COMFORT_OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { // Without a comfort frame in this transmission a current comfort preset is left alone this->preset = climate::CLIMATE_PRESET_NONE; } @@ -337,7 +338,8 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) { // Nothing changes yet: the state frame that follows applies it once it has passed its own checksum - this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::ON : PendingComfort::OFF; + this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::PENDING_COMFORT_ON + : PendingComfort::PENDING_COMFORT_OFF; this->pending_comfort_at_ = App.get_loop_component_start_time(); return true; } diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index 7f8638101d5..3124ba0145e 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -82,8 +82,8 @@ class DaikinClimate final : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); /// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame - enum class PendingComfort : uint8_t { UNKNOWN, OFF, ON }; - PendingComfort pending_comfort_{PendingComfort::UNKNOWN}; + enum class PendingComfort : uint8_t { PENDING_COMFORT_UNKNOWN, PENDING_COMFORT_OFF, PENDING_COMFORT_ON }; + PendingComfort pending_comfort_{PendingComfort::PENDING_COMFORT_UNKNOWN}; uint32_t pending_comfort_at_{0}; }; From 2681126986199d41a9b0f3a64bb8f67c37d89c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:31:14 +0200 Subject: [PATCH 37/97] [teleinfo] Fix out-of-bounds read when splitting a frame into groups (#20513) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 6b68dacd31b..adf56707844 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -116,8 +116,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_index_ - 1))) && - ((buf_finger - buf_) < buf_index_)) { // NOLINT(clang-diagnostic-sign-compare) + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -168,8 +167,8 @@ void TeleInfo::loop() { continue; } - /* Advance buf_finger to end of group */ - buf_finger += field_len + 1 + 1 + 1; + /* Resume after the group's 0xd; grp_end < buf_end, so this stays in bounds. */ + buf_finger = grp_end + 1; publish_value_(std::string(tag_), std::string(val_)); } From 1402cb7b64a1f1d3adfe4f62b04abdbfd321efb8 Mon Sep 17 00:00:00 2001 From: Georg Seema Date: Sun, 11 Oct 2026 13:32:39 +0300 Subject: [PATCH 38/97] [tuya] Add forced minute boundary time sync (#18603) Co-authored-by: J. Nick Koston Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/tuya/__init__.py | 25 ++++++++-- esphome/components/tuya/tuya.cpp | 46 +++++++++++++++---- esphome/components/tuya/tuya.h | 7 ++- tests/component_tests/tuya/__init__.py | 0 tests/component_tests/tuya/test_init.py | 23 ++++++++++ .../tuya/test-force-time-sync.esp32-idf.yaml | 16 +++++++ 6 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 tests/component_tests/tuya/__init__.py create mode 100644 tests/component_tests/tuya/test_init.py create mode 100644 tests/components/tuya/test-force-time-sync.esp32-idf.yaml diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index bf392c78268..c05db51cdf9 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -4,6 +4,7 @@ from esphome.components import time, uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["uart"] DOMAIN = "tuya" @@ -13,6 +14,7 @@ CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" CONF_ON_DATAPOINT_UPDATE = "on_datapoint_update" CONF_DATAPOINT_TYPE = "datapoint_type" CONF_STATUS_PIN = "status_pin" +CONF_FORCE_TIME_SYNC = "force_time_sync" tuya_ns = cg.esphome_ns.namespace("tuya") TuyaDatapointType = tuya_ns.enum("TuyaDatapointType", is_class=True) @@ -43,11 +45,23 @@ DATAPOINT_TYPES = { CONF_TUYA_ID = "tuya_id" -CONFIG_SCHEMA = ( + + +def _validate_time_options(config: ConfigType) -> ConfigType: + if config[CONF_FORCE_TIME_SYNC] and CONF_TIME_ID not in config: + raise cv.Invalid( + f"{CONF_FORCE_TIME_SYNC} requires {CONF_TIME_ID}", + path=[CONF_FORCE_TIME_SYNC], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Tuya), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional(CONF_FORCE_TIME_SYNC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS): cv.ensure_list( cv.uint8_t ), @@ -63,7 +77,8 @@ CONFIG_SCHEMA = ( } ) .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + _validate_time_options, ) @@ -71,9 +86,11 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) + if (time_id := config.get(CONF_TIME_ID)) is not None: + time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) + if config[CONF_FORCE_TIME_SYNC]: + cg.add(var.set_force_time_sync(True)) if CONF_STATUS_PIN in config: status_pin_ = await cg.gpio_pin_expression(config[CONF_STATUS_PIN]) cg.add(var.set_status_pin(status_pin_)) diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index b23dcc946bd..c6316f59fce 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -23,11 +23,13 @@ ESPHOME_LOG_TAG(TAG, "tuya"); static constexpr uint32_t HEARTBEAT_INTERVAL_ID = 0; static constexpr uint32_t WIFI_STATUS_INTERVAL_ID = 1; static constexpr uint32_t DATAPOINT_DUMP_TIMEOUT_ID = 2; +static constexpr uint32_t FORCE_TIME_SYNC_INTERVAL_ID = 3; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static constexpr uint32_t FORCE_TIME_SYNC_CHECK_INTERVAL = 1000; static bool network_is_connected() { #ifdef USE_NETWORK @@ -39,6 +41,13 @@ static bool network_is_connected() { void Tuya::setup() { this->set_interval(HEARTBEAT_INTERVAL_ID, 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); +#ifdef USE_TIME + if (this->force_time_sync_) { + this->register_local_time_sync_callback_(); + this->set_interval(FORCE_TIME_SYNC_INTERVAL_ID, FORCE_TIME_SYNC_CHECK_INTERVAL, + [this] { this->check_force_time_sync_(); }); + } +#endif if (this->status_pin_ != nullptr) { this->status_pin_->digital_write(false); } @@ -99,6 +108,11 @@ void Tuya::dump_config() { } LOG_PIN(" Status Pin: ", this->status_pin_); ESP_LOGCONFIG(TAG, " Product: '%s'", this->product_.c_str()); +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + ESP_LOGCONFIG(TAG, " Force time sync: %s", YESNO(this->force_time_sync_)); + } +#endif } bool Tuya::validate_message_() { @@ -306,12 +320,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff #ifdef USE_TIME if (this->time_id_ != nullptr) { this->send_local_time_(); - - if (!this->time_sync_callback_registered_) { - // tuya mcu supports time, so we let them know when our time changed - this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); - this->time_sync_callback_registered_ = true; - } + this->register_local_time_sync_callback_(); } else #endif { @@ -616,7 +625,23 @@ void Tuya::send_wifi_status_() { } #ifdef USE_TIME -void Tuya::send_local_time_() { +void Tuya::register_local_time_sync_callback_() { + if (this->time_sync_callback_registered_) { + return; + } + this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); + this->time_sync_callback_registered_ = true; +} + +void Tuya::check_force_time_sync_() { + ESPTime now = this->time_id_->now(); + if (!now.is_valid() || now.minute == this->last_force_time_sync_minute_) + return; + this->last_force_time_sync_minute_ = now.minute; + this->send_local_time_(true); +} + +void Tuya::send_local_time_(bool periodic) { std::vector payload; ESPTime now = this->time_id_->now(); if (now.is_valid()) { @@ -631,7 +656,12 @@ void Tuya::send_local_time_() { if (day_of_week == 0) { day_of_week = 7; } - ESP_LOGD(TAG, "Sending local time"); + // The forced send repeats every minute, so it stays out of the debug log + if (periodic) { + ESP_LOGV(TAG, "Sending local time"); + } else { + ESP_LOGD(TAG, "Sending local time"); + } payload = std::vector{0x01, year, month, day_of_month, hour, minute, second, day_of_week}; } else { // By spec we need to notify MCU that the time was not obtained if this is a response to a query diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index f074d301629..1e2e12200dd 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -113,6 +113,7 @@ class Tuya final : public Component, public uart::UARTDevice { TuyaInitState get_init_state(); #ifdef USE_TIME void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } + void set_force_time_sync(bool force_time_sync) { this->force_time_sync_ = force_time_sync; } #endif void add_ignore_mcu_update_on_datapoints(uint8_t ignore_mcu_update_on_datapoints) { this->ignore_mcu_update_on_datapoints_.push_back(ignore_mcu_update_on_datapoints); @@ -144,9 +145,13 @@ class Tuya final : public Component, public uart::UARTDevice { uint8_t get_wifi_rssi_(); #ifdef USE_TIME - void send_local_time_(); + void register_local_time_sync_callback_(); + void check_force_time_sync_(); + void send_local_time_(bool periodic = false); void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; + bool force_time_sync_{false}; + uint8_t last_force_time_sync_minute_{0xFF}; // 0xFF: no minute seen yet bool time_sync_callback_registered_{false}; bool gmt_time_sync_callback_registered_{false}; #endif diff --git a/tests/component_tests/tuya/__init__.py b/tests/component_tests/tuya/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/tuya/test_init.py b/tests/component_tests/tuya/test_init.py new file mode 100644 index 00000000000..6984b9aa1b4 --- /dev/null +++ b/tests/component_tests/tuya/test_init.py @@ -0,0 +1,23 @@ +"""Tests for Tuya time synchronization configuration validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.tuya import CONF_FORCE_TIME_SYNC, CONFIG_SCHEMA +from esphome.const import CONF_TIME_ID + + +def test_force_time_sync_accepts_time_id() -> None: + config = CONFIG_SCHEMA( + { + CONF_FORCE_TIME_SYNC: True, + CONF_TIME_ID: "tuya_time", + } + ) + + assert config[CONF_FORCE_TIME_SYNC] is True + + +def test_force_time_sync_requires_time_id() -> None: + with pytest.raises(cv.Invalid, match="force_time_sync requires time_id"): + CONFIG_SCHEMA({CONF_FORCE_TIME_SYNC: True}) diff --git a/tests/components/tuya/test-force-time-sync.esp32-idf.yaml b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml new file mode 100644 index 00000000000..df0fe110b44 --- /dev/null +++ b/tests/components/tuya/test-force-time-sync.esp32-idf.yaml @@ -0,0 +1,16 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + status_pin: GPIO12 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + common: !include common.yaml + +time: + - platform: sntp + id: tuya_time + +tuya: + time_id: tuya_time + force_time_sync: true From 3df03e4be962687ddc6feeb1c8184534a0c526eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:31:07 +0200 Subject: [PATCH 39/97] [emontx] Read UART bytes with read_byte() (#20514) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/emontx.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index ae92b737316..17497984c39 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -18,7 +18,9 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } void EmonTx::loop() { // Read all available data to prevent UART buffer overflow while (this->available() > 0) { - uint8_t received = this->read(); + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == '\r') { continue; // Ignore CR From 6001cde2342aecea184d16824cf9b0472a46484a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:41:02 +0200 Subject: [PATCH 40/97] [emontx] Discard the rest of a line that overflows the buffer (#20515) Co-authored-by: Claude Opus 5.5 Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/emontx/emontx.cpp | 13 +++-- esphome/components/emontx/emontx.h | 1 + tests/components/emontx/common.h | 45 +++++++++++++++ tests/components/emontx/emontx_test.cpp | 75 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 tests/components/emontx/common.h create mode 100644 tests/components/emontx/emontx_test.cpp diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 17497984c39..675744b5f58 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -22,11 +22,11 @@ void EmonTx::loop() { if (!this->read_byte(&received)) break; - if (received == '\r') { - continue; // Ignore CR - } else if (received == '\n') { + if (received == '\n') { // End of line - process the buffer - if (this->buffer_pos_ > 0) { + if (this->discarding_line_) { + this->discarding_line_ = false; + } else if (this->buffer_pos_ > 0) { // Null-terminate for safe logging and c_str() use size_t len = this->buffer_pos_; this->buffer_[len] = '\0'; @@ -44,9 +44,12 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } } + } else if (received == '\r' || this->discarding_line_) { + // Ignore CR, and the rest of a line that overflowed the buffer } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH); + ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; + this->discarding_line_ = true; } else { this->buffer_[this->buffer_pos_++] = static_cast(received); } diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index c56005e7ad8..f97c5aa6cb6 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -55,6 +55,7 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; + bool discarding_line_{false}; // Set after an overflow until the end of that line std::array buffer_{}; }; diff --git a/tests/components/emontx/common.h b/tests/components/emontx/common.h new file mode 100644 index 00000000000..4af5a98572d --- /dev/null +++ b/tests/components/emontx/common.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/emontx/emontx.h" + +namespace esphome::emontx::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(const char *data) { this->rx.insert(this->rx.end(), data, data + strlen(data)); } + void push_rx(size_t count, uint8_t byte) { this->rx.insert(this->rx.end(), count, byte); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + 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 // defined(USE_ESP8266) || defined(USE_ESP32) +}; + +} // namespace esphome::emontx::testing diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp new file mode 100644 index 00000000000..373b54d7857 --- /dev/null +++ b/tests/components/emontx/emontx_test.cpp @@ -0,0 +1,75 @@ +#include + +#include +#include + +#include "esphome/components/emontx/emontx.h" +#include "common.h" + +namespace esphome::emontx::testing { + +namespace { +class EmonTxTest : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.add_on_data_callback([this](StringRef line) { this->lines_.emplace_back(line.c_str(), line.size()); }); + this->sut_.add_on_json_callback([this](JsonObject, StringRef) { this->json_count_++; }); + } + + MockUARTComponent uart_; + EmonTx sut_; + std::vector lines_; + int json_count_{0}; +}; +} // namespace + +TEST_F(EmonTxTest, LinePassedToDataCallbacks) { + this->uart_.push_rx("MSG:1\r\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:1"); + EXPECT_EQ(this->json_count_, 0); +} + +TEST_F(EmonTxTest, JsonLineParsed) { + this->uart_.push_rx("{\"P1\":100}\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->json_count_, 1); +} + +TEST_F(EmonTxTest, OverflowedLineDiscardedUntilLineFeed) { + // One byte more than the buffer holds, then more of the same line. + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->uart_.push_rx("tail\n"); + this->uart_.push_rx("MSG:2\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:2"); +} + +TEST_F(EmonTxTest, OverflowedLineTailNotParsedAsJson) { + // The tail after the overflow looks like a JSON line and must not be parsed. + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->uart_.push_rx("{\"P1\":100}\n"); + this->sut_.loop(); + + EXPECT_TRUE(this->lines_.empty()); + EXPECT_EQ(this->json_count_, 0); +} + +TEST_F(EmonTxTest, OverflowAcrossLoopCalls) { + this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); + this->sut_.loop(); + this->uart_.push_rx("tail\nMSG:3\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "MSG:3"); +} + +} // namespace esphome::emontx::testing From 493db0c2bb750a2fced3fadad0d0aabb0f0c87e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:45:19 -1000 Subject: [PATCH 41/97] [esp32] Add allow_insecure_tls() for components that skip certificate verification (#20506) --- esphome/components/esp32/__init__.py | 11 +++++++++++ esphome/components/http_request/__init__.py | 10 ++-------- esphome/components/nextion/display.py | 5 +---- tests/component_tests/esp32/test_esp32.py | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 9bceaf5676d..422413a269f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2071,6 +2071,17 @@ def require_vfs_termios() -> None: CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True +def allow_insecure_tls() -> None: + """Let esp-tls connect without verifying the server certificate. + + The two sdkconfig flags are global, so a component asking for an unverified + connection switches them on for the whole build; they are never switched off + here because another component may have asked first. + """ + add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) + add_idf_sdkconfig_option("CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True) + + def require_certificate_bundle() -> None: """Enable the mbedTLS root certificate bundle for this build. diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index ad51d5f39be..a2c181a5ae0 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -226,14 +226,8 @@ async def to_code(config: ConfigType) -> None: # use_full_certificate_bundle: true esp32.require_certificate_bundle() - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_INSECURE", - not config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", - not config.get(CONF_VERIFY_SSL), - ) + else: + esp32.allow_insecure_tls() if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CORE.is_rp2 and CORE.using_arduino: diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 3a70be21caf..983a4777e81 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -261,10 +261,7 @@ async def to_code(config): if CORE.is_esp32: esp32.request_http_client() - esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True - ) + esp32.allow_insecure_tls() elif CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 9072a906293..bb751dd677c 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -30,6 +30,7 @@ from esphome.components.esp32 import ( _reconcile_network_sdkconfig, _reconcile_vfs_fatfs_sdkconfig, _user_sdkconfig_wants_tls, + allow_insecure_tls, ) from esphome.components.esp32.const import ( KEY_ESP32, @@ -2400,3 +2401,20 @@ def test_nvs_cache_in_psram_explicit_true_on_valid_board_is_quiet( sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] assert sdkconfig.get("CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM") is True assert "nvs_cache_in_psram" not in caplog.text + + +def test_allow_insecure_tls_only_switches_the_flags_on( + set_core_config: SetCoreConfigCallable, +) -> None: + """A component that verifies certificates must not undo another one's request.""" + set_core_config(PlatformFramework.ESP32_IDF) + CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: {}} + + allow_insecure_tls() + # A second caller, or a verifying component that no longer touches the flags, leaves them on + allow_insecure_tls() + + assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == { + "CONFIG_ESP_TLS_INSECURE": True, + "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY": True, + } From 7f7ae4c8fc7bd7a6d9640ea27b9e33b4332e7d37 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 02:47:12 -1000 Subject: [PATCH 42/97] [uart_tcp] Prove the accept has run before checking what a new client receives (#20489) --- tests/integration/test_uart_tcp_bridge.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_uart_tcp_bridge.py b/tests/integration/test_uart_tcp_bridge.py index 33d32f4c07e..d2e1603bd15 100644 --- a/tests/integration/test_uart_tcp_bridge.py +++ b/tests/integration/test_uart_tcp_bridge.py @@ -84,13 +84,16 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 1) + # The accept log precedes the stale discard in the same loop pass, so + # prove the device is past it with a client to UART round trip before + # writing the bytes the client must see. + writer.write(b"down1") + await writer.drain() + assert await read_uart(5) == b"down1" os.write(controller_fd, b"live!") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live!", ( "First bytes to the client were not the live payload" ) - writer.write(b"down1") - await writer.drain() - assert await read_uart(5) == b"down1" # Drop the client; bytes while no client is connected are discarded # when the next one is accepted. @@ -101,13 +104,13 @@ async def test_uart_tcp_bridge( reader, writer = await asyncio.open_connection("127.0.0.1", server_port) await wait_log_count("Client connected", 2) + writer.write(b"down2") + await writer.drain() + assert await read_uart(5) == b"down2" os.write(controller_fd, b"live2") assert await asyncio.wait_for(reader.readexactly(5), 10) == b"live2", ( "Second client received stale bytes from the gap" ) - writer.write(b"down2") - await writer.drain() - assert await read_uart(5) == b"down2" writer.close() # A peer outside the allow list is rejected and closed. From c5cbc4a4e080beb27a6eda36758df97f550d305d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:50:42 +0200 Subject: [PATCH 43/97] [emontx] Test lines of exactly MAX_LINE_LENGTH bytes (#20517) Co-authored-by: Claude Opus 5.5 --- tests/components/emontx/emontx_test.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 373b54d7857..20d3adf0dc0 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -41,6 +41,25 @@ TEST_F(EmonTxTest, JsonLineParsed) { EXPECT_EQ(this->json_count_, 1); } +// The CR check must come before the overflow check, or a full line ending in CRLF would overflow. +TEST_F(EmonTxTest, FullLengthLineWithCrLfDelivered) { + this->uart_.push_rx(MAX_LINE_LENGTH, 'a'); + this->uart_.push_rx("\r\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], std::string(MAX_LINE_LENGTH, 'a')); +} + +TEST_F(EmonTxTest, FullLengthLineWithLfDelivered) { + this->uart_.push_rx(MAX_LINE_LENGTH, 'a'); + this->uart_.push_rx("\n"); + this->sut_.loop(); + + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], std::string(MAX_LINE_LENGTH, 'a')); +} + TEST_F(EmonTxTest, OverflowedLineDiscardedUntilLineFeed) { // One byte more than the buffer holds, then more of the same line. this->uart_.push_rx(MAX_LINE_LENGTH + 1, 'a'); From 1312d5eec018cead1926f6e6e5c78cc3a016e365 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Sun, 11 Oct 2026 18:51:41 +0200 Subject: [PATCH 44/97] [modbus] Pass the request's address to client device callbacks (#20504) --- esphome/components/modbus/modbus.cpp | 10 +- esphome/components/modbus/modbus.h | 15 +++ .../modbus/modbus_client_hub_test.cpp | 109 ++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 80065546cf7..a79a7c3bd8c 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -950,7 +950,7 @@ bool ModbusDeviceCommand::sent() { // on_sent() is not a terminal, so nothing is consumed. if (this->device == nullptr) return false; - this->device->on_sent(this->frame.pdu()); + this->device->on_sent(this->frame.address(), this->frame.pdu()); return true; } @@ -958,7 +958,7 @@ bool ModbusDeviceCommand::notify_retired() { if (!this->decrement_pending()) return false; // nothing owed - stop the sweep draining this entry if (this->device != nullptr) - this->device->on_not_sent(this->frame.pdu()); + this->device->on_not_sent(this->frame.address(), this->frame.pdu()); return true; // consumed one debt (delivered, or silent when device-less) - keep draining to zero } @@ -969,7 +969,7 @@ bool ModbusDeviceCommand::response(std::span response_pdu) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_response(this->frame.pdu(), response_pdu); + this->device->on_response(this->frame.address(), this->frame.pdu(), response_pdu); return true; } @@ -979,7 +979,7 @@ bool ModbusDeviceCommand::error(ExceptionCode exception_code) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_error(this->frame.pdu(), exception_code); + this->device->on_error(this->frame.address(), this->frame.pdu(), exception_code); return true; } @@ -1004,7 +1004,7 @@ bool ModbusDeviceCommand::timed_out() { return false; // resolved, no one to tell // A cleared frame that timed out still honors a retry: the clear is address-scoped (any device may // call it) while the retry is the owning device's call via on_no_response - the bus obeys the owner. - if (this->device->on_no_response(this->frame.pdu())) + if (this->device->on_no_response(this->frame.address(), this->frame.pdu())) this->increment_pending(); // granted retry = re-request (capped) return true; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 298cd9f5278..011813a20d3 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -437,6 +437,21 @@ class ModbusClientDevice { return this->on_modbus_no_response(); #pragma GCC diagnostic pop } + /// Overloads of the hooks above with the device address of the request first, taken from the request frame, so it + /// stays correct after set_address(). The hub calls these; the defaults forward to the hooks above. + /// Override one form per hook: overriding an address form drops its forward, so the other form is no longer called. + virtual void on_response(uint8_t address, std::span request_pdu, + std::span response_pdu) { + this->on_response(request_pdu, response_pdu); + } + virtual void on_error(uint8_t address, std::span request_pdu, ExceptionCode exception_code) { + this->on_error(request_pdu, exception_code); + } + virtual void on_not_sent(uint8_t address, std::span request_pdu) { this->on_not_sent(request_pdu); } + virtual void on_sent(uint8_t address, std::span request_pdu) { this->on_sent(request_pdu); } + virtual bool on_no_response(uint8_t address, std::span request_pdu) { + return this->on_no_response(request_pdu); + } // Remove before 2027.2.0 ESPDEPRECATED("Override on_not_sent() instead. Removed in 2027.2.0", "2026.8.0") virtual void on_modbus_not_sent() {} diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index 3bdfa094e0e..1e351b4971a 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -2436,4 +2436,113 @@ TEST(ModbusClientHubPriority, ReadModifyWritesRankAsWrites) { EXPECT_EQ(next->priority(), CommandPriority::WRITE); // 0x16 wins selection over the queued read EXPECT_EQ(next->frame.pdu()[0], 0x16); } +namespace { +// Overrides only the address overloads and records the address each one is given. +class AddressRecordingDevice : public ModbusClientDevice { + public: + AddressRecordingDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(uint8_t address, std::span request_pdu) override { this->sent_ = address; } + void on_response(uint8_t address, std::span request_pdu, + std::span response_pdu) override { + this->response_ = address; + } + void on_error(uint8_t address, std::span request_pdu, ExceptionCode exception_code) override { + this->error_ = address; + } + bool on_no_response(uint8_t address, std::span request_pdu) override { + this->no_response_ = address; + return this->retry_; + } + void on_not_sent(uint8_t address, std::span request_pdu) override { this->not_sent_ = address; } + int sent_{-1}; + int response_{-1}; + int error_{-1}; + int no_response_{-1}; + int not_sent_{-1}; + bool retry_{false}; +}; +} // namespace + +// Each callback gets the address its request was sent to, even when the device has changed its own +// address before the outcome arrives. +TEST(ModbusClientHubAddress, CallbacksReportTheRequestAddressAfterSetAddress) { + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + const uint8_t exception_response[] = {0x83, 0x02}; + { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, 0x02); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, 0x02); + hub.receive_frame_for_test(0x02, ok_response); + EXPECT_EQ(device.response_, 0x02); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x03); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.force_send_next(); + hub.receive_frame_for_test(0x03, exception_response); + EXPECT_EQ(device.error_, 0x03); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x04); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.force_send_next(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_, 0x04); + } + { + NoResponseProbeHub hub; + AddressRecordingDevice device(&hub, 0x06); + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.clear_tx_queue_for_address(0x06); + hub.sweep_for_test(); + EXPECT_EQ(device.not_sent_, 0x06); + } +} + +// A granted retry resends the same frame, so the retry's callbacks keep the original address too. +TEST(ModbusClientHubAddress, GrantedRetryKeepsTheRequestAddress) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, 0x04); + device.retry_ = true; + ASSERT_TRUE(device.queue_pdu(read_pdu())); + device.set_address(0x05); + hub.send_next_for_test(); + hub.timeout_waiting(); + EXPECT_EQ(device.no_response_, 0x04); + + device.sent_ = -1; + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, 0x04); + const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00}; + hub.receive_frame_for_test(0x04, ok_response); + EXPECT_EQ(device.response_, 0x04); +} + +// A broadcast reports address 0 from on_sent, its only callback. +TEST(ModbusClientHubAddress, BroadcastReportsAddressZero) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + AddressRecordingDevice device(&hub, BROADCAST_ADDRESS); + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; + ASSERT_TRUE(device.queue_pdu(write)); + device.set_address(0x05); + hub.send_next_for_test(); + EXPECT_EQ(device.sent_, BROADCAST_ADDRESS); +} } // namespace esphome::modbus::testing From 5186728d53d0e930adc5db8f59ab7acedc18bdff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:52:41 +0200 Subject: [PATCH 45/97] [emontx] Pass on_json/on_data data without copies and reject deferring actions (#20516) --- esphome/components/emontx/__init__.py | 24 +++++++-- .../emontx/test_synchronous_automations.py | 54 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/emontx/test_synchronous_automations.py diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 0262f8681f1..971f9769246 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -44,13 +44,29 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] +def _synchronous_automation(value: ConfigType) -> ConfigType: + """Reject deferring actions: the received line and parsed JSON are only valid while the + automation runs, and DelayAction and friends keep the trigger arguments for later.""" + if automation.has_non_synchronous_actions(value): + raise cv.Invalid( + "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and " + "on_data: the received data is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + +def _synchronous_automation_schema() -> cv.All: + return cv.All(automation.validate_automation({}), _synchronous_automation) + + # Main configuration schema CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(EmonTx), - cv.Optional(CONF_ON_JSON): automation.validate_automation({}), - cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), + cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), } ) .extend(cv.COMPONENT_SCHEMA) @@ -104,10 +120,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_JSON, "add_on_json_callback", - [(cg.JsonObject, "json"), (cg.std_string, "raw_json")], + [(cg.JsonObject, "json"), (cg.StringRef, "raw_json")], ), automation.CallbackAutomation( - CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")] + CONF_ON_DATA, "add_on_data_callback", [(cg.StringRef, "data")] ), ) diff --git a/tests/component_tests/emontx/test_synchronous_automations.py b/tests/component_tests/emontx/test_synchronous_automations.py new file mode 100644 index 00000000000..c190c14ab19 --- /dev/null +++ b/tests/component_tests/emontx/test_synchronous_automations.py @@ -0,0 +1,54 @@ +"""Tests that emontx on_json/on_data automations reject deferring actions. + +The triggers pass views into the receive buffer and the parsed JSON document, which are only valid +while the automation runs, so the deferring-actions guard is a safety property. +""" + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.emontx import CONF_ON_JSON, CONFIG_SCHEMA +from esphome.const import CONF_ON_DATA +from esphome.core import Lambda +from esphome.types import ConfigType + +TRIGGER_KEYS = [CONF_ON_JSON, CONF_ON_DATA] + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _trigger_schema(trigger_key: str) -> Callable[[list[ConfigType]], list[ConfigType]]: + """The validator CONFIG_SCHEMA applies to this trigger key.""" + return CONFIG_SCHEMA.schema[cv.Optional(trigger_key)] + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_synchronous_automation_accepted(trigger_key: str) -> None: + _trigger_schema(trigger_key)([{"then": [SYNCHRONOUS_ACTION]}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_deferring_action_rejected(trigger_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)([{"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_nested_deferring_action_rejected(trigger_key: str) -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)([{"then": actions}]) + + +@pytest.mark.parametrize("trigger_key", TRIGGER_KEYS) +def test_deferring_action_in_second_automation_rejected(trigger_key: str) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(trigger_key)( + [{"then": [SYNCHRONOUS_ACTION]}, {"then": [DEFERRING_ACTION]}] + ) From e9073f7298f70cf85efb095a7898a4672511b3e0 Mon Sep 17 00:00:00 2001 From: Mat931 <49403702+Mat931@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:38:37 +0000 Subject: [PATCH 46/97] [systa_bus] Add text_sensor (#20518) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/systa_bus/__init__.py | 2 + .../components/systa_bus/sensor/__init__.py | 9 +++- esphome/components/systa_bus/systa_bus.h | 3 +- .../systa_bus/text_sensor/__init__.py | 45 +++++++++++++++++++ .../text_sensor/systa_bus_text_sensor.cpp | 27 +++++++++++ .../text_sensor/systa_bus_text_sensor.h | 20 +++++++++ esphome/core/defines.h | 2 +- tests/components/systa_bus/common.yaml | 6 +++ 8 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 esphome/components/systa_bus/text_sensor/__init__.py create mode 100644 esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp create mode 100644 esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py index 49ac214d400..ae44017112b 100644 --- a/esphome/components/systa_bus/__init__.py +++ b/esphome/components/systa_bus/__init__.py @@ -17,6 +17,8 @@ SystaBus = systa_bus_ns.class_("SystaBus", cg.Component, uart.UARTDevice) CONF_SYSTA_BUS_ID = "systa_bus_id" +CONF_SYSTASOLAR_AQUA = "systasolar_aqua" + CONFIG_SCHEMA = uart.UART_DEVICE_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(SystaBus), diff --git a/esphome/components/systa_bus/sensor/__init__.py b/esphome/components/systa_bus/sensor/__init__.py index 3def5fba12f..e4aa0684c78 100644 --- a/esphome/components/systa_bus/sensor/__init__.py +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -13,11 +13,16 @@ from esphome.const import ( ) from esphome.types import ConfigType -from .. import CONF_SYSTA_BUS_ID, SystaBus, register_systa_bus_listener, systa_bus_ns +from .. import ( + CONF_SYSTA_BUS_ID, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) SystaSolarAquaSensor = systa_bus_ns.class_("SystaSolarAquaSensor", cg.Component) -CONF_SYSTASOLAR_AQUA = "systasolar_aqua" CONF_TEMPERATURE_TSA = "temperature_tsa" CONF_TEMPERATURE_TSE = "temperature_tse" CONF_TEMPERATURE_TWU = "temperature_twu" diff --git a/esphome/components/systa_bus/systa_bus.h b/esphome/components/systa_bus/systa_bus.h index 99ca31590f5..b46f769db00 100644 --- a/esphome/components/systa_bus/systa_bus.h +++ b/esphome/components/systa_bus/systa_bus.h @@ -14,7 +14,8 @@ static constexpr uint8_t START_BYTE = 0xfc; static constexpr uint8_t FRAME_OVERHEAD = 3; // start byte, length, checksum // SystaSolar Aqua sensor data, 25 bytes: [0..1] type, [2..3] not decoded, big-endian int16 in 0.1 degrees at -// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..23] not decoded. +// [4..5] TSA, [6..7] TSE, [8..9] TWU and [10..11] TW2, pump speed in percent at [12], [13..14] not decoded, +// [15] error code (zero for no error), [16..23] not decoded. static constexpr uint16_t MESSAGE_TYPE_AQUA_SENSOR_DATA = 0xfc16; // The bus only buffers known message types, so the buffer is sized by the longest of them diff --git a/esphome/components/systa_bus/text_sensor/__init__.py b/esphome/components/systa_bus/text_sensor/__init__.py new file mode 100644 index 00000000000..4b5cb52b087 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODEL +from esphome.types import ConfigType + +from .. import ( + CONF_SYSTA_BUS_ID, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) + +SystaSolarAquaTextSensor = systa_bus_ns.class_("SystaSolarAquaTextSensor", cg.Component) + +CONF_ERROR_CODE = "error_code" + + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_SYSTASOLAR_AQUA: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaSolarAquaTextSensor), + cv.GenerateID(CONF_SYSTA_BUS_ID): cv.use_id(SystaBus), + cv.Optional(CONF_ERROR_CODE): text_sensor.text_sensor_schema(), + } + ), + }, + key=CONF_MODEL, + lower=True, + space="_", +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if config[CONF_MODEL] == CONF_SYSTASOLAR_AQUA and CONF_ERROR_CODE in config: + sens = await text_sensor.new_text_sensor(config[CONF_ERROR_CODE]) + cg.add(var.set_error_code_text_sensor(sens)) + + systa_bus = await cg.get_variable(config[CONF_SYSTA_BUS_ID]) + await register_systa_bus_listener(systa_bus, var) diff --git a/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp new file mode 100644 index 00000000000..cba3caa8a6d --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp @@ -0,0 +1,27 @@ +#include "systa_bus_text_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::systa_bus { + +ESPHOME_LOG_TAG(TAG, "systa_bus.text_sensor"); + +void SystaSolarAquaTextSensor::dump_config() { + ESP_LOGCONFIG(TAG, "SystaSolar Aqua:"); + LOG_TEXT_SENSOR(" ", "Error code", this->error_code_text_sensor_); +} + +void SystaSolarAquaTextSensor::handle_message(std::span message) { + if (get_message_type(message) == MESSAGE_TYPE_AQUA_SENSOR_DATA) { + if (this->error_code_text_sensor_ != nullptr) { + if (message[15] == 0) { + this->error_code_text_sensor_->publish_state("--"); + } else { + char buf[4]; + int len = snprintf(buf, sizeof(buf), "%02d", message[15]); + this->error_code_text_sensor_->publish_state(buf, static_cast(len)); + } + } + } +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h new file mode 100644 index 00000000000..f0c12b5dce5 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/text_sensor/text_sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaTextSensor : public SystaBusListener, public Component { + public: + void dump_config() override; + void set_error_code_text_sensor(text_sensor::TextSensor *sensor) { this->error_code_text_sensor_ = sensor; } + void handle_message(std::span message) override; + + protected: + text_sensor::TextSensor *error_code_text_sensor_{nullptr}; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 0e5e2f4f292..de621f891a1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -188,7 +188,7 @@ #define USE_REMOTE_PROTOCOL_SYMPHONY #define USE_REMOTE_PROTOCOL_TOSHIBA_AC #define USE_REMOTE_PROTOCOL_TOTO -#define SYSTA_BUS_LISTENER_COUNT 1 +#define SYSTA_BUS_LISTENER_COUNT 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER diff --git a/tests/components/systa_bus/common.yaml b/tests/components/systa_bus/common.yaml index ea97e2d840b..b4ae2410fde 100644 --- a/tests/components/systa_bus/common.yaml +++ b/tests/components/systa_bus/common.yaml @@ -13,3 +13,9 @@ sensor: name: TW2 pump_speed: name: Pump Speed + +text_sensor: + - platform: systa_bus + model: systasolar_aqua + error_code: + name: Error Code From 8e8261c39e7a6ea998f111fd768263f7a6125a46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 08:14:56 -1000 Subject: [PATCH 47/97] [core] Add this week's review lessons to AGENTS.md (#20522) --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8194cd50407..6bdd9467c01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix - Enumerator names: prefix every value of an `enum class` with the enum name converted to `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with @@ -177,6 +178,8 @@ file does, and it is the authority when they disagree. The most useful starting Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm + rebuilds the callback and re-queues a scheduler item, and the period drifts. * **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a `static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other components, so number them from 0 and keep all of a component's ids together in one place. @@ -191,6 +194,18 @@ file does, and it is the authority when they disagree. The most useful starting // Bad - this is exactly what the base class already does float get_setup_priority() const override { return setup_priority::DATA; } ``` + * **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope + constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the + table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`); + for a table of structs copy the whole entry out and return the copy, never a pointer into flash. + On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with + `ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g. + `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array + a `TEMPLATABLE_BYTES` field so a static code stays in flash. + * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure + sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. + * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a + runtime check of something the build already knows. * **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they can be stored in flash rather than RAM. ```cpp @@ -534,6 +549,7 @@ file does, and it is the authority when they disagree. The most useful starting `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. + An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. @@ -569,6 +585,13 @@ file does, and it is the authority when they disagree. The most useful starting * **Testing:** * **Python:** Run unit tests with `pytest`. * **C++:** Use `clang-tidy` for static analysis. + * **C++ unit tests:** gtests in `tests/components//*.cpp`, run with + `script/cpp_unit_test.py `; see `tests/components/README.md`. Set the loop clock in a test + with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test. + * **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove + timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). + * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs + on. A variant per board for platform-identical code only adds CI time. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ @@ -615,6 +638,7 @@ file does, and it is the authority when they disagree. The most useful starting name: My Sensor ``` Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time. + `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead. * **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use: ```bash From 86af0af0e4161a157aa73b3528fe3a543e2cc20c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 08:48:47 -1000 Subject: [PATCH 48/97] [core] Add sum_bytes() and use it for the additive byte checksums components hand-roll (#20507) --- esphome/components/ballu/ballu.cpp | 5 +---- esphome/components/bl0939/bl0939.cpp | 6 +----- esphome/components/bl0940/bl0940.cpp | 8 ++------ esphome/components/bl0942/bl0942.cpp | 9 +++------ esphome/components/cse7761/cse7761.cpp | 16 ++++------------ esphome/components/cse7766/cse7766.cpp | 5 +---- esphome/components/daikin/daikin.cpp | 9 +-------- esphome/components/daikin_arc/daikin_arc.cpp | 5 +---- esphome/components/daikin_brc/daikin_brc.cpp | 6 ++---- esphome/components/daly_bms/daly_bms.cpp | 7 +------ esphome/components/delonghi/delonghi.cpp | 6 ++---- esphome/components/fs3000/fs3000.cpp | 7 +------ .../fujitsu_general/fujitsu_general.cpp | 6 +----- esphome/components/hm3301/hm3301.cpp | 9 +-------- .../improv_serial/improv_serial_component.cpp | 11 ++--------- esphome/components/mhz19/mhz19.cpp | 8 +------- esphome/components/mitsubishi/mitsubishi.cpp | 4 +--- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 3 +-- esphome/components/mk2pvrouter/mk2pvrouter.cpp | 9 +-------- esphome/components/pm1006/pm1006.cpp | 6 +----- esphome/components/pn532_i2c/pn532_i2c.cpp | 6 +----- esphome/components/pn532_spi/pn532_spi.cpp | 8 ++------ esphome/components/pzem004t/pzem004t.cpp | 4 +--- esphome/components/sds011/sds011.cpp | 6 +----- .../components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 12 +++--------- esphome/components/sm300d2/sm300d2.cpp | 8 +------- esphome/components/sonoff_d1/sonoff_d1.cpp | 8 +------- esphome/components/systa_bus/systa_bus.cpp | 7 +------ esphome/components/tcl112/tcl112.cpp | 4 +--- esphome/components/teleinfo/teleinfo.cpp | 10 +++++----- esphome/components/toshiba/toshiba.cpp | 7 +------ esphome/components/tuya/tuya.cpp | 10 +++------- esphome/components/ufm01/ufm01.cpp | 9 ++------- esphome/components/wts01/wts01.cpp | 5 +---- esphome/core/helpers.h | 8 ++++++++ tests/components/core/helpers_test.cpp | 8 ++++++++ 36 files changed, 69 insertions(+), 196 deletions(-) diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index 1553f336186..e18325303c8 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; - // Calculate checksum and compare with signal value. - for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) - checksum += remote_state[i]; + const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1); if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index b0b9bb1c176..0e2da59ab1a 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -51,12 +51,8 @@ void BL0939::loop() { } bool BL0939::validate_checksum(const DataPacket *data) { - uint8_t checksum = BL0939_READ_COMMAND; // Whole package but checksum - for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) { - checksum += data->raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index a8b470de7ec..ac1386df03a 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -44,13 +44,9 @@ void BL0940::loop() { } bool BL0940::validate_checksum_(DataPacket *data) { - uint8_t checksum = this->read_command_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index 7d0eff8c9da..7da8dcca546 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -70,13 +70,10 @@ void BL0942::loop() { } bool BL0942::validate_checksum_(DataPacket *data) { - uint8_t checksum = BL0942_READ_COMMAND | this->address_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^ + 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 695b7f33d6c..c4073d27821 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -81,11 +81,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[3] = data & 0xFF; len = 4; } - uint8_t crc = 0; - for (uint32_t i = 0; i < len; i++) { - crc += buffer[i]; - } - buffer[len] = ~crc; + buffer[len] = ~sum_bytes(buffer, len); len++; } @@ -115,16 +111,12 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { } rcvd--; + if (static_cast(~sum_bytes(buffer, rcvd, 0xA5 + reg)) != buffer[rcvd]) { + return false; + } uint32_t result = 0; - // CRC check - uint8_t crc = 0xA5 + reg; for (uint32_t i = 0; i < rcvd; i++) { result = (result << 8) | buffer[i]; - crc += buffer[i]; - } - crc = ~crc; - if (crc != buffer[rcvd]) { - return false; } *value = result; diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 92769011563..0bf077b7468 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -67,10 +67,7 @@ bool CSE7766Component::check_byte_() { } if (index == CSE7766_RAW_DATA_SIZE - 1) { - uint8_t checksum = 0; - for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) { - checksum += this->raw_data_[i]; - } + const uint8_t checksum = sum_bytes(this->raw_data_ + 2, CSE7766_RAW_DATA_SIZE - 3); if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) { ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index 8b8d16677a1..370c04fbf66 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,6 +1,7 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::daikin { @@ -8,14 +9,6 @@ ESPHOME_LOG_TAG(TAG, "daikin.climate"); static constexpr uint32_t BOOST_TIMEOUT_ID = 0; -static uint8_t sum_bytes(const uint8_t *data, size_t len) { - uint8_t sum = 0; - for (size_t i = 0; i < len; i++) { - sum += data[i]; - } - return sum; -} - void DaikinClimate::setup() { climate_ir::ClimateIR::setup(); // A boost preset restored from flash gets its timer too diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index e79135b1859..fa01df73d85 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -246,10 +246,7 @@ climate::ClimateTraits DaikinArcClimate::traits() { } bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGI(TAG, "checksum error"); return false; diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 5d8ef52dc01..cfb3521a66d 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -1,5 +1,6 @@ #include "daikin_brc.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::daikin_brc { @@ -160,10 +161,7 @@ uint8_t DaikinBrcClimate::temperature_() { } bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_BRC_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_BRC_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_BRC_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGCONFIG(TAG, "Bad CheckSum %x", checksum); return false; diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index a486037266e..04ef798b69e 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -129,12 +129,7 @@ void DalyBmsComponent::decode_data_(std::vector data) { while ((it = std::find(it, data.end(), 0xA5)) != data.end()) { if (data.end() - it >= DALY_FRAME_SIZE && it[1] == 0x01) { - uint8_t checksum; - int sum = 0; - for (int i = 0; i < 12; i++) { - sum += it[i]; - } - checksum = sum; + const uint8_t checksum = sum_bytes(&*it, 12); if (checksum == it[12]) { switch (it[2]) { diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index eec4d7f55b1..3ed794daea0 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -1,5 +1,6 @@ #include "delonghi.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::delonghi { @@ -102,10 +103,7 @@ uint8_t DelonghiClimate::temperature_() { } bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DELONGHI_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DELONGHI_STATE_FRAME_SIZE - 1); if (frame[DELONGHI_STATE_FRAME_SIZE - 1] != checksum) { return false; } diff --git a/esphome/components/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index 81d6647642e..b5e1c98bcb2 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -42,12 +42,7 @@ void FS3000Component::update() { } // checksum passes if the modulo 256 sum of the five bytes is 0 - uint8_t checksum = 0; - for (uint8_t i : data) { - checksum += i; - } - - if (checksum != 0) { + if (sum_bytes(data, sizeof(data)) != 0) { this->status_set_warning(); ESP_LOGW(TAG, "Checksum failure when reading from FS3000"); return; diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 9a928dac01b..af411d133a8 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -224,11 +224,7 @@ void FujitsuGeneralClimate::transmit_(uint8_t const *message, uint8_t length) { } uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { - uint8_t checksum = 0; - for (uint8_t i = 7; i < FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 1; ++i) { - checksum += message[i]; - } - return 256 - checksum; + return -sum_bytes(message + 7, FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 8); } uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index bfed99b21b5..ddfe77d3ac1 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -80,14 +80,7 @@ void HM3301Component::update() { this->status_clear_warning(); } -bool HM3301Component::validate_checksum_(const uint8_t *data) { - uint8_t sum = 0; - for (int i = 0; i < 28; i++) { - sum += data[i]; - } - - return sum == data[28]; -} +bool HM3301Component::validate_checksum_(const uint8_t *data) { return sum_bytes(data, 28) == data[28]; } uint16_t HM3301Component::get_sensor_value_(const uint8_t *data, uint8_t i) { return (uint16_t) data[i * 2] << 8 | data[i * 2 + 1]; diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 10454678669..906781e17bf 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -95,16 +95,9 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2; // Only transmit the full buffer length if there is no data (only state/error byte is provided in this case) const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE; - // Calculate checksum for message - uint8_t checksum = 0; - for (uint8_t i = 0; i < header_checksum_len; i++) { - checksum += this->tx_header_[i]; - } + uint8_t checksum = sum_bytes(this->tx_header_, header_checksum_len); if (there_is_data) { - // Include data in checksum - for (size_t i = 0; i < size; i++) { - checksum += data[i]; - } + checksum = sum_bytes(data, size, checksum); } this->tx_header_[TX_CHECKSUM_IDX] = checksum; diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 5be6da88eff..0f623bfd726 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -31,13 +31,7 @@ static const LogString *detection_range_to_log_string(MHZ19DetectionRange range) } #endif -uint8_t mhz19_checksum(const uint8_t *command) { - uint8_t sum = 0; - for (uint8_t i = 1; i < MHZ19_REQUEST_LENGTH; i++) { - sum += command[i]; - } - return 0xFF - sum + 0x01; -} +uint8_t mhz19_checksum(const uint8_t *command) { return -sum_bytes(command + 1, MHZ19_REQUEST_LENGTH - 1); } void MHZ19Component::setup() { if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) { diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 515cd81fdfe..9fba222d635 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -242,9 +242,7 @@ void MitsubishiClimate::transmit_state() { } // Checksum - for (int i = 0; i < 17; i++) { - remote_state[17] += remote_state[i]; - } + 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], diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index c6c7b57c5fa..3261d9d5694 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -31,7 +30,7 @@ static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { - return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); + return static_cast(0xFC - sum_bytes(bytes, length)); } template diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index eecaa1a1d41..b759758019a 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -36,14 +36,7 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, // Calculates the CRC (checksum) for a given group of characters. uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - uint8_t crc_tmp{0}; - const auto effective_len = grp_len - CRC_SUFFIX_LEN; - for (size_t i = 0; i < effective_len; i++) { - crc_tmp += grp[i]; - } - crc_tmp &= CRC_MASK; - crc_tmp += CRC_OFFSET; - return crc_tmp; + return (sum_bytes(reinterpret_cast(grp), grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index ebd508b5352..2f57c028439 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -43,11 +43,7 @@ void PM1006Component::loop() { } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional PM1006Component::check_byte_() const { diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 1687edd5116..5326aeb145c 100644 --- a/esphome/components/pn532_i2c/pn532_i2c.cpp +++ b/esphome/components/pn532_i2c/pn532_i2c.cpp @@ -71,11 +71,7 @@ bool PN532I2C::read_response(uint8_t command, pn532::PN532Frame &data) { // frame: status, preamble, start code (2), LEN, LCS, TFI, command response code, data, DCS, postamble constexpr size_t tfi_offset = 6; - uint8_t checksum = 0; - for (size_t i = 0; i < len + 1U; i++) { - checksum += data[tfi_offset + i]; - } - checksum = ~checksum + 1; + const uint8_t checksum = -sum_bytes(data.data() + tfi_offset, len + 1U); if (data[tfi_offset + len + 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[tfi_offset + len + 1], checksum); diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index e86153be86a..34c6a3e44f7 100644 --- a/esphome/components/pn532_spi/pn532_spi.cpp +++ b/esphome/components/pn532_spi/pn532_spi.cpp @@ -127,12 +127,8 @@ bool PN532Spi::read_response(uint8_t command, pn532::PN532Frame &data) { ESP_LOGV(TAG, "Response data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - uint8_t checksum = header[5] + header[6]; // TFI + Command response code - for (int i = 0; i < len - 1; i++) { - uint8_t dat = data[i]; - checksum += dat; - } - checksum = ~checksum + 1; + // TFI + Command response code + data + const uint8_t checksum = -sum_bytes(data.data(), len - 1, header[5] + header[6]); if (data[len - 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len - 1], checksum); diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index 7809e803c9d..606a03e0c23 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -34,9 +34,7 @@ void PZEM004T::loop() { // 1-5: data // 6: checksum (sum of other bytes) // see https://github.com/olehs/PZEM004T - uint8_t sum = 0; - for (int i = 0; i < 6; i++) - sum += resp[i]; + const uint8_t sum = sum_bytes(resp.data(), 6); if (sum != resp[6]) { ESP_LOGV(TAG, "PZEM004T invalid checksum! 0x%02X != 0x%02X", sum, resp[6]); diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 3d46f0613e0..147365251b5 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -115,11 +115,7 @@ void SDS011Component::sds011_write_command_(const uint8_t *command_data) { } uint8_t SDS011Component::sds011_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional SDS011Component::check_byte_() const { diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 31c8e89af3a..1bc3e0aeb42 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -251,17 +251,11 @@ void MR24HPC1Component::loop() { } } -// Calculate CRC check digit -static uint8_t get_frame_crc_sum(const uint8_t *data, int len) { - unsigned int crc_sum = 0; - for (int i = 0; i < len - 3; i++) { - crc_sum += data[i]; - } - return crc_sum & 0xff; -} +// Frames are at least 10 bytes, so the three trailing bytes can always be left out +static uint8_t get_frame_crc_sum(const uint8_t *data, size_t len) { return sum_bytes(data, len - 3); } // Check that the check digit is correct -static int get_frame_check_status(uint8_t *data, int len) { +static int get_frame_check_status(const uint8_t *data, size_t len) { uint8_t crc_sum = get_frame_crc_sum(data, len); uint8_t verified = data[len - 3]; return (verified == crc_sum) ? 1 : 0; diff --git a/esphome/components/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 0660e0f20e4..a7959a09997 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -83,13 +83,7 @@ void SM300D2Sensor::update() { this->humidity_sensor_->publish_state(humidity); } -uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { - uint8_t sum = 0; - for (int i = 0; i < (SM300D2_RESPONSE_LENGTH - 1); i++) { - sum += *ptr++; - } - return sum; -} +uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { return sum_bytes(ptr, SM300D2_RESPONSE_LENGTH - 1); } void SM300D2Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SM300D2:"); diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index 02282fef9ca..c72210e932f 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -52,13 +52,7 @@ ESPHOME_LOG_TAG(TAG, "sonoff_d1"); static constexpr size_t SONOFF_D1_ACK_SIZE = 7; static constexpr size_t SONOFF_D1_MAX_CMD_SIZE = 17; -uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { - uint8_t crc = 0; - for (size_t i = 2; i < len - 1; i++) { - crc += cmd[i]; - } - return crc; -} +uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { return sum_bytes(cmd + 2, len - 3); } void SonoffD1Output::populate_checksum_(uint8_t *cmd, const size_t len) { // Update the checksum diff --git a/esphome/components/systa_bus/systa_bus.cpp b/esphome/components/systa_bus/systa_bus.cpp index 4c537b75cbf..6c780d07a92 100644 --- a/esphome/components/systa_bus/systa_bus.cpp +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -10,12 +10,7 @@ ESPHOME_LOG_TAG(TAG, "systa_bus"); void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } -static bool checksum(std::span data) { - uint8_t csum = 0; - for (uint8_t i : data) - csum += i; - return csum == 0; -} +static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 0; } void SystaBus::loop() { uint8_t c; diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index 5e7f0ef29c7..15111338b24 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -168,11 +168,9 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; // Calculate & set the checksum for the current internal state of the remote. // Stored the checksum value in the last byte. - for (uint8_t checksum_byte = 0; checksum_byte < TCL112_STATE_LENGTH - 1; checksum_byte++) - checksum += remote_state[checksum_byte]; + const uint8_t checksum = sum_bytes(remote_state, TCL112_STATE_LENGTH - 1); if (checksum != remote_state[TCL112_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); return false; diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index adf56707844..91a0d0e16f5 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -24,12 +24,12 @@ static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int ma /* TeleInfo methods */ bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { int grp_len = grp_end - grp; + if (grp_len < this->checksum_area_end_) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = 0; - int i; - - for (i = 0; i < grp_len - checksum_area_end_; i++) - crc_tmp += grp[i]; + uint8_t crc_tmp = sum_bytes(reinterpret_cast(grp), grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 57361a4a587..406a1bc55db 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1052,8 +1052,6 @@ bool ToshibaClimate::compare_rac_pt1411hwru_packets_(const uint8_t *message1, co } bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { - uint8_t checksum = 0; - switch (this->is_valid_rac_pt1411hwru_header_(message)) { case RAC_PT1411HWRU_MESSAGE_HEADER0: case RAC_PT1411HWRU_CS_HEADER: @@ -1065,10 +1063,7 @@ bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { break; case RAC_PT1411HWRU_MESSAGE_HEADER1: - for (uint8_t i = 0; i < RAC_PT1411HWRU_MESSAGE_LENGTH - 1; i++) { - checksum += message[i]; - } - if (checksum == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { + if (sum_bytes(message, RAC_PT1411HWRU_MESSAGE_LENGTH - 1) == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { return true; } break; diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index c6316f59fce..025b97ee727 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -152,9 +152,7 @@ bool Tuya::validate_message_() { // Byte 6+LEN: CHECKSUM - sum of all bytes (including header) modulo 256 uint8_t rx_checksum = new_byte; - uint8_t calc_checksum = 0; - for (uint32_t i = 0; i < 6 + length; i++) - calc_checksum += data[i]; + const uint8_t calc_checksum = sum_bytes(data, 6 + length); if (rx_checksum != calc_checksum) { ESP_LOGW(TAG, "Tuya Received invalid message checksum %02X!=%02X", rx_checksum, calc_checksum); @@ -531,10 +529,8 @@ void Tuya::send_raw_command_(TuyaCommand command) { if (!command.payload.empty()) this->write_array(command.payload.data(), command.payload.size()); - uint8_t checksum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; - for (auto &data : command.payload) - checksum += data; - this->write_byte(checksum); + const uint8_t header_sum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; + this->write_byte(sum_bytes(command.payload.data(), command.payload.size(), header_sum)); } void Tuya::process_command_queue_() { diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index cd4388372cd..c68842ab6c1 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -68,9 +68,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp } static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { - uint8_t sum = 0; - for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) - sum += data[i]; + const uint8_t sum = sum_bytes(data, FRAME_CHECKSUM_INDEX); return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && @@ -83,10 +81,7 @@ static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) return false; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += data[i]; - return data[21] == (sum & 0xFF); + return data[21] == sum_bytes(data, 21); } static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index 2888fc0abb3..b7b9e5cfa99 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -58,10 +58,7 @@ void WTS01Sensor::process_packet_() { // Based on Tasmota implementation // Format: 55 01 01 04 01 11 16 12 95 // header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum - uint8_t calculated_checksum = 0; - for (uint8_t i = 0; i < PACKET_SIZE - 1; i++) { - calculated_checksum += this->buffer_[i]; - } + const uint8_t calculated_checksum = sum_bytes(this->buffer_, PACKET_SIZE - 1); uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1]; if (calculated_checksum != received_checksum) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a2ede273413..c54d1bc16f0 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -879,6 +880,13 @@ template T remap(U value, U min, U max, T min_out, T max return (value - min) * (max_out - min_out) / (max - min) + min_out; } +/// Add up the bytes of \p data with size \p len, truncated to 8 bits: the checksum many serial and IR +/// protocols use. Prefer this over a bare std::accumulate, whose result type follows the seed, so a plain 0 +/// sums in int and compares wrong against a checksum byte. +constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + /// Calculate a CRC-8 checksum of \p data with size \p len. uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); diff --git a/tests/components/core/helpers_test.cpp b/tests/components/core/helpers_test.cpp index 521ae10e5c3..d0058a634b2 100644 --- a/tests/components/core/helpers_test.cpp +++ b/tests/components/core/helpers_test.cpp @@ -55,6 +55,14 @@ TEST(HelpersTest, Ilog10RoundTripMatchesLog10) { } } +TEST(HelpersTest, SumBytes) { + static constexpr uint8_t FRAME[] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x10}; + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME)), 0xE7); // the Daikin comfort frame checksum + EXPECT_EQ(sum_bytes(FRAME, 0), 0); + EXPECT_EQ(sum_bytes(FRAME, sizeof(FRAME), 0x19), 0x00); // wraps at 8 bits + static_assert(sum_bytes(FRAME, 2) == 0xEB, "usable in constant expressions"); +} + TEST(StaticVectorTest, ConvertingConstructorFromSmaller) { StaticVector small{0x03, 0x00, 0x10, 0x00, 0x01}; StaticVector big = small; From c8a300164b71e38d585156f1c920ec8f3e207418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:53:37 +0200 Subject: [PATCH 49/97] [core] Add validators that reject deferring actions in an automation (#20523) Co-authored-by: Claude Opus 5.5 --- esphome/automation.py | 46 +++++++++++++++ tests/unit_tests/test_automation.py | 91 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/esphome/automation.py b/esphome/automation.py index 6b6af4ed72e..0105d1e1072 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1048,6 +1048,52 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: return False +def validate_no_deferring_actions( + context: str, data: str = "the trigger data" +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that rejects deferring actions in a validated automation. + + Use it for triggers whose arguments only live while the callback runs (views into a reused + buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later. + + :param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``. + :param data: What is only valid while the automation runs, for the error message. + """ + + def validator(value: ConfigType) -> ConfigType: + if has_non_synchronous_actions(value): + raise cv.Invalid( + f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in " + f"{context}: {data} is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + return validator + + +def validate_synchronous_automation( + context: str, + data: str = "the trigger data", + extra_schema=None, + extra_validators=None, + single: bool = False, +) -> cv.All: + """``validate_automation()`` that also rejects deferring actions. + + See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are + passed to ``validate_automation()``. + """ + return cv.All( + validate_automation( + extra_schema=extra_schema, + extra_validators=extra_validators, + single=single, + ), + validate_no_deferring_actions(context, data), + ) + + async def _new_automation( args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj ) -> MockObj: diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 3cd70c082d5..351afe541d6 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -36,12 +36,15 @@ from esphome.automation import ( register_simple_condition, string_ref_literal, templatable_bytes, + validate_no_deferring_actions, + validate_synchronous_automation, ) import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_AUTOMATION_ID, CONF_ID, CONF_THEN from esphome.core import CORE, ID, KEY_CORE, KEY_TARGET_PLATFORM, EsphomeError, Lambda from esphome.cpp_generator import MockObj, RawExpression +from esphome.types import ConfigType from esphome.util import Registry, RegistryEntry @@ -213,6 +216,94 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False +def test_validate_no_deferring_actions_accepts_synchronous( + mock_registry: dict[str, RegistryEntry], +) -> None: + value = [{CONF_THEN: [{"logger.log": "hello"}]}] + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_accepts_lambda_form( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A returning lambda has no action list, so there is nothing to reject.""" + value = Lambda("return true;") + assert validate_no_deferring_actions("on_test")(value) is value + + +def test_validate_no_deferring_actions_rejects_deferring( + mock_registry: dict[str, RegistryEntry], +) -> None: + validator = validate_no_deferring_actions("on_test", "the test data") + with pytest.raises( + cv.Invalid, + match=r"not allowed in on_test: the test data is only valid while", + ): + validator([{CONF_THEN: [{"logger.log": "hello"}, {"delay": "1s"}]}]) + + +def test_validate_no_deferring_actions_default_data( + mock_registry: dict[str, RegistryEntry], +) -> None: + with pytest.raises(cv.Invalid, match=r"on_test: the trigger data is only valid"): + validate_no_deferring_actions("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_accepts_synchronous() -> None: + result = validate_synchronous_automation("on_test")( + [{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}] + ) + assert len(result) == 1 + + +def test_validate_synchronous_automation_rejects_deferring() -> None: + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: [{"delay": "1s"}]}]) + + +def test_validate_synchronous_automation_rejects_nested_deferring() -> None: + actions = [ + { + "if": { + "condition": {"lambda": Lambda("return true;")}, + CONF_THEN: [{"delay": "1s"}], + } + } + ] + with pytest.raises(cv.Invalid, match="Deferring actions"): + validate_synchronous_automation("on_test")([{CONF_THEN: actions}]) + + +def test_validate_synchronous_automation_passes_options() -> None: + """extra_schema and single are passed through to validate_automation().""" + validator = validate_synchronous_automation( + "on_test", extra_schema={cv.Optional("retry"): cv.string}, single=True + ) + result = validator( + {"retry": "yes", CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]} + ) + assert isinstance(result, dict) + assert result["retry"] == "yes" + with pytest.raises(cv.Invalid, match="Deferring actions"): + validator({CONF_THEN: [{"delay": "1s"}]}) + + +def test_validate_synchronous_automation_passes_extra_validators() -> None: + """extra_validators is passed through to validate_automation() and runs on each automation.""" + seen: list[ConfigType] = [] + + def extra_validator(value: ConfigType) -> ConfigType: + seen.append(value) + return value + + validator = validate_synchronous_automation( + "on_test", extra_validators=extra_validator + ) + validator([{CONF_THEN: [{"lambda": Lambda('ESP_LOGD("test", "ran");')}]}]) + assert len(seen) == 1 + assert CONF_THEN in seen[0] + + def _build_forwarder( automation_name: str, args: list[tuple[str, str]], From 6322cef8e2310b1e053fc016fa1e85272de64de1 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:06:40 -0400 Subject: [PATCH 50/97] [tmp102] Add hardware configuration options (#20063) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/tmp102/__init__.py | 90 ++++++- esphome/components/tmp102/sensor.py | 65 +++-- esphome/components/tmp102/tmp102.cpp | 138 ++++++++++- esphome/components/tmp102/tmp102.h | 28 ++- esphome/core/defines.h | 1 + tests/component_tests/tmp102/__init__.py | 1 + .../tmp102/config/advanced.yaml | 23 ++ .../component_tests/tmp102/config/basic.yaml | 15 ++ tests/component_tests/tmp102/test_config.py | 95 ++++++++ tests/components/tmp102/__init__.py | 13 + tests/components/tmp102/common.yaml | 13 + tests/components/tmp102/test.esp32-idf.yaml | 3 +- tests/components/tmp102/test.esp8266-ard.yaml | 3 +- tests/components/tmp102/test.rp2040-ard.yaml | 3 +- tests/components/tmp102/test_tmp102.cpp | 228 ++++++++++++++++++ 15 files changed, 684 insertions(+), 35 deletions(-) create mode 100644 tests/component_tests/tmp102/__init__.py create mode 100644 tests/component_tests/tmp102/config/advanced.yaml create mode 100644 tests/component_tests/tmp102/config/basic.yaml create mode 100644 tests/component_tests/tmp102/test_config.py create mode 100644 tests/components/tmp102/__init__.py create mode 100644 tests/components/tmp102/test_tmp102.cpp diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 8510f18769a..6fd5f481e72 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,11 +1,85 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. +"""Shared TMP102 types and configuration validation.""" -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE +import esphome.config_validation as cv +from esphome.types import ConfigType DOMAIN = "tmp102" + +tmp102_ns = cg.esphome_ns.namespace("tmp102") +TMP102Component = tmp102_ns.class_( + "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor +) + +CONF_EXTENDED_MODE = "extended_mode" +CONF_ONE_SHOT_MODE = "one_shot_mode" +CONF_TEMPERATURE_HIGH = "temperature_high" +CONF_TEMPERATURE_LOW = "temperature_low" +CONF_ALERT_POLARITY = "alert_polarity" +CONF_THERMOSTAT_MODE = "thermostat_mode" +CONF_FAULT_QUEUE = "fault_queue" + +CONVERSION_RATES = {"0.25Hz": 0, "1Hz": 1, "4Hz": 2, "8Hz": 3} +THERMOSTAT_MODES = {"comparator": 0, "interrupt": 1} +ALERT_POLARITIES = {"active_low": 0, "active_high": 1} +FAULT_QUEUE_BITS = {1: 0, 2: 1, 4: 2, 6: 3} + +TMP102_CONVERSION_FACTOR = 0.0625 +TMP102_DEFAULT_THIGH = 80.0 +TMP102_DEFAULT_TLOW = 75.0 + + +def encode_temperature(temperature: float, extended: bool) -> int: + """Encode a temperature as a TMP102 limit-register word.""" + steps = round(temperature / TMP102_CONVERSION_FACTOR) + return (steps << (3 if extended else 4)) & 0xFFFF + + +def build_configuration(config: ConfigType) -> int: + """Build the writable TMP102 configuration-register bits.""" + value = CONVERSION_RATES[config.get(CONF_CONVERSION_RATE, "4Hz")] << 6 + value |= FAULT_QUEUE_BITS[config.get(CONF_FAULT_QUEUE, 1)] << 11 + value |= ALERT_POLARITIES[config.get(CONF_ALERT_POLARITY, "active_low")] << 10 + value |= THERMOSTAT_MODES[config.get(CONF_THERMOSTAT_MODE, "comparator")] << 9 + if config.get(CONF_ONE_SHOT_MODE, False): + value |= 1 << 8 + if config.get(CONF_EXTENDED_MODE, False): + value |= 1 << 4 + return value + + +def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: + extended = config.get(CONF_EXTENDED_MODE, False) + t_min = -55.0 + t_max = 150.0 if extended else 127.9375 + high = config.get(CONF_TEMPERATURE_HIGH) + low = config.get(CONF_TEMPERATURE_LOW) + for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): + if value is not None and not t_min <= value <= t_max: + raise cv.Invalid( + f"{key} ({value}°C) out of range for " + f"{'extended' if extended else 'normal'} mode " + f"({t_min}°C to {t_max}°C)", + [key], + ) + + if high is not None and low is not None and low > high: + raise cv.Invalid( + f"low limit ({low}°C) must be <= high limit ({high}°C)", + [CONF_TEMPERATURE_LOW], + ) + if high is not None and low is None and high < TMP102_DEFAULT_TLOW: + raise cv.Invalid( + f"high limit ({high}°C) is below the chip power-up TLOW default " + f"({TMP102_DEFAULT_TLOW}°C). Set temperature_low explicitly.", + [CONF_TEMPERATURE_HIGH], + ) + if low is not None and high is None and low > TMP102_DEFAULT_THIGH: + raise cv.Invalid( + f"low limit ({low}°C) is above the chip power-up THIGH default " + f"({TMP102_DEFAULT_THIGH}°C). Set temperature_high explicitly.", + [CONF_TEMPERATURE_LOW], + ) + return config diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index eba0d3c0269..fbfed7444d6 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -1,15 +1,8 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. - -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +"""TMP102 temperature sensor and optional hardware configuration.""" import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -18,15 +11,42 @@ from esphome.const import ( ) from esphome.types import ConfigType +from . import ( + ALERT_POLARITIES, + CONF_ALERT_POLARITY, + CONF_EXTENDED_MODE, + CONF_FAULT_QUEUE, + CONF_ONE_SHOT_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_THERMOSTAT_MODE, + CONVERSION_RATES, + FAULT_QUEUE_BITS, + THERMOSTAT_MODES, + TMP102_DEFAULT_THIGH, + TMP102_DEFAULT_TLOW, + TMP102Component, + build_configuration, + encode_temperature, + validate_tmp102_thresholds, +) + CODEOWNERS = ["@timsavage"] DEPENDENCIES = ["i2c"] -tmp102_ns = cg.esphome_ns.namespace("tmp102") -TMP102Component = tmp102_ns.class_( - "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor -) +# No defaults here: absence of all advanced options preserves upstream's read-only behavior and firmware size. +_ADVANCED_SCHEMA = { + cv.Optional(CONF_EXTENDED_MODE): cv.boolean, + cv.Optional(CONF_CONVERSION_RATE): cv.enum(CONVERSION_RATES), + cv.Optional(CONF_ONE_SHOT_MODE): cv.boolean, + cv.Optional(CONF_ALERT_POLARITY): cv.enum(ALERT_POLARITIES), + cv.Optional(CONF_THERMOSTAT_MODE): cv.enum(THERMOSTAT_MODES), + cv.Optional(CONF_FAULT_QUEUE): cv.enum(FAULT_QUEUE_BITS, int=True), + cv.Optional(CONF_TEMPERATURE_HIGH): cv.temperature, + cv.Optional(CONF_TEMPERATURE_LOW): cv.temperature, +} -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( TMP102Component, unit_of_measurement=UNIT_CELSIUS, @@ -36,6 +56,8 @@ CONFIG_SCHEMA = ( ) .extend(cv.polling_component_schema("60s")) .extend(i2c.i2c_device_schema(0x48)) + .extend(_ADVANCED_SCHEMA), + validate_tmp102_thresholds, ) @@ -43,3 +65,18 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + + if not any(key in config for key in _ADVANCED_SCHEMA): + return + + extended = config.get(CONF_EXTENDED_MODE, False) + high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH) + low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW) + cg.add_define("USE_TMP102_CONFIGURE") + cg.add( + var.set_configuration( + build_configuration(config), + encode_temperature(high, extended), + encode_temperature(low, extended), + ) + ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index d851bd925fa..a1066b97619 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -8,13 +8,33 @@ ESPHOME_LOG_TAG(TAG, "tmp102"); static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; -static const uint8_t TMP102_ADDRESS = 0x48; -static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; -static const uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; -static const uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; -static const uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; +static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; +#ifdef USE_TMP102_CONFIGURE +static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; +static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; -static const float TMP102_CONVERSION_FACTOR = 0.0625; +static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; +static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; +static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; +static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; +static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; +static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; + +static float decode_temperature(uint16_t raw, bool extended) { + return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; +} + +void TMP102Component::setup() { + if (!this->configure_) + return; + if (!this->write_configuration_()) + this->mark_failed(); +} +#endif void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); @@ -24,9 +44,44 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_CONFIGURE + if (!this->configure_) + return; + const uint8_t rate = (this->config_ >> 6) & 0x03; + const LogString *rate_str = rate == 0 ? LOG_STR("0.25Hz") + : rate == 1 ? LOG_STR("1Hz") + : rate == 2 ? LOG_STR("4Hz") + : LOG_STR("8Hz"); + const uint8_t fault_bits = (this->config_ >> 11) & 0x03; + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + ESP_LOGCONFIG( + TAG, + " Extended Mode: %s\n" + " One-Shot Mode: %s\n" + " Conversion Rate: %s\n" + " Thermostat Mode: %s\n" + " Alert Polarity: %s\n" + " Fault Queue: %u\n" + " Temperature High: %.4f°C\n" + " Temperature Low: %.4f°C", + YESNO(extended), YESNO(this->config_ & TMP102_SHUTDOWN_BIT), LOG_STR_ARG(rate_str), + this->config_ & TMP102_THERMOSTAT_MODE_BIT ? LOG_STR_LITERAL("Interrupt") : LOG_STR_LITERAL("Comparator"), + this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"), + fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended), + decode_temperature(this->low_limit_, extended)); + if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { + ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); + } +#endif } void TMP102Component::update() { +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->update_configured_(); + return; + } +#endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { this->status_set_warning(); return; @@ -47,4 +102,75 @@ void TMP102Component::update() { }); } +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::update_configured_() { + if (!this->check_configuration_()) + return; + if (this->config_ & TMP102_SHUTDOWN_BIT) { + if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { + this->status_set_warning(); + return; + } + this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); + } else { + this->read_temperature_(); + } +} + +bool TMP102Component::write_register_(uint8_t reg, uint16_t value) { + uint8_t frame[3] = {reg, static_cast(value >> 8), static_cast(value)}; + if (this->write(frame, 3) == i2c::ERROR_OK) + return true; + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return false; +} + +bool TMP102Component::write_configuration_() { + return this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_) && + this->write_register_(TMP102_REGISTER_HIGH_LIMIT, this->high_limit_) && + this->write_register_(TMP102_REGISTER_LOW_LIMIT, this->low_limit_); +} + +bool TMP102Component::check_configuration_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->status_set_warning(); + return false; + } + bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); + // A chip reset is invisible in the configuration register when it matches the reset default; check the limits. + if (matches && + (this->config_ & TMP102_CONFIGURATION_MASK) == (TMP102_RESET_CONFIGURATION & TMP102_CONFIGURATION_MASK)) { + uint16_t high, low; + if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || + !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { + this->status_set_warning(); + return false; + } + matches = high == this->high_limit_ && low == this->low_limit_; + } + if (matches) + return true; + + ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); + this->status_set_warning(); + if (!this->write_configuration_()) { + ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + } + return false; +} + +void TMP102Component::read_temperature_() { + uint16_t raw; + if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { + this->status_set_warning(); + return; + } + const float temperature = decode_temperature(raw, raw & 0x0001); + ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); + this->publish_state(temperature); + this->status_clear_warning(); +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index f9eda32e989..b14b6c4cc1f 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -1,15 +1,41 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" +#include "esphome/core/defines.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +#include namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_CONFIGURE + void setup() override; + void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { + this->config_ = config; + this->high_limit_ = high_limit; + this->low_limit_ = low_limit; + this->configure_ = true; + } +#endif void dump_config() override; void update() override; + + protected: +#ifdef USE_TMP102_CONFIGURE + bool write_register_(uint8_t reg, uint16_t value); + bool write_configuration_(); + bool check_configuration_(); + void read_temperature_(); + void update_configured_(); + + uint16_t config_{0}; + uint16_t high_limit_{0}; + uint16_t low_limit_{0}; + bool configure_{false}; +#endif }; } // namespace esphome::tmp102 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index de621f891a1..31549ceb8eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -243,6 +243,7 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX diff --git a/tests/component_tests/tmp102/__init__.py b/tests/component_tests/tmp102/__init__.py new file mode 100644 index 00000000000..61db1769a2f --- /dev/null +++ b/tests/component_tests/tmp102/__init__.py @@ -0,0 +1 @@ +"""TMP102 regression tests.""" diff --git a/tests/component_tests/tmp102/config/advanced.yaml b/tests/component_tests/tmp102/config/advanced.yaml new file mode 100644 index 00000000000..5d32255cb83 --- /dev/null +++ b/tests/component_tests/tmp102/config/advanced.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + extended_mode: false + conversion_rate: 1Hz + one_shot_mode: false + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 2 + temperature_high: 30 + temperature_low: 25 diff --git a/tests/component_tests/tmp102/config/basic.yaml b/tests/component_tests/tmp102/config/basic.yaml new file mode 100644 index 00000000000..6326b0eaa26 --- /dev/null +++ b/tests/component_tests/tmp102/config/basic.yaml @@ -0,0 +1,15 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py new file mode 100644 index 00000000000..020303d455d --- /dev/null +++ b/tests/component_tests/tmp102/test_config.py @@ -0,0 +1,95 @@ +"""Exercise TMP102 sensor validation and code generation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components import tmp102 as component +from esphome.components.tmp102.sensor import CONFIG_SCHEMA +import esphome.config_validation as cv +from esphome.core import CORE + + +def test_basic_unchanged( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("basic.yaml")) + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_" not in defines + + +def test_advanced_options_enable_configuration_once( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("advanced.yaml")) + assert source.count("set_configuration(") == 1 + assert "set_configuration(3648, 7680, 6400)" in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_CONFIGURE" in defines + + +@pytest.mark.parametrize( + ("extended", "high", "low"), + [ + (False, 127.9375, -55), + (True, 150, -55), + (False, 30, 30), + ], +) +def test_valid_limits(extended: bool, high: float, low: float) -> None: + validated = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": extended, + "temperature_high": high, + "temperature_low": low, + } + ) + assert validated["temperature_high"] == high + assert validated["temperature_low"] == low + + +@pytest.mark.parametrize( + "config", + [ + {"temperature_high": float("nan")}, + {"temperature_low": float("inf")}, + {"temperature_low": -55.0625}, + {"temperature_high": 128}, + {"extended_mode": True, "temperature_high": 150.0625}, + {"temperature_high": 30, "temperature_low": 31}, + {"temperature_high": 74}, + {"temperature_low": 81}, + {"fault_queue": 3}, + ], +) +def test_invalid_limits(config: dict) -> None: + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA({"name": "Temperature", **config}) + + +def test_precomputed_register_values() -> None: + config = CONFIG_SCHEMA( + { + "name": "Temperature", + "extended_mode": True, + "conversion_rate": "0.25Hz", + "one_shot_mode": True, + "alert_polarity": "active_high", + "thermostat_mode": "interrupt", + "fault_queue": 6, + "temperature_high": 12.22, + "temperature_low": -12.22, + } + ) + assert component.build_configuration(config) == 0x1F10 + assert component.encode_temperature(config["temperature_high"], True) == 0x0620 + assert component.encode_temperature(config["temperature_low"], True) == 0xF9E0 + + +def test_defaults_match_chip_reset() -> None: + assert component.build_configuration({}) == 0x0080 diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py new file mode 100644 index 00000000000..9702134e8c6 --- /dev/null +++ b/tests/components/tmp102/__init__.py @@ -0,0 +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: + async def to_code_testing(config: ConfigType) -> None: + CORE.testing_ensure_platform_registered("sensor") + cg.add_define("USE_TMP102_CONFIGURE") + + manifest.to_code = to_code_testing + manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index 049a8067da7..c764eae42b2 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -2,3 +2,16 @@ sensor: - platform: tmp102 i2c_id: i2c_bus name: TMP102 Temperature + - platform: tmp102 + id: tmp102_extended_temperature + name: TMP102 Extended Temperature + i2c_id: i2c_bus + address: 0x49 + extended_mode: true + one_shot_mode: true + conversion_rate: 8Hz + alert_polarity: active_high + thermostat_mode: interrupt + fault_queue: 6 + temperature_high: 140°C + temperature_low: 120°C diff --git a/tests/components/tmp102/test.esp32-idf.yaml b/tests/components/tmp102/test.esp32-idf.yaml index b47e39c3898..d879c5b4aef 100644 --- a/tests/components/tmp102/test.esp32-idf.yaml +++ b/tests/components/tmp102/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.esp8266-ard.yaml b/tests/components/tmp102/test.esp8266-ard.yaml index 4a98b9388ab..10be01238cf 100644 --- a/tests/components/tmp102/test.esp8266-ard.yaml +++ b/tests/components/tmp102/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test.rp2040-ard.yaml b/tests/components/tmp102/test.rp2040-ard.yaml index 319a7c71a65..b364443f573 100644 --- a/tests/components/tmp102/test.rp2040-ard.yaml +++ b/tests/components/tmp102/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml - -<<: !include common.yaml + tmp102: !include common.yaml diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp new file mode 100644 index 00000000000..b148b07716c --- /dev/null +++ b/tests/components/tmp102/test_tmp102.cpp @@ -0,0 +1,228 @@ +#ifdef USE_HOST + +#include + +#include +#include "esphome/components/tmp102/tmp102.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" + +namespace esphome::tmp102::testing { + +class RegisterBus final : public i2c::I2CBus { + public: + std::array registers{0x1900, 0x60A0, 0x4B00, 0x5000}; + int fail_read{-1}; + int fail_write{-1}; + int fail_select{-1}; + unsigned transactions{0}; + unsigned writes{0}; + uint8_t pointer{0}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out, + size_t out_size) override { + ++this->transactions; + if (size != 0) { + if (data[0] >= this->registers.size() || data[0] == this->fail_select) + return i2c::ERROR_NOT_ACKNOWLEDGED; + this->pointer = data[0]; + if (size == 3) { + if (this->pointer == this->fail_write) + return i2c::ERROR_NOT_ACKNOWLEDGED; + ++this->writes; + this->registers[this->pointer] = (uint16_t(data[1]) << 8) | data[2]; + } + } + if (out_size != 0) { + if (out_size != 2 || this->pointer == this->fail_read) + return i2c::ERROR_NOT_ACKNOWLEDGED; + out[0] = this->registers[this->pointer] >> 8; + out[1] = this->registers[this->pointer]; + } + return i2c::ERROR_OK; + } +}; + +class TMP102Test : public ::testing::Test { + protected: + RegisterBus bus_; + TMP102Component chip_; + unsigned publications_{0}; + + void SetUp() override { + new (&App) Application; + char name[] = "tmp102-regression-tests"; + char friendly_name[] = "TMP102 tests"; + App.pre_setup(name, 23, friendly_name, 12); + this->chip_.set_i2c_bus(&this->bus_); + this->chip_.set_i2c_address(0x48); + this->chip_.add_on_state_callback([this](float) { ++this->publications_; }); + } + + void TearDown() override { + App.scheduler.cancel_timeout(&this->chip_, uint32_t{0}); + App.scheduler.call(millis()); + App.~Application(); + } + + static void run_timers(uint32_t duration) { + App.scheduler.call(millis()); + delay(duration); + App.scheduler.call(millis()); + } + + void configure(uint16_t config = 0x0080, uint16_t high = 0x5000, uint16_t low = 0x4B00) { + this->chip_.set_configuration(config, high, low); + } +}; + +TEST_F(TMP102Test, LegacySetupAndContinuousReadDoNotWriteRegisters) { + const auto original = this->bus_.registers; + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + this->chip_.update(); + run_timers(55); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, 0); + EXPECT_EQ(this->bus_.registers, original); +} + +TEST_F(TMP102Test, SampleFormatOverridesConfiguredModeInBothDirections) { + for (bool configured_extended : {false, true}) { + this->configure(configured_extended ? 0x0010 : 0, configured_extended ? 0x2800 : 0x5000, + configured_extended ? 0x2580 : 0x4B00); + this->chip_.setup(); + const auto writes = this->bus_.writes; + for (auto raw : {0x1900, 0x0C81, 0xFF00, 0xFF81}) { + this->bus_.registers[0] = raw; + this->chip_.update(); + EXPECT_FLOAT_EQ(this->chip_.state, raw & 0x8000 ? -1 : 25); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.writes, writes); + } + } +} + +TEST_F(TMP102Test, WritableConfigurationFields) { + this->configure(0x1FD0, 0x2800, 0x2580); + this->chip_.setup(); + EXPECT_EQ(this->bus_.registers[1], 0x1FD0); + EXPECT_EQ(this->bus_.registers[3], 0x2800); + EXPECT_EQ(this->bus_.registers[2], 0x2580); +} + +TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + const auto configured = this->bus_.registers; + this->chip_.update(); + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_TRUE(this->chip_.status_has_warning()); + for (size_t reg = 1; reg < 4; ++reg) + EXPECT_EQ(this->bus_.registers[reg], configured[reg]); + this->chip_.update(); + EXPECT_EQ(this->publications_, 2); + EXPECT_FLOAT_EQ(this->chip_.state, 25); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, RecoveryRetriesFailedWritesAndDetectsThresholdOnlyChange) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[3] = 0; + this->bus_.fail_write = 3; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.fail_write = -1; + this->chip_.update(); + EXPECT_EQ(this->bus_.registers[3], 0x5000); + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, ConfigurationReadFailuresDoNotPublishOrOverwriteRegisters) { + this->configure(); + this->chip_.setup(); + for (int reg : {1, 2, 3}) { + const auto publications = this->publications_; + const auto writes = this->bus_.writes; + this->bus_.fail_read = reg; + this->chip_.update(); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->publications_, publications); + EXPECT_EQ(this->bus_.writes, writes); + this->bus_.fail_read = -1; + } +} + +TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { + this->configure(); + this->chip_.setup(); + const auto writes = this->bus_.writes; + this->bus_.registers[1] ^= 0xE020; + this->chip_.update(); + EXPECT_EQ(this->publications_, 1); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->configure(0x0180); + this->chip_.setup(); + this->bus_.fail_write = 1; + this->chip_.update(); + run_timers(45); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) { + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + this->bus_.pointer = 3; + run_timers(45); + EXPECT_EQ(this->publications_, 1); + EXPECT_FLOAT_EQ(this->chip_.state, 25); +} + +TEST_F(TMP102Test, LimitsAreOnlyCheckedWhenConfigurationMatchesResetDefault) { + this->configure(); + this->chip_.setup(); + this->bus_.registers[2] = 0; + this->chip_.update(); + EXPECT_EQ(this->publications_, 0); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_EQ(this->bus_.registers[2], 0x4B00); + + this->configure(0x00C0); + this->chip_.setup(); + this->bus_.registers[2] = 0; + const auto transactions = this->bus_.transactions; + const auto writes = this->bus_.writes; + this->chip_.update(); + EXPECT_EQ(this->bus_.transactions - transactions, 2); + EXPECT_EQ(this->bus_.writes, writes); + EXPECT_EQ(this->publications_, 1); +} + +TEST_F(TMP102Test, FailureOfAnySetupWriteMarksComponentFailed) { + for (int reg : {1, 2, 3}) { + TMP102Component failed; + failed.set_i2c_bus(&this->bus_); + failed.set_i2c_address(0x48); + failed.set_configuration(0x0080, 0x5000, 0x4B00); + this->bus_.fail_write = reg; + failed.setup(); + EXPECT_TRUE(failed.is_failed()); + } +} + +} // namespace esphome::tmp102::testing + +#endif From eb1aa2bfd6e44ffbc5082b53be410bad68776896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:07:06 -1000 Subject: [PATCH 51/97] [web_server] Keep the cover, valve and alarm panel action tables in flash (#20397) --- esphome/components/web_server/web_server.cpp | 72 ++++++++------------ 1 file changed, 27 insertions(+), 45 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 062007b02e0..323857c37e8 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -60,6 +60,27 @@ ESPHOME_LOG_TAG(TAG, "web_server"); // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +// One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the +// table needs no RAM on ESP8266. +template struct ActionEntry { + char name[16]; + Call &(Call::*action)(); +}; + +// Runs the entry whose name matches the request's action; false when none does. +template +static bool run_action(const UrlMatch &match, Call &call, const ActionEntry (&table)[N]) { + for (const auto &entry : table) { + if (match.method_equals(reinterpret_cast(entry.name))) { + Call &(Call::*action)(); + progmem_memcpy(&action, &entry.action, sizeof(action)); + (call.*action)(); + return true; + } + } + return false; +} + // Out of line: every GET handler ends with this [[maybe_unused]] static void send_json(AsyncWebServerRequest *request, json::JsonBuilder &builder) { auto data = builder.serialize(); @@ -1051,27 +1072,14 @@ void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for cover methods - static const struct { - const char *name; - cover::CoverCall &(cover::CoverCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &cover::CoverCall::set_command_open}, {"close", &cover::CoverCall::set_command_close}, {"stop", &cover::CoverCall::set_command_stop}, {"toggle", &cover::CoverCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1745,27 +1753,14 @@ void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMa auto call = obj->make_call(); - // Lookup table for valve methods - static const struct { - const char *name; - valve::ValveCall &(valve::ValveCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &valve::ValveCall::set_command_open}, {"close", &valve::ValveCall::set_command_close}, {"stop", &valve::ValveCall::set_command_stop}, {"toggle", &valve::ValveCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1831,11 +1826,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques static_cast(&decltype(call)::set_code)); - // Lookup table for alarm control panel methods - static const struct { - const char *name; - alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, {"arm_away", &alarm_control_panel::AlarmControlPanelCall::arm_away}, {"arm_home", &alarm_control_panel::AlarmControlPanelCall::arm_home}, @@ -1843,16 +1834,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques {"arm_vacation", &alarm_control_panel::AlarmControlPanelCall::arm_vacation}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found) { + if (!run_action(match, call, ACTIONS)) { request->send(404); return; } From 9355bdc3c5472560566763ec7ed5949b43c8fdf0 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 11 Oct 2026 21:12:28 +0200 Subject: [PATCH 52/97] [canbus] Bus error logging, Auto-recover bus-off on ESP32 (#10953) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/canbus/__init__.py | 17 ++++- esphome/components/canbus/canbus.cpp | 58 +++++++++++++++ esphome/components/canbus/canbus.h | 44 ++++++++++++ esphome/components/esp32_can/canbus.py | 2 +- esphome/components/esp32_can/esp32_can.cpp | 82 ++++++++++++++++++++++ esphome/components/esp32_can/esp32_can.h | 5 ++ esphome/components/mcp2515/canbus.py | 2 +- esphome/components/mcp2515/mcp2515.cpp | 53 +++++++++++--- esphome/components/mcp2515/mcp2515.h | 5 ++ tests/components/esp32_can/common.yaml | 9 +++ tests/components/mcp2515/common.yaml | 11 +++ 11 files changed, 273 insertions(+), 15 deletions(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 23dc41d5092..56733870dc4 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,14 +1,17 @@ +import logging import re from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID +from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@mvturnho", "@danielschramm"] DOMAIN = "canbus" IS_PLATFORM_COMPONENT = True @@ -150,9 +153,19 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: ) -async def register_canbus(var: MockObj, config: ConfigType) -> None: +async def register_canbus( + var: MockObj, config: ConfigType, *, reports_status: bool | None = None +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) + if reports_status is None: + _LOGGER.warning( + "CAN bus platform '%s' does not report bus state, so bus-off recovery and " + "error-state logging are disabled. Platform authors should either override " + "get_status() and get_events() and call register_canbus(..., reports_status=True) " + "or pass reports_status=False if the hardware cannot report it", + config[CONF_PLATFORM], + ) await setup_canbus_core_(var, config) diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index 88eed2246fd..dfe3ab9fd31 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -1,5 +1,6 @@ #include "canbus.h" #include +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::canbus { @@ -65,6 +66,12 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { }; void Canbus::loop() { + uint32_t now = App.get_loop_component_start_time(); + if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) { + this->log_events_(this->get_events(), now); + this->last_event_check_time_ = now; + } + struct CanFrame can_message; uint32_t message_counter = 0; for (; message_counter < this->max_frames_per_loop_; message_counter++) { @@ -109,4 +116,55 @@ void Canbus::loop() { } } +void Canbus::log_events_(CanEventFlags events, uint32_t now) { + // Bus-off gets its own handling because automatic recovery can make it flap + if (events & CanEventFlags::CAN_EVENT_BUS_OFF) { + this->last_bus_off_time_ = now; + if (!this->bus_off_) { + this->bus_off_ = true; + this->status_set_warning(LOG_STR("Bus off")); + } + } else if (this->bus_off_) { + if ((now - this->last_bus_off_time_) >= EVENT_LOG_BUS_OFF_HOLDOFF_MS) { + // No bus-off event for the hold-off time, so ask whether it is really over + if (this->get_status().bus_off) { + this->last_bus_off_time_ = now; + } else { + this->bus_off_ = false; + this->status_clear_warning(); + // Events gathered while bus-off say nothing about the recovered bus + this->events_to_log_ = 0; + } + } + } else { + this->events_to_log_ |= events; + if (this->events_to_log_ != 0 && (now - this->last_event_log_time_) >= EVENT_LOG_THROTTLE_MS) { + if (this->events_to_log_ & (CanEventFlags::CAN_EVENT_PASSIVE | CanEventFlags::CAN_EVENT_ACTIVE)) { + bool error_passive = this->events_to_log_ & CanEventFlags::CAN_EVENT_PASSIVE; + if (error_passive && (this->events_to_log_ & CanEventFlags::CAN_EVENT_ACTIVE)) { + // Both edges within one throttle window, so the counters decide the current state + auto status = this->get_status(); + error_passive = status.rx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD || + status.tx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD; + } + ESP_LOGW(TAG, "entered %s", error_passive ? LOG_STR_LITERAL("error-passive") : LOG_STR_LITERAL("error-active")); + } + if (this->events_to_log_ & CanEventFlags::CAN_EVENT_RX_QUEUE_FULL) { + ESP_LOGD(TAG, "receive buffer overrun"); + } + this->events_to_log_ = 0; + this->last_event_log_time_ = now; + } + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + if ((now - this->last_state_log_time_) >= STATE_LOG_INTERVAL_MS) { + auto status = this->get_status(); + ESP_LOGV(TAG, "Status: bus_off %d, tx_err %d, rx_err %d", status.bus_off, status.tx_error_counter, + status.rx_error_counter); + this->last_state_log_time_ = now; + } +#endif +} + } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 432eb77e13d..077ac9d4cdf 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -48,6 +48,8 @@ template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ static const uint8_t CAN_MAX_DATA_LENGTH = 8; +static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; + /* Can Frame describes a normative CAN Frame The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used @@ -61,6 +63,26 @@ struct CanFrame { uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); }; +enum CanEventFlags : uint8_t { + CAN_EVENT_PASSIVE = 1 << 0, + CAN_EVENT_ACTIVE = 1 << 1, + CAN_EVENT_BUS_OFF = 1 << 2, + CAN_EVENT_RX_QUEUE_FULL = 1 << 3, +}; + +static constexpr uint32_t CAN_STATUS_UNSET = UINT32_MAX; + +struct CanStatus { + bool bus_off{false}; + uint8_t rx_error_counter{0}; + uint8_t tx_error_counter{0}; + uint32_t tx_failed_count{CAN_STATUS_UNSET}; + uint32_t rx_missed_count{CAN_STATUS_UNSET}; + uint32_t rx_overrun_count{CAN_STATUS_UNSET}; + uint32_t arb_lost_count{CAN_STATUS_UNSET}; + uint32_t bus_error_count{CAN_STATUS_UNSET}; +}; + class Canbus : public Component { public: Canbus(){}; @@ -83,6 +105,7 @@ class Canbus : public Component { void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } + virtual CanStatus get_status() { return {}; } void add_trigger(CanbusTrigger *trigger); /** @@ -104,12 +127,33 @@ class Canbus : public Component { uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py bool use_extended_id_{false}; + uint8_t events_to_log_{0}; + bool bus_off_{false}; CallbackManager &data)> callback_manager_{}; + // interval in which to check for new can bus events (bus-off, passive, active) + static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100; + uint32_t last_event_check_time_{0}; + + // time to wait after logging an event before logging the next event(s) + static constexpr uint32_t EVENT_LOG_THROTTLE_MS = 1000; + uint32_t last_event_log_time_{0}; + + // time span without bus-off events before the bus is declared recovered + static constexpr uint32_t EVENT_LOG_BUS_OFF_HOLDOFF_MS = 1000; + uint32_t last_bus_off_time_{0}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE + static constexpr uint32_t STATE_LOG_INTERVAL_MS = 1000; + uint32_t last_state_log_time_{0}; +#endif + + void log_events_(CanEventFlags events, uint32_t now); virtual bool setup_internal() = 0; virtual Error send_message(struct CanFrame *frame) = 0; virtual Error read_message(struct CanFrame *frame) = 0; + virtual CanEventFlags get_events() { return CanEventFlags{}; } }; template class CanbusSendAction final : public Action, public Parented { diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2272459fb8f..2713bae241a 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -129,7 +129,7 @@ async def to_code(config: ConfigType) -> None: # Also enable esp_driver_twai for future migration to new API include_builtin_idf_component("esp_driver_twai") var = cg.new_Pvariable(config[CONF_ID]) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) cg.add(var.set_rx(config[CONF_RX_PIN])) cg.add(var.set_tx(config[CONF_TX_PIN])) diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index 083c97f8cfe..b98ba853e78 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -84,6 +84,8 @@ bool ESP32Can::setup_internal() { twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t) this->tx_, (gpio_num_t) this->rx_, twai_mode); g_config.controller_id = next_twai_ctrl_num++; + g_config.alerts_enabled = TWAI_ALERT_ERR_PASS | TWAI_ALERT_ERR_ACTIVE | TWAI_ALERT_BUS_OFF | + TWAI_ALERT_BUS_RECOVERED | TWAI_ALERT_RX_QUEUE_FULL; if (this->tx_queue_len_.has_value()) { g_config.tx_queue_len = this->tx_queue_len_.value(); } @@ -116,6 +118,86 @@ bool ESP32Can::setup_internal() { return true; } +canbus::CanEventFlags ESP32Can::get_events() { + if (this->twai_handle_ == nullptr) { + return {}; // not set up yet, or setup failed + } + + uint32_t alerts; + esp_err_t err = twai_read_alerts_v2(this->twai_handle_, &alerts, 0); + if (err != ESP_OK) { + if (err != ESP_ERR_TIMEOUT) { + ESP_LOGD(TAG, "failed to get CAN events"); + } + alerts = 0; + } + + uint8_t events = 0; + if (alerts & TWAI_ALERT_ERR_PASS) { + events |= canbus::CAN_EVENT_PASSIVE; + } + if (alerts & TWAI_ALERT_ERR_ACTIVE) { + events |= canbus::CAN_EVENT_ACTIVE; + } + if (alerts & TWAI_ALERT_BUS_OFF) { + events |= canbus::CAN_EVENT_BUS_OFF; + // Start recovery at once, as the MCP2515 does in hardware + this->recovery_pending_ = true; + } + if (alerts & TWAI_ALERT_BUS_RECOVERED) { + // Recovery leaves the controller stopped + this->restart_pending_ = true; + } + if (this->recovery_pending_ || this->restart_pending_) { + this->handle_bus_off_recovery_(); + } + if (alerts & TWAI_ALERT_RX_QUEUE_FULL) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + } + return static_cast(events); +} + +void ESP32Can::handle_bus_off_recovery_() { + // These only fail with an invalid handle or outside the expected state; retry on the next pass + if (this->recovery_pending_) { + if (twai_initiate_recovery_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Recovery after bus off failed")); + return; + } + this->recovery_pending_ = false; + } + if (this->restart_pending_) { + if (twai_start_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Restart after bus off failed")); + return; + } + this->restart_pending_ = false; + } + this->status_clear_error(); +} + +canbus::CanStatus ESP32Can::get_status() { + canbus::CanStatus status = {}; + + twai_status_info_t twai_status; + + if (this->twai_handle_ != nullptr && twai_get_status_info_v2(this->twai_handle_, &twai_status) == ESP_OK) { + status.bus_off = (twai_status.state == TWAI_STATE_BUS_OFF) || (twai_status.state == TWAI_STATE_RECOVERING); + status.rx_error_counter = twai_status.rx_error_counter; + status.tx_error_counter = twai_status.tx_error_counter; + + status.tx_failed_count = twai_status.tx_failed_count; + status.rx_missed_count = twai_status.rx_missed_count; + status.rx_overrun_count = twai_status.rx_overrun_count; + status.arb_lost_count = twai_status.arb_lost_count; + status.bus_error_count = twai_status.bus_error_count; + } else { + ESP_LOGD(TAG, "failed to get CAN status"); + } + + return status; +} + canbus::Error ESP32Can::send_message(struct canbus::CanFrame *frame) { // In listen-only mode, we cannot transmit if (this->mode_ == CAN_MODE_LISTEN_ONLY) { diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f224e10be33..f9549bea346 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -24,16 +24,21 @@ class ESP32Can final : public canbus::Canbus { void set_tx_enqueue_timeout_ms(uint32_t tx_enqueue_timeout_ms) { this->tx_enqueue_timeout_ticks_ = pdMS_TO_TICKS(tx_enqueue_timeout_ms); } + canbus::CanStatus get_status() override; ESP32Can(){}; protected: bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error send_message(struct canbus::CanFrame *frame) override; canbus::Error read_message(struct canbus::CanFrame *frame) override; + void handle_bus_off_recovery_(); int rx_{-1}; int tx_{-1}; CanMode mode_{CAN_MODE_NORMAL}; + bool recovery_pending_{false}; + bool restart_pending_{false}; TickType_t tx_enqueue_timeout_ticks_{}; optional tx_queue_len_{}; optional rx_queue_len_{}; diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index 8bb8918f965..eab9be0f700 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -40,7 +40,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( async def to_code(config: ConfigType) -> None: rhs = mcp2515.new() var = cg.Pvariable(config[CONF_ID], rhs) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) if CONF_CLOCK in config: canclock = CAN_CLOCK[config[CONF_CLOCK]] cg.add(var.set_mcp_clock(canclock)) diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index 8a1973d022f..599a864e54e 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -43,6 +43,48 @@ bool MCP2515::setup_internal() { return true; } +canbus::CanEventFlags MCP2515::get_events() { + uint8_t events = 0; + uint8_t error_flags = this->get_error_flags_(); + + // Error-passive is one state for RX and TX together, so only a change of the combined flag is an event + bool was_passive = this->last_error_flags_ & (EFLG_RXEP | EFLG_TXEP); + bool is_passive = error_flags & (EFLG_RXEP | EFLG_TXEP); + if (was_passive != is_passive) { + events |= is_passive ? canbus::CAN_EVENT_PASSIVE : canbus::CAN_EVENT_ACTIVE; + } + if ((this->last_error_flags_ ^ error_flags) & EFLG_TXBO) { + events |= canbus::CAN_EVENT_BUS_OFF; + } + + // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set + // once both buffers are full. However, the RX0OVR flag is actually set instead. + // We can just check for both though because it doesn't break anything. + if (error_flags & (EFLG_RX0OVR | EFLG_RX1OVR)) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + this->clear_rx_n_ovr_flags_(); + } + + this->last_error_flags_ = error_flags; + return static_cast(events); +} + +canbus::CanStatus MCP2515::get_status() { + canbus::CanStatus status = {}; + + uint8_t error_flags = this->get_error_flags_(); + status.bus_off = error_flags & EFLG_TXBO; + + // Transmit Error Counter is followed by Receive Error Counter so we're reading both at once + uint8_t err_counters[2]; + this->read_registers_(MCP_TEC, err_counters, 2); + + status.rx_error_counter = err_counters[1]; + status.tx_error_counter = err_counters[0]; + + return status; +} + canbus::Error MCP2515::reset_() { this->enable(); this->transfer_byte(INSTRUCTION_RESET); @@ -331,17 +373,6 @@ canbus::Error MCP2515::read_message(struct canbus::CanFrame *frame) { rc = canbus::ERROR_NOMSG; } -#ifdef ESPHOME_LOG_HAS_DEBUG - uint8_t err = get_error_flags_(); - // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set - // once both buffers are full. However, the RX0OVR flag is actually set instead. - // We can just check for both though because it doesn't break anything. - if (err & (EFLG_RX0OVR | EFLG_RX1OVR)) { - ESP_LOGD(TAG, "receive buffer overrun"); - clear_rx_n_ovr_flags_(); - } -#endif - return rc; } diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index 960e1508000..8d2fe4d51b6 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -58,6 +58,7 @@ class MCP2515 final : public canbus::Canbus, MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; void set_mcp_mode(const CanctrlReqopMode mode) { this->mcp_mode_ = mode; } + canbus::CanStatus get_status() override; static const struct TxBnRegs { REGISTER CTRL; REGISTER SIDH; @@ -74,7 +75,11 @@ class MCP2515 final : public canbus::Canbus, protected: CanClock mcp_clock_{MCP_8MHZ}; CanctrlReqopMode mcp_mode_ = CANCTRL_REQOP_NORMAL; + + uint8_t last_error_flags_{0}; + bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error set_mode_(CanctrlReqopMode mode); uint8_t read_register_(REGISTER reg); diff --git a/tests/components/esp32_can/common.yaml b/tests/components/esp32_can/common.yaml index f15b609d843..a26c5d35a34 100644 --- a/tests/components/esp32_can/common.yaml +++ b/tests/components/esp32_can/common.yaml @@ -44,3 +44,12 @@ canbus: break; // to be continued... } + +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(esp32_can_esp32_internal_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d, bus_err: %" PRIu32 ", arb_lost: %" PRIu32, + status.bus_off, status.tx_error_counter, status.rx_error_counter, + status.bus_error_count, status.arb_lost_count); diff --git a/tests/components/mcp2515/common.yaml b/tests/components/mcp2515/common.yaml index 2e88f3e5c64..a35839d9f29 100644 --- a/tests/components/mcp2515/common.yaml +++ b/tests/components/mcp2515/common.yaml @@ -42,6 +42,17 @@ canbus: // to be continued... } +interval: + - interval: 1s + then: + - lambda: |- + auto status = id(mcp2515_can).get_status(); + ESP_LOGD("canbus_status", "bus_off: %d, tx_err: %d, rx_err: %d", + status.bus_off, + status.tx_error_counter, + status.rx_error_counter + ); + button: - platform: template name: MCP2515 Send From 8449820480e14cc8769b9fab16352c50d6c0660d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:18 +0200 Subject: [PATCH 53/97] [emontx] Use the core validate_synchronous_automation() helper (#20525) Co-authored-by: Claude Opus 5.5 --- esphome/components/emontx/__init__.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 971f9769246..79b0e83405b 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -44,20 +44,11 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] -def _synchronous_automation(value: ConfigType) -> ConfigType: - """Reject deferring actions: the received line and parsed JSON are only valid while the - automation runs, and DelayAction and friends keep the trigger arguments for later.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in on_json and " - "on_data: the received data is only valid while the automation runs. Copy what you need " - "into globals first, then defer in a separate script or automation." - ) - return value - - def _synchronous_automation_schema() -> cv.All: - return cv.All(automation.validate_automation({}), _synchronous_automation) + # The received line and the parsed JSON document only live while the callbacks run. + return automation.validate_synchronous_automation( + "on_json and on_data", "the received data" + ) # Main configuration schema From eff3a6f9a269d80329ad490b3d1bd4498a570a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:39 +0200 Subject: [PATCH 54/97] [modbus_client] Use the core deferring-actions validators (#20526) Co-authored-by: Claude Opus 5.5 --- esphome/components/modbus_client/__init__.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 5294f20f2de..67c3cd75bdc 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -113,20 +113,18 @@ def _packed_bit_bytes(bits: int) -> int: return (bits + 7) // 8 -def _synchronous_handler(value: ConfigType) -> ConfigType: - """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused - once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " - "handlers: the request/response data is only valid while the handler runs. Copy what you " - "need into globals first, then defer in a separate script or automation." - ) - return value +# Handler PDU spans point into hub buffers that are reused once the handler returns. +_HANDLER_CONTEXT = "modbus_client handlers" +_HANDLER_DATA = "the request/response data" +_synchronous_handler = automation.validate_no_deferring_actions( + _HANDLER_CONTEXT, _HANDLER_DATA +) def _handler_schema() -> cv.All: - return cv.All(automation.validate_automation(single=True), _synchronous_handler) + return automation.validate_synchronous_automation( + _HANDLER_CONTEXT, _HANDLER_DATA, single=True + ) # Each action is its own hub device: the modbus hub routes the reply straight back to the action that From 78c7cd725de3b80dc6d2cc496141617a85d0a5d2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:22:39 -1000 Subject: [PATCH 55/97] [remote_base] Transmit constant Haier, Mirage and AEHA codes without a heap vector (#20287) --- .../components/remote_base/aeha_protocol.cpp | 9 +- .../components/remote_base/aeha_protocol.h | 12 +- .../components/remote_base/haier_protocol.cpp | 7 +- .../components/remote_base/haier_protocol.h | 7 +- .../remote_base/mirage_protocol.cpp | 9 +- .../components/remote_base/mirage_protocol.h | 9 +- tests/components/remote_base/__init__.py | 3 +- .../remote_base/ir_code_encoders_test.cpp | 139 ++++++++++++++++++ 8 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 tests/components/remote_base/ir_code_encoders_test.cpp diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index eb05f7d21e8..3263f6b2208 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -14,20 +14,21 @@ static constexpr uint16_t BIT_ONE_LOW_US = BITWISE * 3; static constexpr uint16_t BIT_ZERO_LOW_US = BITWISE; static constexpr uint16_t TRAILER = BITWISE; -void AEHAProtocol::encode(RemoteTransmitData *dst, const AEHAData &data) { - dst->reserve(2 + 32 + (data.data.size() * 2) + 1); +void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len) { + dst->reserve(2 + 32 + (len * 2) + 1); dst->item(HEADER_HIGH_US, HEADER_LOW_US); for (uint16_t mask = 1 << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { + if (address & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); } else { dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); } } - for (uint8_t bit : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t bit = data[i]; for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { if (bit & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 57706a7e4f1..f6064dd5df7 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,7 +15,10 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data); + void encode(RemoteTransmitData *dst, const AEHAData &data) { + this->encode(dst, data.address, data.data.data(), data.data.size()); + } + void encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const AEHAData &data); @@ -32,11 +35,10 @@ template class AEHAAction : public RemoteTransmitterActionBaseaddress_.value(x...); - data.data = this->data_.value(x...); + const uint16_t address = this->address_.value(x...); dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - AEHAProtocol().encode(dst, data); + this->data_.visit( + [dst, address](const uint8_t *data, size_t len) { AEHAProtocol().encode(dst, address, data, len); }, x...); } }; diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index 3c9ba7b61f0..e7fa563fb54 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -29,16 +29,17 @@ void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { } } -void HaierProtocol::encode(RemoteTransmitData *dst, const HaierData &data) { +void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 16)); + dst->reserve(5 + ((len + 1) * 16)); dst->mark(HEADER_LOW_US); dst->space(HEADER_LOW_US); dst->mark(HEADER_LOW_US); dst->space(HEADER_HIGH_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = data[i]; this->encode_byte_(dst, item); checksum += item; } diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 90af88d2c78..770784b0fc5 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,7 +13,8 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data); + void encode(RemoteTransmitData *dst, const HaierData &data) { this->encode(dst, data.data.data(), data.data.size()); } + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const HaierData &data); @@ -28,9 +29,7 @@ template class HaierAction : public RemoteTransmitterActionBase< TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - HaierData data{}; - data.data = this->code_.value(x...); - HaierProtocol().encode(dst, data); + this->code_.visit([dst](const uint8_t *data, size_t len) { HaierProtocol().encode(dst, data, len); }, x...); } }; diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index c2a487080bc..23c6bd21e8f 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -16,16 +16,17 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; // Max data bytes in packet (excluding checksum) constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); -void MirageProtocol::encode(RemoteTransmitData *dst, const MirageData &data) { +void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { char hex_buf[format_hex_pretty_size(MIRAGE_MAX_DATA_BYTES)]; - ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data, len)); dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 2)); + dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); dst->space(HEADER_SPACE_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = data[i]; this->encode_byte_(dst, item); checksum += (item >> 4) + (item & 0xF); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index 958127ba664..923d412e841 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,7 +13,10 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data); + void encode(RemoteTransmitData *dst, const MirageData &data) { + this->encode(dst, data.data.data(), data.data.size()); + } + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); optional decode(RemoteReceiveData src); void dump(const MirageData &data); @@ -28,9 +31,7 @@ template class MirageAction : public RemoteTransmitterActionBase TEMPLATABLE_BYTES(code) void encode(RemoteTransmitData *dst, Ts... x) override { - MirageData data{}; - data.data = this->code_.value(x...); - MirageProtocol().encode(dst, data); + this->code_.visit([dst](const uint8_t *data, size_t len) { MirageProtocol().encode(dst, data, len); }, x...); } }; diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 714f8ab571a..c52eb41ca44 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - request_protocol("hob2hood") + for protocol in ("hob2hood", "haier", "mirage", "aeha"): + request_protocol(protocol) manifest.to_code = to_code_testing # AUTO_LOAD is not resolved by the unit test build. diff --git a/tests/components/remote_base/ir_code_encoders_test.cpp b/tests/components/remote_base/ir_code_encoders_test.cpp new file mode 100644 index 00000000000..bffea25864e --- /dev/null +++ b/tests/components/remote_base/ir_code_encoders_test.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/components/remote_base/haier_protocol.h" +#include "esphome/components/remote_base/mirage_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +const uint8_t HAIER_CODE[] = {0xA6, 0x12, 0x00, 0x00, 0x40, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x05}; +const uint8_t MIRAGE_CODE[] = {0x56, 0x75, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; +const uint8_t AEHA_CODE[] = {0x80, 0x00, 0x00, 0x00, 0x00}; +constexpr uint16_t AEHA_ADDRESS = 0x2002; + +// Captured from the struct encoders before they gained the pointer and length overloads +const RawTimings HAIER_GOLDEN = { + 3100, -3100, 3100, -4400, 540, -1650, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, 540, -580, + 540, -580, 540, -580, 540, -580, 540, -580, 540, -1650, 540, -580, 540, -1650, 540, -1650, 540, -580, + 540, -1650, 540, -1650, 540, -1650, 540, -1650, 540, -580, 540, -1650, 540}; +const RawTimings MIRAGE_GOLDEN = { + 8360, -4248, 554, -545, 554, -1592, 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -1592, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, + 554, -545, 554, -545, 554, -545, 554, -545, 554, -545, 554, -1592, 554, -1592, 554, -1592, 554, -545, + 554, -1592, 554, -545, 554, -545, 554, -545, 554}; +const RawTimings AEHA_GOLDEN = { + 3400, -1700, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -1275, 425, -425, 425, -1275, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, + 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425, -425, 425}; + +std::vector haier_code() { return {std::begin(HAIER_CODE), std::end(HAIER_CODE)}; } +std::vector mirage_code() { return {std::begin(MIRAGE_CODE), std::end(MIRAGE_CODE)}; } +std::vector aeha_code() { return {std::begin(AEHA_CODE), std::end(AEHA_CODE)}; } + +// The Haier and Mirage decoders expect an item after the last mark (pre-existing) +RawTimings with_trailing_space(RawTimings timings) { + timings.push_back(-10000); + return timings; +} + +template RawTimings encode(A &action) { + RemoteTransmitData dst; + action.encode(&dst); + return dst.get_data(); +} + +// Exposes the protected encode() of an action for testing +template class Open : public A { + public: + using A::encode; +}; + +// A constant and a lambda code must both encode to the golden timings +template +void expect_static_and_lambda_match(const uint8_t *code, size_t len, std::vector (*lambda)(), + const RawTimings &golden) { + Open fixed, from_lambda; + fixed.set_code_static(code, len); + from_lambda.set_code_template(lambda); + EXPECT_EQ(encode(fixed), golden); + EXPECT_EQ(encode(from_lambda), golden); +} + +template void expect_decodes_to(const RawTimings &timings, const std::vector &code) { + auto decoded = P().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); + ASSERT_TRUE(decoded.has_value()); + // clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check + EXPECT_EQ(decoded.value_or(typename P::ProtocolData{}).data, code); +} + +} // namespace + +TEST(IrCodeEncodersTest, HaierStaticAndLambdaMatchGolden) { + expect_static_and_lambda_match>(HAIER_CODE, sizeof(HAIER_CODE), haier_code, HAIER_GOLDEN); +} + +TEST(IrCodeEncodersTest, MirageStaticAndLambdaMatchGolden) { + expect_static_and_lambda_match>(MIRAGE_CODE, sizeof(MIRAGE_CODE), mirage_code, MIRAGE_GOLDEN); +} + +TEST(IrCodeEncodersTest, AehaStaticAndLambdaMatchGolden) { + Open> fixed, lambda; + for (auto *action : {&fixed, &lambda}) { + // TemplatableFn fields take functions, not raw constants + action->set_address([]() -> uint16_t { return AEHA_ADDRESS; }); + action->set_carrier_frequency([]() -> uint32_t { return 38000; }); + } + fixed.set_data_static(AEHA_CODE, sizeof(AEHA_CODE)); + lambda.set_data_template(aeha_code); + EXPECT_EQ(encode(fixed), AEHA_GOLDEN); + EXPECT_EQ(encode(lambda), AEHA_GOLDEN); +} + +TEST(IrCodeEncodersTest, StructEncodersMatchGolden) { + RemoteTransmitData haier, mirage, aeha; + HaierProtocol().encode(&haier, HaierData{haier_code()}); + MirageProtocol().encode(&mirage, MirageData{mirage_code()}); + AEHAData aeha_data; + aeha_data.address = AEHA_ADDRESS; + aeha_data.data = aeha_code(); + AEHAProtocol().encode(&aeha, aeha_data); + EXPECT_EQ(haier.get_data(), HAIER_GOLDEN); + EXPECT_EQ(mirage.get_data(), MIRAGE_GOLDEN); + EXPECT_EQ(aeha.get_data(), AEHA_GOLDEN); +} + +TEST(IrCodeEncodersTest, RoundTripDecodes) { + expect_decodes_to(with_trailing_space(HAIER_GOLDEN), haier_code()); + expect_decodes_to(with_trailing_space(MIRAGE_GOLDEN), mirage_code()); + expect_decodes_to(AEHA_GOLDEN, aeha_code()); + auto aeha = AEHAProtocol().decode(RemoteReceiveData(AEHA_GOLDEN, 25, TOLERANCE_MODE_PERCENTAGE)); + EXPECT_EQ(aeha.value_or(AEHAData{}).address, AEHA_ADDRESS); +} + +} // namespace esphome::remote_base::testing From c130e2808b24fe430df9d88d628ffd3f6da3ee05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 09:23:05 -1000 Subject: [PATCH 56/97] [display] Build on_page_change with build_callback_automation and compile it out when unused (#20456) --- esphome/components/display/__init__.py | 30 ++++++++++++-------------- esphome/components/display/display.cpp | 17 ++++++--------- esphome/components/display/display.h | 26 +++++++++------------- esphome/core/defines.h | 1 + 4 files changed, 32 insertions(+), 42 deletions(-) diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 1cd75ddaf07..0b871af30cd 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -21,7 +21,6 @@ from esphome.const import ( CONF_PAGES, CONF_ROTATION, CONF_TO, - CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, CONF_WIDTH, SCHEDULER_DONT_RUN, @@ -39,14 +38,13 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayOnPageChangeTrigger = display_ns.class_( - "DisplayOnPageChangeTrigger", automation.Trigger -) CONF_ON_PAGE_CHANGE = "on_page_change" CONF_SHOW_TEST_CARD = "show_test_card" CONF_UNSPECIFIED = "unspecified" +_request_page_change_slot = cg.slot_counter("DISPLAY_PAGE_CHANGE_CALLBACK_COUNT") + DISPLAY_ROTATIONS = { 0: display_ns.DISPLAY_ROTATION_0_DEGREES, 90: display_ns.DISPLAY_ROTATION_90_DEGREES, @@ -99,9 +97,6 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend( ), cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayOnPageChangeTrigger - ), cv.Optional(CONF_FROM): cv.use_id(DisplayPage), cv.Optional(CONF_TO): cv.use_id(DisplayPage), } @@ -137,15 +132,18 @@ async def setup_display_core_(var, config): pages.append(page) cg.add(var.set_pages(pages)) for conf in config.get(CONF_ON_PAGE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if CONF_FROM in conf: - page = await cg.get_variable(conf[CONF_FROM]) - cg.add(trigger.set_from(page)) - if CONF_TO in conf: - page = await cg.get_variable(conf[CONF_TO]) - cg.add(trigger.set_to(page)) - await automation.build_automation( - trigger, [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], conf + _request_page_change_slot(str(var)) + checks = [] + for key, name in ((CONF_FROM, "from"), (CONF_TO, "to")): + if (page_id := conf.get(key)) is not None: + page = await cg.get_variable(page_id) + checks.append(f"{name} == ::{page}") + await automation.build_callback_automation( + var, + "add_on_page_change_callback", + [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], + conf, + when=" && ".join(checks) or None, ) if config.get(CONF_SHOW_TEST_CARD): cg.add(var.show_test_card()) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 49cd2fc7c66..0bd8346953c 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -678,12 +678,14 @@ void Display::set_pages(std::vector pages) { } void Display::show_page(DisplayPage *page) { - this->previous_page_ = this->page_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + DisplayPage *previous = this->page_; this->page_ = page; - if (this->previous_page_ != this->page_) { - for (auto *t : on_page_change_triggers_) - t->process(this->previous_page_, this->page_); - } + if (previous != page) + this->page_change_callback_.call(previous, page); +#else + this->page_ = page; +#endif } void Display::do_update_() { @@ -700,11 +702,6 @@ void Display::do_update_() { this->clear_clipping_(); } -void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) { - if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to)) - this->trigger(from, to); -} - void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ESPTime time) { char buffer[64]; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index 74b5bcedd94..7d1053f4fa7 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -7,6 +7,8 @@ #include "esphome/core/color.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/time.h" #include "esphome/core/log.h" #include "display_color_utils.h" @@ -173,7 +175,6 @@ enum RegularPolygonDrawing { class Display; class DisplayPage; -class DisplayOnPageChangeTrigger; /** Optimized display writer that uses function pointers for stateless lambdas. * @@ -702,7 +703,11 @@ class Display : public PollingComponent { const DisplayPage *get_active_page() const { return this->page_; } - void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); } +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + template void add_on_page_change_callback(F &&callback) { + this->page_change_callback_.add(std::forward(callback)); + } +#endif /// Internal method to set the display rotation with. virtual void set_rotation(DisplayRotation rotation); @@ -809,8 +814,9 @@ class Display : public PollingComponent { DisplayRotation rotation_{DISPLAY_ROTATION_0_DEGREES}; display_writer_t writer_{}; DisplayPage *page_{nullptr}; - DisplayPage *previous_page_{nullptr}; - std::vector on_page_change_triggers_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + StaticCallbackManager page_change_callback_; +#endif bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; uint8_t wdt_pixel_counter_{0}; @@ -850,18 +856,6 @@ template class DisplayPageShowAction final : public Action { - public: - explicit DisplayOnPageChangeTrigger(Display *parent) { parent->add_on_page_change_trigger(this); } - void process(DisplayPage *from, DisplayPage *to); - void set_from(DisplayPage *p) { this->from_ = p; } - void set_to(DisplayPage *p) { this->to_ = p; } - - protected: - DisplayPage *from_{nullptr}; - DisplayPage *to_{nullptr}; -}; - const LogString *text_align_to_string(TextAlign textalign); } // namespace esphome::display diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 31549ceb8eb..552221dd5eb 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -63,6 +63,7 @@ #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY +#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT From 86192d89f4afc681e18b0652ea1a7fb1f6b4bb35 Mon Sep 17 00:00:00 2001 From: Petter Ljungqvist Date: Sun, 11 Oct 2026 21:29:56 +0200 Subject: [PATCH 57/97] [ufm01] Improve stale recovery, add diagnostics and clear flow action (#18899) Co-authored-by: Cursor Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- esphome/components/ufm01/__init__.py | 37 ++ esphome/components/ufm01/text_sensor.py | 34 ++ esphome/components/ufm01/ufm01.cpp | 563 ++++++++++++++---- esphome/components/ufm01/ufm01.h | 135 +++-- esphome/core/defines.h | 3 + tests/components/ufm01/__init__.py | 12 + tests/components/ufm01/benchmark.yaml | 29 + tests/components/ufm01/common.h | 214 ++++++- tests/components/ufm01/common.yaml | 14 + tests/components/ufm01/test.esp32-idf.yaml | 3 +- tests/components/ufm01/test.esp8266-ard.yaml | 3 +- tests/components/ufm01/test.rp2040-ard.yaml | 3 +- .../ufm01/ufm01_clear_action_test.cpp | 113 ++++ .../ufm01/ufm01_diagnostics_test.cpp | 173 ++++++ tests/components/ufm01/ufm01_frame_test.cpp | 26 +- tests/components/ufm01/ufm01_startup_test.cpp | 127 +++- tests/components/ufm01/validate.host.yaml | 16 + .../integration/fixtures/uart_mock_ufm01.yaml | 153 +++++ tests/integration/test_uart_mock_ufm01.py | 399 +++++++++++++ 19 files changed, 1858 insertions(+), 199 deletions(-) create mode 100644 esphome/components/ufm01/text_sensor.py create mode 100644 tests/components/ufm01/__init__.py create mode 100644 tests/components/ufm01/benchmark.yaml create mode 100644 tests/components/ufm01/ufm01_clear_action_test.cpp create mode 100644 tests/components/ufm01/ufm01_diagnostics_test.cpp create mode 100644 tests/components/ufm01/validate.host.yaml create mode 100644 tests/integration/fixtures/uart_mock_ufm01.yaml create mode 100644 tests/integration/test_uart_mock_ufm01.py diff --git a/esphome/components/ufm01/__init__.py b/esphome/components/ufm01/__init__.py index e1fcef107d5..1846958e591 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass + +from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] @@ -16,6 +20,23 @@ UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component CONF_UFM01_ID = "ufm01_id" + +@dataclass +class UFM01Data: + clear_action_used: bool = False + + +def _get_data() -> UFM01Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UFM01Data() + return CORE.data[DOMAIN] + + +def _note_clear_action(config: ConfigType) -> ConfigType: + _get_data().clear_action_used = True + return config + + CONFIG_SCHEMA = ( cv.Schema( { @@ -26,6 +47,13 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(UFM01Component), + }, + _note_clear_action, +) + FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "ufm01", require_tx=True, @@ -41,3 +69,12 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) + if _get_data().clear_action_used: + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + +automation.register_apply_action( + "ufm01.clear_accumulated_flow", + CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA, + automation.ApplyCall("request_clear_accumulated_flow()"), +) diff --git a/esphome/components/ufm01/text_sensor.py b/esphome/components/ufm01/text_sensor.py new file mode 100644 index 00000000000..dcb5d3294c2 --- /dev/null +++ b/esphome/components/ufm01/text_sensor.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP, ICON_FINGERPRINT +from esphome.types import ConfigType + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_METER_ID = "meter_id" +CONF_SOFTWARE_VERSION = "software_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_METER_ID): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_FINGERPRINT + ), + cv.Optional(CONF_SOFTWARE_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_CHIP + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_UFM01_ID]) + sensors = text_sensor.sub_text_sensors(config) + if await sensors(CONF_METER_ID, hub.set_meter_id_text_sensor) is not None: + cg.add_define("USE_UFM01_METER_ID") + if ( + await sensors(CONF_SOFTWARE_VERSION, hub.set_software_version_text_sensor) + is not None + ): + cg.add_define("USE_UFM01_SOFTWARE_VERSION") diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index c68842ab6c1..47ff2165f8c 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -1,8 +1,9 @@ #include "ufm01.h" -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include #include #include #include @@ -13,6 +14,7 @@ ESPHOME_LOG_TAG(TAG, "ufm01"); static constexpr uint8_t COMMAND_ACK = 0xE5; static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t CLEAR_QUEUE_TIMEOUT_MS = 15000; static constexpr uint32_t STARTUP_DELAY_MS = 2000; static constexpr uint32_t POST_RESET_DELAY_MS = 2000; static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; @@ -20,23 +22,40 @@ static constexpr uint32_t STARTUP_RETRY_MS = 3000; static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; static constexpr uint32_t ACTIVE_STALE_MS = 5000; static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t SOFTWARE_VERSION_READ_TIMEOUT_MS = 2000; +static constexpr uint32_t SOFTWARE_VERSION_RETRY_MS = 30000; +static constexpr uint8_t SOFTWARE_VERSION_MAX_ATTEMPTS = 3; static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; +// After this many consecutive passive poll failures, re-run the reset/startup sequence +static constexpr uint8_t PASSIVE_FAIL_ESCALATE_COUNT = 8; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr std::array READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr std::array GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_DEVICE_ID_INDEX = 2; static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr size_t PASSIVE_WITH_ID_DEVICE_ID_INDEX = 2; +static constexpr uint8_t PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX = 22; +static constexpr uint8_t PASSIVE_WITH_ID_TEMP_FLAG_INDEX = 31; +static constexpr uint8_t PASSIVE_WITH_ID_ST1_INDEX = 35; +static constexpr size_t PASSIVE_WITH_ID_CHECKSUM_INDEX = 37; +static constexpr size_t PASSIVE_WITH_ID_STOP_INDEX = 38; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; @@ -58,8 +77,40 @@ static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; +static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "software version read reuses passive_frame_"); +static constexpr size_t SOFTWARE_VERSION_CHECKSUM_INDEX = 5; +static constexpr size_t SOFTWARE_VERSION_STOP_INDEX = 6; + static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } +static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { + bool all_zero = true; + for (size_t i = 0; i < len; ++i) { + const uint8_t lo = data[i] & 0x0F; + const uint8_t hi = data[i] >> 4; + if (lo > 9 || hi > 9) + return false; + const size_t pos = (len - 1 - i) * 2; + if (hi != 0) + all_zero = false; + out[pos] = static_cast('0' + hi); + if (lo != 0) + all_zero = false; + out[pos + 1] = static_cast('0' + lo); + } + out[len * 2] = '\0'; + return !all_zero; +} + +static bool validate_software_version_response(const uint8_t data[SOFTWARE_VERSION_RESPONSE_SIZE]) { + if (data[0] != COMMAND_ACK || data[SOFTWARE_VERSION_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 1; i < SOFTWARE_VERSION_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[SOFTWARE_VERSION_CHECKSUM_INDEX] == sum; +} + static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { if (data[index] == expected) return true; @@ -84,25 +135,42 @@ static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { return data[21] == sum_bytes(data, 21); } -static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { +#ifdef USE_UFM01_METER_ID +static bool validate_passive_with_id_frame(const uint8_t data[PASSIVE_FRAME_WITH_ID_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2_WITH_ID || + data[PASSIVE_WITH_ID_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < PASSIVE_WITH_ID_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[PASSIVE_WITH_ID_CHECKSUM_INDEX] == sum; +} +#endif + +// Start index of each run in a passive reply; each run maps to the matching run of the active frame +struct PassiveLayout { + uint8_t acc_flow; // flag + 6 bytes, active 8..14 + uint8_t instant_flow; // flag + 5 bytes, active 15..20 + uint8_t temperature; // flag + 3 bytes, active 24..27 + uint8_t status; // ST1, ST2, checksum, active 28..30 +}; +static constexpr PassiveLayout PASSIVE_NO_ID_LAYOUT{2, 9, 15, 19}; +#ifdef USE_UFM01_METER_ID +static constexpr PassiveLayout PASSIVE_WITH_ID_LAYOUT{PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_TEMP_FLAG_INDEX, PASSIVE_WITH_ID_ST1_INDEX}; +#endif + +static void passive_to_active_frame(const uint8_t *passive, const PassiveLayout &layout, uint8_t active[FRAME_SIZE]) { std::memset(active, 0, FRAME_SIZE); active[0] = FRAME_START_BYTE_1; active[1] = FRAME_START_BYTE_2; - active[7] = 0x01; - active[8] = passive[2]; - for (size_t i = 0; i < 6; ++i) - active[9 + i] = passive[3 + i]; - active[15] = passive[9]; - for (size_t i = 0; i < 5; ++i) - active[16 + i] = passive[10 + i]; - active[21] = FRAME_FLAG_RESERVED_SECTION; - active[24] = passive[15]; - for (size_t i = 0; i < 3; ++i) - active[25 + i] = passive[16 + i]; - active[28] = passive[19]; - active[29] = passive[20]; - active[30] = passive[21]; - active[31] = FRAME_STOP_BYTE; + std::copy_n(&passive[layout.acc_flow], 7, &active[FRAME_ACC_FLOW_FLAG_INDEX]); + std::copy_n(&passive[layout.instant_flow], 6, &active[FRAME_INDEX_INSTANT_FLOW_FLAG]); + active[FRAME_INDEX_RESERVED_SECTION] = FRAME_FLAG_RESERVED_SECTION; + std::copy_n(&passive[layout.temperature], 4, &active[FRAME_INDEX_TEMP_FLAG]); + std::copy_n(&passive[layout.status], 3, &active[FRAME_ST1_INDEX]); + active[FRAME_STOP_INDEX] = FRAME_STOP_BYTE; } static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { @@ -118,8 +186,10 @@ static float read_flow(const uint8_t data[FRAME_SIZE]) { M3_PER_L; } +static constexpr size_t LOG_HEX_MAX_SIZE = std::max(FRAME_SIZE, PASSIVE_FRAME_MAX_SIZE); + static void log_hex(const uint8_t *data, size_t len) { - char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + char hex_buf[format_hex_pretty_size(LOG_HEX_MAX_SIZE)]; ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } @@ -157,7 +227,6 @@ void UFM01Component::send_command_no_wait_(const std::array &command this->flush(); } -// Drains whatever is currently in the RX buffer, looking for a command ACK. bool UFM01Component::consume_ack_() { while (this->available()) { uint8_t byte; @@ -170,29 +239,65 @@ bool UFM01Component::consume_ack_() { return false; } -bool UFM01Component::send_command_(const std::array &command) { - this->send_command_no_wait_(command); - const uint32_t start = millis(); - while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->consume_ack_()) - return true; - delay(1); - } - return false; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +bool UFM01Component::can_start_clear_action_() const { + return +#ifdef USE_UFM01_SOFTWARE_VERSION + !this->software_version_read_pending_ && +#endif + (this->operating_mode_ == OperatingMode::ACTIVE_STREAM || + (this->operating_mode_ == OperatingMode::PASSIVE_POLL && !this->passive_read_pending_)); } -bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } +void UFM01Component::request_clear_accumulated_flow() { + if (this->clear_pending_) { + ESP_LOGD(TAG, "Clear accumulated flow already pending"); + return; + } + this->clear_pending_ = true; + this->pending_clear_sent_ = false; + this->pending_clear_start_ms_ = App.get_loop_component_start_time(); +} -bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } +void UFM01Component::finish_pending_clear_() { + this->clear_pending_ = false; + this->pending_clear_sent_ = false; +} -bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } +void UFM01Component::loop_pending_clear_action_(uint32_t now) { + if (!this->clear_pending_) + return; + if (!this->pending_clear_sent_) { + if (!this->can_start_clear_action_()) { + if (now - this->pending_clear_start_ms_ < CLEAR_QUEUE_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow timed out waiting for an idle UART"); + this->finish_pending_clear_(); + return; + } + this->pending_clear_start_ms_ = now; + this->pending_clear_sent_ = true; + this->send_command_no_wait_(CLEAR_ACCUMULATED_FLOW); + return; + } + if (this->consume_ack_()) { + ESP_LOGI(TAG, "Clear accumulated flow acknowledged"); + this->finish_pending_clear_(); + return; + } + if (now - this->pending_clear_start_ms_ < COMMAND_ACK_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow not acknowledged"); + this->finish_pending_clear_(); +} +#endif // USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); this->startup_wait_ms_ = STARTUP_DELAY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, App.get_loop_component_start_time()); } void UFM01Component::dump_config() { @@ -208,9 +313,123 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif +#ifdef USE_UFM01_METER_ID + LOG_TEXT_SENSOR(" ", "Meter ID", this->meter_id_text_sensor_); +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + LOG_TEXT_SENSOR(" ", "Software Version", this->software_version_text_sensor_); +#endif } -void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { +// The binary sensors keep their last state while readings are stale; only flow and temperature go unknown +void UFM01Component::publish_stale_flow_and_temperature_() { +#ifdef USE_SENSOR + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); +#endif +} + +#ifdef USE_UFM01_METER_ID +void UFM01Component::publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]) { + if (this->meter_id_text_sensor_ == nullptr || this->meter_id_published_) + return; + + char meter_id_str[DEVICE_ID_STRING_LENGTH + 1]; + if (!bcd_to_digits(&data[FRAME_DEVICE_ID_INDEX], DEVICE_ID_LENGTH, meter_id_str)) { + if (!this->meter_id_invalid_logged_) { + ESP_LOGW(TAG, "Meter ID in frame is zero or not BCD, leaving the sensor unknown"); + this->meter_id_invalid_logged_ = true; + } + return; + } + + this->meter_id_text_sensor_->publish_state(meter_id_str); + this->meter_id_published_ = true; + ESP_LOGI(TAG, "UFM-01 meter ID: %s", meter_id_str); +} +#endif + +#ifdef USE_UFM01_SOFTWARE_VERSION +void UFM01Component::start_software_version_read_(uint32_t now) { + this->send_command_no_wait_(GET_SOFTWARE_VERSION); + this->passive_index_ = 0; + this->passive_start_ms_ = now; + this->software_version_read_pending_ = true; +} + +ReadResult UFM01Component::continue_software_version_read_(uint32_t now) { + while (this->available() && this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + // The tail of an in-flight active-stream frame can arrive before the reply + if (this->passive_index_ == 0 && byte != COMMAND_ACK) + continue; + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + if (now - this->passive_start_ms_ < SOFTWARE_VERSION_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "software version read timeout (%u/%u bytes)", this->passive_index_, + static_cast(SOFTWARE_VERSION_RESPONSE_SIZE)); + return ReadResult::READ_RESULT_FAILURE; + } + + if (!validate_software_version_response(this->passive_frame_)) { + log_hex(this->passive_frame_, SOFTWARE_VERSION_RESPONSE_SIZE); + ESP_LOGW(TAG, "invalid software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_) { + char version_str[SOFTWARE_VERSION_STRING_LENGTH + 1]; + if (!bcd_to_digits(&this->passive_frame_[1], SOFTWARE_VERSION_LENGTH, version_str)) { + ESP_LOGW(TAG, "invalid BCD data in software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + this->software_version_text_sensor_->publish_state(version_str); + this->software_version_published_ = true; + ESP_LOGI(TAG, "UFM-01 software version: %s", version_str); + } + + return ReadResult::READ_RESULT_SUCCESS; +} + +void UFM01Component::finish_software_version_read_(ReadResult result, uint32_t now) { + this->software_version_read_pending_ = false; + this->last_software_version_attempt_ms_ = now; + if (result == ReadResult::READ_RESULT_FAILURE && + ++this->software_version_failures_ == SOFTWARE_VERSION_MAX_ATTEMPTS) { + ESP_LOGW(TAG, "Software version not read after %u attempts, giving up", SOFTWARE_VERSION_MAX_ATTEMPTS); + } +} + +// Returns true while a software version read owns the UART, so the caller skips its own work +bool UFM01Component::service_software_version_(uint32_t now) { + if (this->software_version_read_pending_) { + const ReadResult result = this->continue_software_version_read_(now); + if (result != ReadResult::READ_RESULT_PENDING) + this->finish_software_version_read_(result, now); + return true; + } + if (this->software_version_text_sensor_ == nullptr || this->software_version_published_ || + this->software_version_failures_ >= SOFTWARE_VERSION_MAX_ATTEMPTS || this->passive_read_pending_) + return false; + if (this->last_software_version_attempt_ms_ != 0 && + now - this->last_software_version_attempt_ms_ < SOFTWARE_VERSION_RETRY_MS) + return false; + this->start_software_version_read_(now); + return true; +} +#endif // USE_UFM01_SOFTWARE_VERSION + +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) { +#ifdef USE_UFM01_METER_ID + this->publish_meter_id_from_frame_(data); +#endif bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -229,10 +448,7 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); if (empty_tube) { - if (this->flow_sensor_ != nullptr) - this->flow_sensor_->publish_state(NAN); - if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(NAN); + this->publish_stale_flow_and_temperature_(); } else { if (this->flow_sensor_ != nullptr) this->flow_sensor_->publish_state(read_flow(data)); @@ -240,12 +456,12 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif - this->last_valid_frame_ms_ = millis(); + this->last_valid_frame_ms_ = now; this->status_clear_warning(); this->status_clear_error(); } -bool UFM01Component::process_active_stream_() { +bool UFM01Component::process_active_stream_(uint32_t now) { bool got_valid_frame = false; while (this->available()) { @@ -264,7 +480,7 @@ bool UFM01Component::process_active_stream_() { continue; if (validate_active_frame(this->data_)) { - this->on_active_frame_(this->data_); + this->on_active_frame_(this->data_, now); this->read_index_ = 0; got_valid_frame = true; continue; @@ -287,33 +503,89 @@ bool UFM01Component::process_active_stream_() { return got_valid_frame; } -void UFM01Component::set_startup_phase_(StartupPhase phase) { - this->startup_phase_ = phase; - this->phase_start_ms_ = millis(); -} - -void UFM01Component::enter_active_stream_(const char *reason) { - ESP_LOGI(TAG, "UFM-01 active stream %s", reason); - this->operating_mode_ = OperatingMode::ACTIVE_STREAM; +void UFM01Component::set_mode_(OperatingMode mode, uint32_t now) { + this->operating_mode_ = mode; + this->phase_start_ms_ = now; this->passive_read_pending_ = false; +#ifdef USE_UFM01_SOFTWARE_VERSION + this->software_version_read_pending_ = false; +#endif + this->consecutive_passive_failures_ = 0; } -void UFM01Component::start_passive_read_() { - this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); +void UFM01Component::set_startup_phase_(StartupPhase phase, uint32_t now) { + this->startup_phase_ = phase; + this->phase_start_ms_ = now; +} + +void UFM01Component::send_reset_(const LogString *status, uint32_t now) { + this->status_set_warning(status); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); +} + +void UFM01Component::enter_active_stream_(const LogString *reason, uint32_t now) { + ESP_LOGI(TAG, "UFM-01 active stream %s", LOG_STR_ARG(reason)); + this->set_mode_(OperatingMode::ACTIVE_STREAM, now); +} + +void UFM01Component::enter_passive_from_stale_(uint32_t now) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->publish_stale_flow_and_temperature_(); + // Flushes leftover stream bytes first; the ACK is not awaited, the next command's flush discards it + this->send_command_no_wait_(PASSIVE_MODE); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; + this->status_set_warning(LOG_STR("UFM-01 passive poll")); +} + +void UFM01Component::restart_startup_(const LogString *reason, uint32_t now) { + ESP_LOGW(TAG, "%s, re-initializing UFM-01", LOG_STR_ARG(reason)); + this->publish_stale_flow_and_temperature_(); + this->set_mode_(OperatingMode::STARTUP, now); + this->send_reset_(LOG_STR("re-initializing UFM-01"), now); +} + +void UFM01Component::note_passive_poll_result_(bool success, uint32_t now) { + if (success) { + this->consecutive_passive_failures_ = 0; + return; + } + this->status_set_warning(LOG_STR("UFM-01 passive poll failed")); + if (++this->consecutive_passive_failures_ < PASSIVE_FAIL_ESCALATE_COUNT) + return; + this->restart_startup_(LOG_STR("Passive poll failed repeatedly"), now); +} + +void UFM01Component::start_passive_read_(uint32_t now) { +#ifdef USE_UFM01_METER_ID + if (this->meter_id_text_sensor_ != nullptr && !this->meter_id_published_) { + this->passive_expected_size_ = PASSIVE_FRAME_WITH_ID_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2_WITH_ID; + this->send_command_no_wait_(READ_SENSOR_DATA_WITH_ID); + } else +#endif + { + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + } this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_start_ms_ = now; } -// Accumulates the reply to a passive read request across loop iterations. -PassiveReadResult UFM01Component::continue_passive_read_() { - while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { +ReadResult UFM01Component::continue_passive_read_(uint32_t now) { + const uint8_t expected_size = this->passive_expected_size_; + + while (this->available() && this->passive_index_ < expected_size) { uint8_t byte; if (!this->read_byte(&byte)) break; if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) continue; - if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + if (this->passive_index_ == 1 && byte != this->passive_start_byte_2_) { // The mismatched byte may itself be the start of the real frame this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; continue; @@ -321,57 +593,69 @@ PassiveReadResult UFM01Component::continue_passive_read_() { this->passive_frame_[this->passive_index_++] = byte; } - if (this->passive_index_ < PASSIVE_FRAME_SIZE) { - if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) - return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; - ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; - } - - if (!validate_passive_frame(this->passive_frame_)) { - log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); - ESP_LOGW(TAG, "invalid passive frame"); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; + if (this->passive_index_ < expected_size) { + if (now - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "passive read timeout (%u/%u bytes)", this->passive_index_, expected_size); + return ReadResult::READ_RESULT_FAILURE; } uint8_t active_frame[FRAME_SIZE]; - passive_no_id_to_active_frame(this->passive_frame_, active_frame); - this->on_active_frame_(active_frame); - return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; +#ifdef USE_UFM01_METER_ID + if (expected_size == PASSIVE_FRAME_WITH_ID_SIZE) { + if (!validate_passive_with_id_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_WITH_ID_SIZE); + ESP_LOGW(TAG, "invalid passive frame with ID"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_WITH_ID_LAYOUT, active_frame); + // Device ID and the byte after it + std::copy_n(&this->passive_frame_[PASSIVE_WITH_ID_DEVICE_ID_INDEX], DEVICE_ID_LENGTH + 1, + &active_frame[FRAME_DEVICE_ID_INDEX]); + } else +#endif + { + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_NO_ID_LAYOUT, active_frame); + active_frame[FRAME_DEVICE_ID_INDEX + DEVICE_ID_LENGTH] = 0x01; + } + this->on_active_frame_(active_frame, now); + return ReadResult::READ_RESULT_SUCCESS; } -void UFM01Component::loop_startup_() { - const uint32_t elapsed = millis() - this->phase_start_ms_; +void UFM01Component::loop_startup_(uint32_t now) { + const uint32_t elapsed = now - this->phase_start_ms_; switch (this->startup_phase_) { case StartupPhase::WAIT: // Pick up an already-streaming device without resetting it - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < this->startup_wait_ms_) return; ESP_LOGD(TAG, "Running startup sequence"); - this->status_set_warning("initializing UFM-01"); - this->reset_retried_ = false; - this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->send_reset_(LOG_STR("initializing UFM-01"), now); return; case StartupPhase::RESET_WAIT_ACK: if (this->consume_ack_()) { - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); return; } if (elapsed < COMMAND_ACK_TIMEOUT_MS) return; if (!this->reset_retried_) { ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); - this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT, now); } else { ESP_LOGW(TAG, "Reset failed during startup"); - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); } return; @@ -380,91 +664,138 @@ void UFM01Component::loop_startup_() { return; this->reset_retried_ = true; this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); return; case StartupPhase::POST_RESET_WAIT: if (elapsed < POST_RESET_DELAY_MS) return; +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_ && + this->software_version_failures_ < SOFTWARE_VERSION_MAX_ATTEMPTS) { + this->start_software_version_read_(now); + this->set_startup_phase_(StartupPhase::SOFTWARE_VERSION_WAIT_REPLY, now); + return; + } +#endif this->send_command_no_wait_(ACTIVE_MODE); - this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); return; +#ifdef USE_UFM01_SOFTWARE_VERSION + case StartupPhase::SOFTWARE_VERSION_WAIT_REPLY: { + const ReadResult result = this->continue_software_version_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) + return; + if (result == ReadResult::READ_RESULT_SUCCESS) { + ESP_LOGD(TAG, "Software version read during startup"); + } else { + ESP_LOGW(TAG, "Software version read failed during startup, continuing"); + } + this->finish_software_version_read_(result, now); + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); + return; + } +#endif + case StartupPhase::ACTIVE_WAIT_FRAME: // The command ACK (0xE5) is consumed by the frame parser as noise - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) return; - this->start_passive_read_(); - this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + this->send_command_no_wait_(PASSIVE_MODE); + this->set_startup_phase_(StartupPhase::SET_PASSIVE_WAIT_ACK, now); + return; + + case StartupPhase::SET_PASSIVE_WAIT_ACK: + if (this->consume_ack_()) { + ESP_LOGD(TAG, "Passive mode acknowledged during startup"); + } else if (elapsed < COMMAND_ACK_TIMEOUT_MS) { + return; + } else { + ESP_LOGW(TAG, "SET_PASSIVE_MODE not acknowledged during startup, continuing"); + } + this->start_passive_read_(now); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY, now); return; case StartupPhase::PASSIVE_WAIT_REPLY: - switch (this->continue_passive_read_()) { - case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: + switch (this->continue_passive_read_(now)) { + case ReadResult::READ_RESULT_PENDING: return; - case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: + case ReadResult::READ_RESULT_SUCCESS: ESP_LOGI(TAG, "UFM-01 using passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = millis(); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; return; - case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: + case ReadResult::READ_RESULT_FAILURE: ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); this->startup_wait_ms_ = STARTUP_RETRY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, now); return; } } } -void UFM01Component::loop_active_stream_() { - this->process_active_stream_(); - if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { - ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = 0; - this->status_set_warning("UFM-01 passive poll"); - } +void UFM01Component::loop_active_stream_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + + this->process_active_stream_(now); + + if (this->last_valid_frame_ms_ != 0 && now - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) + this->enter_passive_from_stale_(now); } -void UFM01Component::loop_passive_poll_() { +void UFM01Component::loop_passive_poll_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + if (this->passive_read_pending_) { - const PassiveReadResult result = this->continue_passive_read_(); - if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) + const ReadResult result = this->continue_passive_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) return; this->passive_read_pending_ = false; - if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) - this->status_set_warning("UFM-01 passive poll failed"); + this->note_passive_poll_result_(result == ReadResult::READ_RESULT_SUCCESS, now); return; } - if (this->process_active_stream_()) { - this->enter_active_stream_("resumed"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("resumed"), now); return; } - if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { - this->last_poll_ms_ = millis(); - this->start_passive_read_(); + if (now - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = now; + this->start_passive_read_(now); this->passive_read_pending_ = true; } } void UFM01Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + this->loop_pending_clear_action_(now); + if (this->clear_pending_ && this->pending_clear_sent_) + return; +#endif switch (this->operating_mode_) { case OperatingMode::STARTUP: - this->loop_startup_(); + this->loop_startup_(now); return; case OperatingMode::ACTIVE_STREAM: - this->loop_active_stream_(); + this->loop_active_stream_(now); return; case OperatingMode::PASSIVE_POLL: - this->loop_passive_poll_(); + this->loop_passive_poll_(now); return; } } diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index 0a39dcc9afd..cf73daa5fa7 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -8,6 +8,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include "esphome/components/uart/uart.h" #include @@ -23,6 +26,17 @@ class TestableUFM01; static constexpr size_t FRAME_SIZE = 32; static constexpr size_t PASSIVE_FRAME_SIZE = 23; +static constexpr size_t PASSIVE_FRAME_WITH_ID_SIZE = 39; +#ifdef USE_UFM01_METER_ID +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_WITH_ID_SIZE; +#else +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_SIZE; +#endif +static constexpr size_t SOFTWARE_VERSION_RESPONSE_SIZE = 7; +static constexpr size_t DEVICE_ID_LENGTH = 5; +static constexpr size_t DEVICE_ID_STRING_LENGTH = 10; +static constexpr size_t SOFTWARE_VERSION_LENGTH = 4; +static constexpr size_t SOFTWARE_VERSION_STRING_LENGTH = 8; enum class OperatingMode : uint8_t { STARTUP = 0, @@ -31,18 +45,22 @@ enum class OperatingMode : uint8_t { }; enum class StartupPhase : uint8_t { - WAIT = 0, - RESET_WAIT_ACK = 1, - RESET_RETRY_WAIT = 2, - POST_RESET_WAIT = 3, - ACTIVE_WAIT_FRAME = 4, - PASSIVE_WAIT_REPLY = 5, + WAIT, + RESET_WAIT_ACK, + RESET_RETRY_WAIT, + POST_RESET_WAIT, +#ifdef USE_UFM01_SOFTWARE_VERSION + SOFTWARE_VERSION_WAIT_REPLY, +#endif + ACTIVE_WAIT_FRAME, + SET_PASSIVE_WAIT_ACK, + PASSIVE_WAIT_REPLY, }; -enum class PassiveReadResult : uint8_t { - PASSIVE_READ_RESULT_PENDING = 0, - PASSIVE_READ_RESULT_SUCCESS = 1, - PASSIVE_READ_RESULT_FAILURE = 2, +enum class ReadResult : uint8_t { + READ_RESULT_PENDING = 0, + READ_RESULT_SUCCESS = 1, + READ_RESULT_FAILURE = 2, }; class UFM01Component : public uart::UARTDevice, public Component { @@ -59,6 +77,13 @@ class UFM01Component : public uart::UARTDevice, public Component { SUB_BINARY_SENSOR(flow_rate_out_of_range) #endif +#ifdef USE_UFM01_METER_ID + SUB_TEXT_SENSOR(meter_id) +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + SUB_TEXT_SENSOR(software_version) +#endif + public: void setup() override; @@ -66,43 +91,85 @@ class UFM01Component : public uart::UARTDevice, public Component { void loop() override; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + /// Queue a clear of the accumulated flow; sent once the UART is idle, a request while one is pending joins it + void request_clear_accumulated_flow(); +#endif + float get_setup_priority() const override; - protected: - bool clear_accumulated_flow_(); - bool set_active_mode_(); - bool reset_device_(); - private: - bool send_command_(const std::array &command); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool can_start_clear_action_() const; + void loop_pending_clear_action_(uint32_t now); + void finish_pending_clear_(); +#endif void send_command_no_wait_(const std::array &command); bool consume_ack_(); void flush_rx_(); - bool process_active_stream_(); - void on_active_frame_(uint8_t data[FRAME_SIZE]); + bool process_active_stream_(uint32_t now); + void on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now); +#ifdef USE_UFM01_METER_ID + void publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]); +#endif + void publish_stale_flow_and_temperature_(); +#ifdef USE_UFM01_SOFTWARE_VERSION + void start_software_version_read_(uint32_t now); + ReadResult continue_software_version_read_(uint32_t now); + void finish_software_version_read_(ReadResult result, uint32_t now); + bool service_software_version_(uint32_t now); +#endif - void loop_startup_(); - void loop_active_stream_(); - void loop_passive_poll_(); - void set_startup_phase_(StartupPhase phase); - void enter_active_stream_(const char *reason); - void start_passive_read_(); - PassiveReadResult continue_passive_read_(); + void loop_startup_(uint32_t now); + void loop_active_stream_(uint32_t now); + void loop_passive_poll_(uint32_t now); + void set_mode_(OperatingMode mode, uint32_t now); + void set_startup_phase_(StartupPhase phase, uint32_t now); + void send_reset_(const LogString *status, uint32_t now); + void enter_active_stream_(const LogString *reason, uint32_t now); + void enter_passive_from_stale_(uint32_t now); + void restart_startup_(const LogString *reason, uint32_t now); + void start_passive_read_(uint32_t now); + ReadResult continue_passive_read_(uint32_t now); + void note_passive_poll_result_(bool success, uint32_t now); + + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + // Shared by the passive read and the software version read, which never overlap + uint32_t passive_start_ms_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + uint32_t last_software_version_attempt_ms_{0}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + uint32_t pending_clear_start_ms_{0}; +#endif + int32_t read_index_{0}; OperatingMode operating_mode_{OperatingMode::STARTUP}; StartupPhase startup_phase_{StartupPhase::WAIT}; - uint32_t phase_start_ms_{0}; - uint32_t startup_wait_ms_{0}; bool reset_retried_{false}; - uint32_t last_valid_frame_ms_{0}; - uint32_t last_poll_ms_{0}; - bool passive_read_pending_{false}; - uint32_t passive_start_ms_{0}; - size_t passive_index_{0}; - uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; + uint8_t consecutive_passive_failures_{0}; + uint8_t passive_index_{0}; + uint8_t passive_expected_size_{PASSIVE_FRAME_SIZE}; + uint8_t passive_start_byte_2_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + bool software_version_published_{false}; + bool software_version_read_pending_{false}; + uint8_t software_version_failures_{0}; +#endif +#ifdef USE_UFM01_METER_ID + bool meter_id_published_{false}; + bool meter_id_invalid_logged_{false}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool clear_pending_{false}; + bool pending_clear_sent_{false}; +#endif - int32_t read_index_ = 0; + uint8_t passive_frame_[PASSIVE_FRAME_MAX_SIZE]; uint8_t data_[FRAME_SIZE]; friend class testing::TestableUFM01; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 552221dd5eb..730e17fb53a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -248,6 +248,9 @@ #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX +#define USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +#define USE_UFM01_METER_ID +#define USE_UFM01_SOFTWARE_VERSION #define USE_UPDATE #define USE_UPTIME_TIMESTAMP #define USE_VALVE diff --git a/tests/components/ufm01/__init__.py b/tests/components/ufm01/__init__.py new file mode 100644 index 00000000000..19febacc61a --- /dev/null +++ b/tests/components/ufm01/__init__.py @@ -0,0 +1,12 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The optional features are define-gated; emit them so the tests cover those paths + async def to_code_testing(config): + cg.add_define("USE_UFM01_METER_ID") + cg.add_define("USE_UFM01_SOFTWARE_VERSION") + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + manifest.to_code = to_code_testing diff --git a/tests/components/ufm01/benchmark.yaml b/tests/components/ufm01/benchmark.yaml new file mode 100644 index 00000000000..f78cdfe0334 --- /dev/null +++ b/tests/components/ufm01/benchmark.yaml @@ -0,0 +1,29 @@ +# Defines USE_SENSOR; the tests attach their own instances +sensor: + - platform: template + id: bench_flow + name: "Flow" + - platform: template + id: bench_temperature + name: "Temperature" + +# Defines USE_TEXT_SENSOR +text_sensor: + - platform: template + id: bench_meter_id + name: "Meter ID" + - platform: template + id: bench_software_version + name: "Software version" + +# One instance so __init__.py's to_code runs and emits the feature defines +uart: + - id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null + +ufm01: + - id: ufm01_bench + uart_id: uart_bus diff --git a/tests/components/ufm01/common.h b/tests/components/ufm01/common.h index 1582358700c..e04e09b765c 100644 --- a/tests/components/ufm01/common.h +++ b/tests/components/ufm01/common.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -9,17 +10,36 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/ufm01/ufm01.h" +#include "esphome/core/application.h" +#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::ufm01::testing { static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; static constexpr uint8_t FRAME_FLAG_INSTANT_FLOW = 0x0B; static constexpr uint8_t FRAME_FLAG_RESERVED_SECTION = 0x0C; static constexpr uint8_t FRAME_FLAG_TEMP = 0x0D; static constexpr uint8_t COMMAND_ACK = 0xE5; +// Matches PASSIVE_FAIL_ESCALATE_COUNT in ufm01.cpp +static constexpr uint8_t ESCALATE_COUNT = 8; + +using Command = std::array; +static constexpr Command ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr Command PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; +static constexpr Command CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; +static constexpr Command RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; +static constexpr Command READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr Command READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr Command GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // UART mock with a byte queue for read-side simulation. class QueuedMockUART : public uart::UARTComponent { @@ -31,6 +51,10 @@ class QueuedMockUART : public uart::UARTComponent { this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); } + template void enqueue(const std::array &data) { + this->rx_queue.insert(this->rx_queue.end(), data.begin(), data.end()); + } + void enqueue(std::initializer_list data) { for (uint8_t byte : data) this->rx_queue.push_back(byte); @@ -71,15 +95,37 @@ class TestableUFM01 : public UFM01Component { public: void set_mock_uart(QueuedMockUART *uart) { this->set_uart_parent(uart); } - bool process_active_stream() { return this->process_active_stream_(); } + static uint32_t now() { return App.get_loop_component_start_time(); } - PassiveReadResult continue_passive_read() { return this->continue_passive_read_(); } + bool process_active_stream() { return this->process_active_stream_(now()); } + + ReadResult continue_passive_read() { return this->continue_passive_read_(now()); } bool consume_ack() { return this->consume_ack_(); } - void start_passive_read() { this->start_passive_read_(); } + void start_passive_read() { this->start_passive_read_(now()); } - void loop_startup() { this->loop_startup_(); } + void loop_startup() { this->loop_startup_(now()); } + + void loop_active_stream() { this->loop_active_stream_(now()); } + + void loop_passive_poll() { this->loop_passive_poll_(now()); } + + void loop_pending_clear_action() { this->loop_pending_clear_action_(now()); } + + bool clear_pending() const { return this->clear_pending_; } + + bool pending_clear_sent() const { return this->pending_clear_sent_; } + + bool meter_id_published() const { return this->meter_id_published_; } + + void start_software_version_read() { this->start_software_version_read_(now()); } + + ReadResult continue_software_version_read() { return this->continue_software_version_read_(now()); } + + bool software_version_published() const { return this->software_version_published_; } + + void set_last_software_version_attempt_ms(uint32_t ms) { this->last_software_version_attempt_ms_ = ms; } OperatingMode operating_mode() const { return this->operating_mode_; } @@ -87,20 +133,52 @@ class TestableUFM01 : public UFM01Component { int32_t read_index() const { return this->read_index_; } - size_t passive_index() const { return this->passive_index_; } + uint8_t passive_index() const { return this->passive_index_; } uint32_t last_valid_frame_ms() const { return this->last_valid_frame_ms_; } + uint8_t consecutive_passive_failures() const { return this->consecutive_passive_failures_; } + + void set_operating_mode(OperatingMode mode) { this->operating_mode_ = mode; } + + // Expects a reply without the meter ID, as start_passive_read_() picks when no meter ID sensor is set void prepare_passive_read() { this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->passive_start_ms_ = now(); + } + + void begin_pending_passive_read() { + this->prepare_passive_read(); + this->passive_read_pending_ = true; } void init_wait_phase() { this->operating_mode_ = OperatingMode::STARTUP; this->startup_phase_ = StartupPhase::WAIT; this->startup_wait_ms_ = 60000; - this->phase_start_ms_ = millis(); + this->phase_start_ms_ = now(); + } + + void begin_active_stream() { + this->operating_mode_ = OperatingMode::ACTIVE_STREAM; + this->last_valid_frame_ms_ = now(); + this->passive_read_pending_ = false; + this->read_index_ = 0; + } + + void begin_active_wait_frame() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::ACTIVE_WAIT_FRAME; + this->phase_start_ms_ = now(); + } + + void begin_post_reset_wait() { + this->operating_mode_ = OperatingMode::STARTUP; + this->startup_phase_ = StartupPhase::POST_RESET_WAIT; + this->phase_start_ms_ = now(); + this->software_version_published_ = false; } void reset_state() { @@ -108,24 +186,59 @@ class TestableUFM01 : public UFM01Component { this->last_valid_frame_ms_ = 0; this->passive_index_ = 0; this->passive_read_pending_ = false; + this->operating_mode_ = OperatingMode::STARTUP; + this->consecutive_passive_failures_ = 0; + this->meter_id_published_ = false; + this->software_version_published_ = false; + this->software_version_read_pending_ = false; + this->software_version_failures_ = 0; + this->meter_id_invalid_logged_ = false; + this->last_software_version_attempt_ms_ = 0; + this->startup_phase_ = StartupPhase::WAIT; + this->clear_pending_ = false; + this->pending_clear_sent_ = false; } }; +template inline uint8_t checksum(const std::array &data, size_t begin, size_t end) { + uint8_t sum = 0; + for (size_t i = begin; i < end; ++i) + sum += data[i]; + return sum; +} + inline std::array make_active_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; frame[1] = FRAME_START_BYTE_2; + // Datasheet example device ID 2307140001 + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; frame[15] = FRAME_FLAG_INSTANT_FLOW; frame[21] = FRAME_FLAG_RESERVED_SECTION; frame[24] = FRAME_FLAG_TEMP; frame[31] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 30; ++i) - sum += frame[i]; - frame[30] = sum; + frame[30] = checksum(frame, 0, 30); return frame; } +inline std::array make_software_version_response() { + // Datasheet example version 23380315 + std::array response{}; + response[0] = COMMAND_ACK; + response[1] = 0x15; + response[2] = 0x03; + response[3] = 0x38; + response[4] = 0x23; + response[5] = checksum(response, 1, 5); + response[6] = FRAME_STOP_BYTE; + return response; +} + inline std::array make_passive_frame() { std::array frame{}; frame[0] = FRAME_START_BYTE_1; @@ -133,22 +246,93 @@ inline std::array make_passive_frame() { frame[9] = FRAME_FLAG_INSTANT_FLOW; frame[15] = FRAME_FLAG_TEMP; frame[22] = FRAME_STOP_BYTE; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += frame[i]; - frame[21] = sum; + frame[21] = checksum(frame, 0, 21); + return frame; +} + +inline std::array make_passive_with_id_frame() { + std::array frame{}; + frame[0] = FRAME_START_BYTE_1; + frame[1] = PASSIVE_START_BYTE_2_WITH_ID; + frame[2] = 0x01; + frame[3] = 0x00; + frame[4] = 0x14; + frame[5] = 0x07; + frame[6] = 0x23; + frame[7] = 0x01; + frame[8] = 0x0A; + // Non-measurement bytes ensure a shifted instant-flow offset is detected. + for (size_t i = 17; i < 22; ++i) + frame[i] = 0x99; + frame[22] = FRAME_FLAG_INSTANT_FLOW; + frame[23] = 0x34; + frame[24] = 0x12; + frame[27] = 0x00; + frame[31] = FRAME_FLAG_TEMP; + frame[32] = 0x50; + frame[33] = 0x23; + frame[38] = FRAME_STOP_BYTE; + frame[37] = checksum(frame, 0, 37); return frame; } class UFM01Test : public ::testing::Test { protected: + // Non-zero so a frame received at the start time still counts as a valid timestamp + static constexpr uint32_t START_MS = 1000; + void SetUp() override { this->mock_uart_.clear_rx(); this->mock_uart_.written_data.clear(); this->ufm01_.set_mock_uart(&this->mock_uart_); this->ufm01_.reset_state(); + this->set_clock(START_MS); } + // Publishes the time the component reads as the loop clock + void set_clock(uint32_t ms) { + this->now_ = ms; + LoopBlockingGuard clock{nullptr, nullptr, ms}; + } + + void advance(uint32_t ms) { this->set_clock(this->now_ + ms); } + + void expect_written(const Command &command) { + EXPECT_EQ(this->mock_uart_.written_data, std::vector(command.begin(), command.end())); + } + + void fail_passive_poll() { + this->ufm01_.begin_pending_passive_read(); + this->advance(2000); + this->ufm01_.loop_passive_poll(); + } + + void make_active_stream_stale() { + this->ufm01_.begin_active_stream(); + this->advance(6000); + } + +#ifdef USE_SENSOR + void attach_flow_and_temperature_sensors() { + this->ufm01_.set_flow_sensor(&this->flow_sensor_); + this->ufm01_.set_temperature_sensor(&this->temperature_sensor_); + this->flow_sensor_.publish_state(1.23f); + this->temperature_sensor_.publish_state(20.5f); + } + + sensor::Sensor flow_sensor_; + sensor::Sensor temperature_sensor_; +#endif + + void attach_diagnostic_text_sensors() { + this->ufm01_.set_meter_id_text_sensor(&this->meter_id_text_sensor_); + this->ufm01_.set_software_version_text_sensor(&this->software_version_text_sensor_); + } + + text_sensor::TextSensor meter_id_text_sensor_; + text_sensor::TextSensor software_version_text_sensor_; + + uint32_t now_{0}; QueuedMockUART mock_uart_; TestableUFM01 ufm01_; }; diff --git a/tests/components/ufm01/common.yaml b/tests/components/ufm01/common.yaml index c818dc29651..29bc05dfabd 100644 --- a/tests/components/ufm01/common.yaml +++ b/tests/components/ufm01/common.yaml @@ -28,3 +28,17 @@ binary_sensor: flow_rate_out_of_range: id: flow_rate_out_of_range name: "Flow rate out of range" + +text_sensor: + - platform: ufm01 + meter_id: + id: ufm01_meter_id + name: "Meter ID" + software_version: + id: ufm01_software_version + name: "Software version" + +script: + - id: ufm01_clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_component diff --git a/tests/components/ufm01/test.esp32-idf.yaml b/tests/components/ufm01/test.esp32-idf.yaml index 34041cc2237..a2d4469798c 100644 --- a/tests/components/ufm01/test.esp32-idf.yaml +++ b/tests/components/ufm01/test.esp32-idf.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp32-idf.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.esp8266-ard.yaml b/tests/components/ufm01/test.esp8266-ard.yaml index 195f4b41b59..7826cdc44a0 100644 --- a/tests/components/ufm01/test.esp8266-ard.yaml +++ b/tests/components/ufm01/test.esp8266-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/esp8266-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/test.rp2040-ard.yaml b/tests/components/ufm01/test.rp2040-ard.yaml index 13b3284fe30..1483674ddcf 100644 --- a/tests/components/ufm01/test.rp2040-ard.yaml +++ b/tests/components/ufm01/test.rp2040-ard.yaml @@ -1,4 +1,3 @@ packages: uart_2400_even: !include ../../test_build_components/common/uart_2400_even/rp2040-ard.yaml - -<<: !include common.yaml + ufm01: !include common.yaml diff --git a/tests/components/ufm01/ufm01_clear_action_test.cpp b/tests/components/ufm01/ufm01_clear_action_test.cpp new file mode 100644 index 00000000000..a1d10b1926d --- /dev/null +++ b/tests/components/ufm01/ufm01_clear_action_test.cpp @@ -0,0 +1,113 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ClearAccumulatedFlowSendsCommand) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); + EXPECT_TRUE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnAck) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.enqueue({COMMAND_ACK}); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, ClearAccumulatedFlowFinishesOnTimeout) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->advance(400); + this->ufm01_.loop_pending_clear_action(); + ASSERT_TRUE(this->ufm01_.clear_pending()); + + this->advance(200); + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); +} + +TEST_F(UFM01Test, DuplicateClearRequestJoinsThePendingOne) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->mock_uart_.written_data.clear(); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->ufm01_.loop_pending_clear_action(); + EXPECT_FALSE(this->ufm01_.clear_pending()); + + this->ufm01_.request_clear_accumulated_flow(); + this->ufm01_.loop_pending_clear_action(); + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingPassiveRead) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.loop_passive_poll(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsForPendingSoftwareVersionRead) { + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.start_software_version_read(); + this->mock_uart_.written_data.clear(); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->mock_uart_.enqueue(make_software_version_response()); + this->ufm01_.loop_active_stream(); + this->ufm01_.loop_pending_clear_action(); + + this->expect_written(CLEAR_ACCUMULATED_FLOW); +} + +TEST_F(UFM01Test, ClearWaitsDuringStartup) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_TRUE(this->ufm01_.clear_pending()); + EXPECT_FALSE(this->ufm01_.pending_clear_sent()); +} + +TEST_F(UFM01Test, ClearTimesOutWhileWaitingForIdle) { + this->ufm01_.set_operating_mode(OperatingMode::STARTUP); + this->ufm01_.request_clear_accumulated_flow(); + this->advance(20000); + + this->ufm01_.loop_pending_clear_action(); + + EXPECT_FALSE(this->ufm01_.clear_pending()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_diagnostics_test.cpp b/tests/components/ufm01/ufm01_diagnostics_test.cpp new file mode 100644 index 00000000000..5898b565633 --- /dev/null +++ b/tests/components/ufm01/ufm01_diagnostics_test.cpp @@ -0,0 +1,173 @@ +#include "common.h" + +namespace esphome::ufm01::testing { + +TEST_F(UFM01Test, ActiveFramePublishesMeterIdOnce) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + + this->mock_uart_.enqueue(frame); + EXPECT_TRUE(this->ufm01_.process_active_stream()); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, PassiveFrameWithIdUsesDocumentedMeasurementOffsets) { + this->attach_diagnostic_text_sensors(); + this->attach_flow_and_temperature_sensors(); + this->ufm01_.start_passive_read(); + this->expect_written(READ_SENSOR_DATA_WITH_ID); + + this->mock_uart_.enqueue(make_passive_with_id_frame()); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->meter_id_text_sensor_.state.c_str(), "2307140001"); + EXPECT_NEAR(this->flow_sensor_.get_state(), 0.01234f, 0.000001f); + EXPECT_NEAR(this->temperature_sensor_.get_state(), 23.5f, 0.001f); +} +#endif + +TEST_F(UFM01Test, InvalidPassiveFrameWithIdIsRejected) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_passive_read(); + auto frame = make_passive_with_id_frame(); + frame[37] ^= 0xFF; + this->mock_uart_.enqueue(frame); + + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.meter_id_published()); +} + +TEST_F(UFM01Test, SoftwareVersionResponseDecoded) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, SoftwareVersionReadSkipsLeftoverStreamBytes) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + // Tail of an active frame that was still on the wire when the command was sent + auto frame = make_active_frame(); + this->mock_uart_.enqueue(std::vector(frame.begin() + 20, frame.end())); + this->mock_uart_.enqueue(make_software_version_response()); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_SUCCESS); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, InvalidSoftwareVersionBcdFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + auto response = make_software_version_response(); + response[2] = 0xFA; + response[5] = checksum(response, 1, 5); + this->mock_uart_.enqueue(response); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, SoftwareVersionReadTimeoutFails) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.start_software_version_read(); + this->advance(1900); + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_PENDING); + + this->advance(100); + + EXPECT_EQ(this->ufm01_.continue_software_version_read(), ReadResult::READ_RESULT_FAILURE); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, StartupReadsSoftwareVersionBeforeActiveMode) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.begin_post_reset_wait(); + this->advance(3000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SOFTWARE_VERSION_WAIT_REPLY); + this->expect_written(GET_SOFTWARE_VERSION); + + this->mock_uart_.enqueue(make_software_version_response()); + this->mock_uart_.written_data.clear(); + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::ACTIVE_WAIT_FRAME); + this->expect_written(ACTIVE_MODE); + EXPECT_TRUE(this->ufm01_.software_version_published()); + EXPECT_STREQ(this->software_version_text_sensor_.state.c_str(), "23380315"); +} + +TEST_F(UFM01Test, AlreadyActiveStartupRequestsSoftwareVersion) { + this->attach_diagnostic_text_sensors(); + this->mock_uart_.enqueue(make_active_frame()); + this->ufm01_.init_wait_phase(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::ACTIVE_STREAM); + EXPECT_TRUE(this->ufm01_.meter_id_published()); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamRetriesSoftwareVersionRead) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + this->ufm01_.set_last_software_version_attempt_ms(this->now_); + this->advance(29000); + this->ufm01_.loop_active_stream(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + + this->advance(2000); + this->ufm01_.loop_active_stream(); + + this->expect_written(GET_SOFTWARE_VERSION); +} + +TEST_F(UFM01Test, ActiveStreamGivesUpSoftwareVersionAfterRepeatedFailures) { + this->attach_diagnostic_text_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::ACTIVE_STREAM); + for (int attempt = 0; attempt < 3; ++attempt) { + this->ufm01_.loop_active_stream(); + this->expect_written(GET_SOFTWARE_VERSION); + this->mock_uart_.written_data.clear(); + this->advance(2000); + this->ufm01_.loop_active_stream(); + this->advance(30000); + } + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_FALSE(this->ufm01_.software_version_published()); +} + +TEST_F(UFM01Test, InvalidMeterIdLeavesSensorUnknown) { + this->attach_diagnostic_text_sensors(); + auto frame = make_active_frame(); + frame[2] = 0xAB; + frame[30] = checksum(frame, 0, 30); + this->mock_uart_.enqueue(frame); + + EXPECT_TRUE(this->ufm01_.process_active_stream()); + + EXPECT_FALSE(this->ufm01_.meter_id_published()); + EXPECT_FALSE(this->meter_id_text_sensor_.has_state()); +} + +} // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_frame_test.cpp b/tests/components/ufm01/ufm01_frame_test.cpp index 3b1b148d504..8e659ce40df 100644 --- a/tests/components/ufm01/ufm01_frame_test.cpp +++ b/tests/components/ufm01/ufm01_frame_test.cpp @@ -4,7 +4,7 @@ namespace esphome::ufm01::testing { TEST_F(UFM01Test, ValidActiveFrameAccepted) { auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -14,7 +14,7 @@ TEST_F(UFM01Test, ValidActiveFrameAccepted) { TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { this->mock_uart_.enqueue({0x00, 0xFF, 0xAA}); auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_TRUE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -23,7 +23,7 @@ TEST_F(UFM01Test, GarbagePrefixThenValidActiveFrame) { TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { auto frame = make_active_frame(); frame[30] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); EXPECT_FALSE(this->ufm01_.process_active_stream()); EXPECT_EQ(this->ufm01_.read_index(), 0); @@ -32,10 +32,10 @@ TEST_F(UFM01Test, InvalidActiveFrameChecksumRejected) { TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { auto frame = make_passive_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); EXPECT_EQ(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); EXPECT_NE(this->ufm01_.last_valid_frame_ms(), 0u); } @@ -43,29 +43,29 @@ TEST_F(UFM01Test, ValidPassiveFrameReadSuccess) { TEST_F(UFM01Test, InvalidPassiveChecksumFails) { auto frame = make_passive_frame(); frame[21] ^= 0xFF; - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_FAILURE); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_FAILURE); EXPECT_EQ(this->ufm01_.last_valid_frame_ms(), 0u); } TEST_F(UFM01Test, PassiveReadResyncsAfterGarbagePrefix) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({0x00, 0x01, 0x02}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadResyncsOnSecondStartByte) { auto frame = make_passive_frame(); this->mock_uart_.enqueue({FRAME_START_BYTE_1, 0x99}); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(frame); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { @@ -73,11 +73,11 @@ TEST_F(UFM01Test, PassiveReadPendingWhenPartial) { this->mock_uart_.enqueue(std::vector(frame.begin(), frame.begin() + 10)); this->ufm01_.prepare_passive_read(); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_PENDING); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_PENDING); EXPECT_LT(this->ufm01_.passive_index(), PASSIVE_FRAME_SIZE); this->mock_uart_.enqueue(std::vector(frame.begin() + 10, frame.end())); - EXPECT_EQ(this->ufm01_.continue_passive_read(), PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS); + EXPECT_EQ(this->ufm01_.continue_passive_read(), ReadResult::READ_RESULT_SUCCESS); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/ufm01_startup_test.cpp b/tests/components/ufm01/ufm01_startup_test.cpp index 6b7feb0f1b0..c6d5a7f4ebf 100644 --- a/tests/components/ufm01/ufm01_startup_test.cpp +++ b/tests/components/ufm01/ufm01_startup_test.cpp @@ -1,14 +1,7 @@ #include "common.h" -#include "esphome/core/component.h" - namespace esphome::ufm01::testing { -TEST(UFM01SetupPriority, IsLate) { - TestableUFM01 ufm01; - EXPECT_EQ(ufm01.get_setup_priority(), setup_priority::LATE); -} - TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { this->mock_uart_.enqueue({0x00, 0x01, COMMAND_ACK, 0x02}); @@ -19,8 +12,7 @@ TEST_F(UFM01Test, ConsumeAckFindsByteAmongGarbage) { TEST_F(UFM01Test, ConsumeAckReturnsFalseWhenEmpty) { EXPECT_FALSE(this->ufm01_.consume_ack()); } TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { - auto frame = make_active_frame(); - this->mock_uart_.enqueue(std::vector(frame.begin(), frame.end())); + this->mock_uart_.enqueue(make_active_frame()); this->ufm01_.init_wait_phase(); this->ufm01_.loop_startup(); @@ -32,12 +24,117 @@ TEST_F(UFM01Test, StartupWaitDetectsActiveStream) { TEST_F(UFM01Test, StartPassiveReadSendsCommand) { this->ufm01_.start_passive_read(); - ASSERT_EQ(this->mock_uart_.written_data.size(), 7u); - EXPECT_EQ(this->mock_uart_.written_data[0], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[1], 0xFE); - EXPECT_EQ(this->mock_uart_.written_data[2], 0x11); - EXPECT_EQ(this->mock_uart_.written_data[3], 0x5B); - EXPECT_EQ(this->mock_uart_.written_data[6], FRAME_STOP_BYTE); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StartupActiveWaitTimeoutSendsSetPassiveMode) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::SET_PASSIVE_WAIT_ACK); + this->expect_written(PASSIVE_MODE); +} + +TEST_F(UFM01Test, StartupSetPassiveWaitAckStartsPassiveRead) { + this->ufm01_.begin_active_wait_frame(); + this->advance(4000); + this->ufm01_.loop_startup(); + this->mock_uart_.enqueue({COMMAND_ACK}); + this->mock_uart_.written_data.clear(); + + this->ufm01_.loop_startup(); + + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::PASSIVE_WAIT_REPLY); + this->expect_written(READ_SENSOR_DATA_NO_ID); +} + +TEST_F(UFM01Test, StaleActiveStreamSendsSetPassiveMode) { + // Leftover stream noise should be flushed before SET_PASSIVE_MODE + this->mock_uart_.enqueue({0x3C, 0x32, 0x00}); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + EXPECT_EQ(this->ufm01_.read_index(), 0); + this->expect_written(PASSIVE_MODE); + EXPECT_EQ(this->mock_uart_.available(), 0u); // RX flushed +} + +TEST_F(UFM01Test, StalePassivePollWaitsOneIntervalAndDiscardsLateAck) { + this->make_active_stream_stale(); + this->ufm01_.loop_active_stream(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + this->mock_uart_.written_data.clear(); + + this->mock_uart_.enqueue({COMMAND_ACK}); + this->advance(500); + this->ufm01_.loop_passive_poll(); + EXPECT_TRUE(this->mock_uart_.written_data.empty()); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); + + this->advance(500); + this->ufm01_.loop_passive_poll(); + this->expect_written(READ_SENSOR_DATA_NO_ID); + EXPECT_EQ(this->mock_uart_.available(), 0u); +} + +#ifdef USE_SENSOR +TEST_F(UFM01Test, StaleActiveStreamPublishesNanToFlowAndTemperature) { + this->attach_flow_and_temperature_sensors(); + this->make_active_stream_stale(); + + this->ufm01_.loop_active_stream(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresPublishNanBeforeReset) { + this->attach_flow_and_temperature_sensors(); + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT; ++i) + this->fail_passive_poll(); + + EXPECT_TRUE(std::isnan(this->flow_sensor_.get_state())); + EXPECT_TRUE(std::isnan(this->temperature_sensor_.get_state())); +} +#endif + +TEST_F(UFM01Test, PassivePollSuccessClearsFailureCount) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (int i = 0; i < 3; ++i) + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.consecutive_passive_failures(), 3u); + + this->mock_uart_.enqueue(make_passive_frame()); + this->ufm01_.begin_pending_passive_read(); + this->ufm01_.loop_passive_poll(); + + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL); +} + +TEST_F(UFM01Test, RepeatedPassiveFailuresEscalateToReset) { + this->ufm01_.set_operating_mode(OperatingMode::PASSIVE_POLL); + + for (uint8_t i = 0; i < ESCALATE_COUNT - 1; ++i) { + this->fail_passive_poll(); + ASSERT_EQ(this->ufm01_.operating_mode(), OperatingMode::PASSIVE_POLL) << "i=" << static_cast(i); + } + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), ESCALATE_COUNT - 1); + + this->mock_uart_.written_data.clear(); + this->fail_passive_poll(); + + EXPECT_EQ(this->ufm01_.operating_mode(), OperatingMode::STARTUP); + EXPECT_EQ(this->ufm01_.startup_phase(), StartupPhase::RESET_WAIT_ACK); + EXPECT_EQ(this->ufm01_.consecutive_passive_failures(), 0u); + this->expect_written(RESET_DEVICE); } } // namespace esphome::ufm01::testing diff --git a/tests/components/ufm01/validate.host.yaml b/tests/components/ufm01/validate.host.yaml new file mode 100644 index 00000000000..19c0739f0bd --- /dev/null +++ b/tests/components/ufm01/validate.host.yaml @@ -0,0 +1,16 @@ +esphome: + name: ufm01-validate + +host: + +logger: + +packages: + ufm01: !include common.yaml + +uart: + id: uart_bus + baud_rate: 2400 + parity: EVEN + stop_bits: 1 + port: /dev/null diff --git a/tests/integration/fixtures/uart_mock_ufm01.yaml b/tests/integration/fixtures/uart_mock_ufm01.yaml new file mode 100644 index 00000000000..0f72225c089 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_ufm01.yaml @@ -0,0 +1,153 @@ +esphome: + name: uart-mock-ufm01 + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates + actions: + - action: clear_accumulated_flow + then: + - ufm01.clear_accumulated_flow: ufm01_dev + - lambda: |- + id(clear_count) += 1; + id(clear_completions).publish_state(id(clear_count)); +logger: + level: DEBUG + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy ufm01's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 2400 + port: /dev/null + +globals: + # The simulated meter streams active frames only after SET_ACTIVE_MODE + - id: streaming + type: bool + initial_value: "false" + # Set by CLEAR_ACCUMULATED_FLOW; later frames report zero accumulated flow + - id: cleared + type: bool + initial_value: "false" + - id: clear_count + type: int + initial_value: "0" + +# Simulated UFM-01: every command is one 7 byte write +uart_mock: + id: mock_uart + baud_rate: 2400 + parity: EVEN + on_tx: + - then: + - lambda: |- + if (id(silent).state || data.size() != 7) { + return; + } + static const std::vector ACK = {0xE5}; + switch (data[3]) { + case 0x5D: // RESET_DEVICE + id(streaming) = false; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5C: // SET_ACTIVE_MODE (00) or SET_PASSIVE_MODE (01) + id(streaming) = data[4] == 0x00; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5A: // CLEAR_ACCUMULATED_FLOW + id(cleared) = true; + id(mock_uart).inject_to_rx_buffer(ACK); + break; + case 0x5B: // READ_SENSOR_DATA without meter ID + if (data[4] != 0x0F) { + break; + } + id(mock_uart).inject_to_rx_buffer(std::vector{ + 0x3C, 0x64, // start + 0x0A, 0x78, 0x56, 0x02, 0x00, 0x00, 0x00, // accumulated flow 25.678 L + 0x0B, 0x00, 0x67, 0x05, 0x00, 0x00, // flow 0.567 m3/h + 0x0D, 0x25, 0x19, 0x00, // temperature 19.25 C + 0x00, 0x00, // ST1, ST2 + 0x3C, 0x16, // checksum, stop + }); + break; + case 0x5E: // GET_SOFTWARE_VERSION: ACK, version 23380315 (BCD, low byte first), checksum, stop + id(mock_uart).inject_to_rx_buffer(std::vector{0xE5, 0x15, 0x03, 0x38, 0x23, 0x73, 0x16}); + break; + } + +interval: + - interval: 1s + then: + - if: + condition: + lambda: "return id(streaming) && !id(silent).state;" + then: + - lambda: |- + // Meter ID 2307140001 (BCD, low byte first), flow 1.2345 m3/h, temperature 23.5 C + std::vector frame{ + 0x3C, 0x32, // start + 0x01, 0x00, 0x14, 0x07, 0x23, 0x01, // meter ID, reserved + 0x0A, 0x45, 0x23, 0x01, 0x00, 0x00, 0x00, // accumulated flow 12.345 L + 0x0B, 0x50, 0x34, 0x12, 0x00, 0x00, // flow + 0x0C, 0x00, 0x00, // reserved section + 0x0D, 0x50, 0x23, 0x00, // temperature + 0x00, 0x00, // ST1, ST2 + 0x4E, 0x16, // checksum, stop + }; + if (id(cleared)) { + // Accumulated flow 0 m3: unit flag 0x1A, zero digits, checksum follows + frame[8] = 0x1A; + std::fill(frame.begin() + 9, frame.begin() + 15, 0x00); + frame[30] = 0xF5; + } + id(mock_uart).inject_to_rx_buffer(frame); + +ufm01: + id: ufm01_dev + uart_id: mock_uart + +sensor: + - platform: ufm01 + accumulated_flow: + name: "Accumulated Flow" + flow: + name: "Flow" + temperature: + name: "Temperature" + - platform: template + id: clear_completions + name: "Clear Completions" + update_interval: never + +text_sensor: + - platform: ufm01 + meter_id: + name: "Meter ID" + software_version: + name: "Software Version" + +switch: + # The meter stops streaming and answers nothing + - platform: template + id: silent + name: "Silent" + optimistic: true + turn_on_action: + - lambda: "id(streaming) = false;" + +button: + # The stream stops while the meter still answers commands + - platform: template + name: "Pause Stream" + on_press: + - lambda: "id(streaming) = false;" + - platform: template + name: "Resume Stream" + on_press: + - lambda: "id(streaming) = true;" diff --git a/tests/integration/test_uart_mock_ufm01.py b/tests/integration/test_uart_mock_ufm01.py new file mode 100644 index 00000000000..c2c2f08f523 --- /dev/null +++ b/tests/integration/test_uart_mock_ufm01.py @@ -0,0 +1,399 @@ +"""Integration tests for the UFM-01 flow meter with a simulated meter on the mock UART. + +All tests share uart_mock_ufm01.yaml, whose on_tx lambda answers like a UFM-01: it ACKs commands, +answers sensor data and software version reads, and streams active frames each second after +SET_ACTIVE_MODE. The "Silent" switch stops it answering, "Pause Stream" stops the stream while it still +answers, and "Resume Stream" starts the stream again. + +test_uart_mock_ufm01_startup: + Reset, software version read and active mode go out in order, the active frames publish flow, + temperature and accumulated flow, and the meter ID and software version publish once. +test_uart_mock_ufm01_stale_recovery: + A stopped stream marks flow and temperature unknown after 5 s and switches to passive polling, + whose answers publish; a resumed stream switches back to the active stream and polling stops. +test_uart_mock_ufm01_escalation: + Eight unanswered passive polls send a reset and run the startup sequence again; values publish + once the meter answers, and the diagnostics are not published again. +test_uart_mock_ufm01_clear_action: + The clear command is sent once the UART is idle while the automation continues at once; the + meter's ACK, or its absence, is only logged, and a second request joins the pending one. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import math +import re + +from aioesphomeapi import ( + APIClient, + ButtonInfo, + EntityInfo, + EntityState, + SensorState, + SwitchInfo, + TextSensorState, + UserService, +) +import pytest + +from .state_utils import InitialStateHelper, StateWaiter +from .types import APIClientConnectedFactory, RunCompiledFunction + +# Commands as the uart mock logs them: FE FE 11, command, argument, checksum, 16 +RESET_DEVICE = "FE:FE:11:5D:CB:28:16" +SET_ACTIVE_MODE = "FE:FE:11:5C:00:5C:16" +SET_PASSIVE_MODE = "FE:FE:11:5C:01:5D:16" +READ_SENSOR_DATA = "FE:FE:11:5B:0F:6A:16" +GET_SOFTWARE_VERSION = "FE:FE:11:5E:62:C0:16" +CLEAR_ACCUMULATED_FLOW = "FE:FE:11:5A:FD:57:16" + +# Values the fixture's frames encode +ACTIVE_FLOW = 1.2345 +ACTIVE_TEMPERATURE = 23.5 +ACTIVE_ACCUMULATED = 12.345 +PASSIVE_FLOW = 0.567 +PASSIVE_TEMPERATURE = 19.25 +PASSIVE_ACCUMULATED = 25.678 +METER_ID = "2307140001" +SOFTWARE_VERSION = "23380315" + +ACTIVE_STARTED = "UFM-01 active stream started" +ACTIVE_RESUMED = "UFM-01 active stream resumed" +STALE = "Active stream stale, switching to passive polling" +ESCALATED = "Passive poll failed repeatedly, re-initializing UFM-01" +CLEAR_ACKED = "Clear accumulated flow acknowledged" +CLEAR_NOT_ACKED = "Clear accumulated flow not acknowledged" + +# Startup wait, reset ACK, post-reset wait, then the first frame within the 1 s stream interval +STARTUP_TIMEOUT = 8.0 +ACTIVE_STALE_S = 5.0 + +ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + +class _Log: + """Record TX frames and ufm01 log lines with their arrival time, and wait for lines.""" + + def __init__(self) -> None: + self.events: list[tuple[float, str]] = [] + self._awaited: list[tuple[str, asyncio.Future[float]]] = [] + + def on_line(self, line: str) -> None: + line = ANSI_ESCAPE.sub("", line) + if "uart_mock" in line and "TX " in line and "bytes: " in line: + text = line.split("bytes: ", 1)[1].strip() + elif "[ufm01" in line: + text = line + else: + return + now = asyncio.get_running_loop().time() + self.events.append((now, text)) + for wanted, future in self._awaited: + if wanted in text and not future.done(): + future.set_result(now) + + def next(self, text: str) -> asyncio.Future[float]: + """Return a future for the time of the next event containing ``text``.""" + future: asyncio.Future[float] = asyncio.get_running_loop().create_future() + self._awaited.append((text, future)) + return future + + @property + def tx(self) -> list[str]: + return [text for _, text in self.events if text.startswith("FE:")] + + def index(self, text: str, start: int = 0) -> int: + return next( + i + for i, (_, event) in enumerate(self.events) + if i >= start and text in event + ) + + def tx_between(self, start: int, end: int) -> list[str]: + return [text for _, text in self.events[start:end] if text.startswith("FE:")] + + +class _Meter: + """Entities and state history of the running fixture.""" + + def __init__( + self, client: APIClient, entities: list[EntityInfo], services: list[UserService] + ) -> None: + self.client = client + self.services = services + self.by_id = {entity.object_id: entity for entity in entities} + self.waiter = StateWaiter() + self.states: dict[str, list[float | str]] = {name: [] for name in self.by_id} + self._names = {entity.key: entity.object_id for entity in entities} + self.helper = InitialStateHelper(entities) + + def on_state(self, state: EntityState) -> None: + if ( + isinstance(state, (SensorState, TextSensorState)) + and not state.missing_state + ): + self.states[self._names[state.key]].append(state.state) + self.waiter.on_state(state) + + def expect( + self, name: str, check: Callable[[float | str], bool], timeout: float + ) -> Awaitable[EntityState]: + key = self.by_id[name].key + return self.waiter.expect( + lambda s: s.key == key and not s.missing_state and check(s.state), + timeout=timeout, + label=name, + ) + + def press(self, name: str) -> None: + entity = self.by_id[name] + assert isinstance(entity, ButtonInfo) + self.client.button_command(entity.key) + + def set_silent(self, silent: bool) -> None: + entity = self.by_id["silent"] + assert isinstance(entity, SwitchInfo) + self.client.switch_command(entity.key, silent) + + def all_values(self, name: str) -> list[float | str]: + """Return every value ``name`` held, including one already set at connect time.""" + initial = self.helper.initial_states[self.by_id[name].key] + return ([] if initial.missing_state else [initial.state]) + self.states[name] + + +async def _connect(client: APIClient) -> _Meter: + entities, services = await client.list_entities_services() + meter = _Meter(client, entities, services) + client.subscribe_states(meter.helper.on_state_wrapper(meter.on_state)) + try: + await meter.helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + return meter + + +def _is_nan(value: float | str) -> bool: + return isinstance(value, float) and math.isnan(value) + + +def _approx(expected: float) -> Callable[[float | str], bool]: + return lambda value: value == pytest.approx(expected, abs=1e-4) + + +def _active_values(meter: _Meter, timeout: float) -> list[Awaitable[EntityState]]: + """Arm waits for the next flow, temperature and accumulated flow from an active frame.""" + return [ + meter.expect("flow", _approx(ACTIVE_FLOW), timeout), + meter.expect("temperature", _approx(ACTIVE_TEMPERATURE), timeout), + meter.expect("accumulated_flow", _approx(ACTIVE_ACCUMULATED), timeout), + ] + + +async def _gather(meter: _Meter, waits: list[Awaitable[EntityState]]) -> None: + try: + await asyncio.gather(*waits) + except TimeoutError as err: + pytest.fail(f"{err}; states: {meter.states}") + + +async def _wait_for(log: _Log, future: asyncio.Future[float], timeout: float) -> float: + try: + return await asyncio.wait_for(future, timeout) + except TimeoutError: + pytest.fail(f"Timed out; log: {log.events}") + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_startup( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Startup reaches the active stream, publishes the decoded values and the diagnostics once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + values = _active_values(meter, STARTUP_TIMEOUT) + await _wait_for(log, started, STARTUP_TIMEOUT) + await _gather(meter, values) + # Two more stream frames + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + assert log.tx == [RESET_DEVICE, GET_SOFTWARE_VERSION, SET_ACTIVE_MODE] + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_stale_recovery( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A stopped stream falls back to passive polling and a resumed stream ends it.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + stale = log.next(STALE) + flow_unknown = meter.expect("flow", _is_nan, ACTIVE_STALE_S + 2) + temperature_unknown = meter.expect("temperature", _is_nan, ACTIVE_STALE_S + 2) + polled = [ + meter.expect("flow", _approx(PASSIVE_FLOW), ACTIVE_STALE_S + 4), + meter.expect( + "temperature", _approx(PASSIVE_TEMPERATURE), ACTIVE_STALE_S + 4 + ), + meter.expect( + "accumulated_flow", _approx(PASSIVE_ACCUMULATED), ACTIVE_STALE_S + 4 + ), + ] + meter.press("pause_stream") + paused_at = asyncio.get_running_loop().time() + stale_at = await _wait_for(log, stale, ACTIVE_STALE_S + 2) + # The last frame came up to 1 s before the pause + assert stale_at - paused_at >= ACTIVE_STALE_S - 1.5 + await _gather(meter, [flow_unknown, temperature_unknown]) + await _gather(meter, polled) + + stale_index = log.index(STALE) + assert log.tx_between(stale_index, len(log.events))[:2] == [ + SET_PASSIVE_MODE, + READ_SENSOR_DATA, + ] + # Unknown first, then the passive answers + assert _is_nan(meter.states["flow"][-2]) + assert _is_nan(meter.states["temperature"][-2]) + + resumed = log.next(ACTIVE_RESUMED) + meter.press("resume_stream") + values = _active_values(meter, 3.0) + await _wait_for(log, resumed, 3.0) + await _gather(meter, values) + # Two more stream frames without any poll in between + for _ in range(2): + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + assert READ_SENSOR_DATA not in log.tx_between( + log.index(ACTIVE_RESUMED), len(log.events) + ) + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_escalation( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Eight unanswered passive polls reset the meter and rerun the startup sequence.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + await _wait_for(log, started, STARTUP_TIMEOUT) + await meter.expect("flow", _approx(ACTIVE_FLOW), 2.0) + + escalated = log.next(ESCALATED) + meter.set_silent(True) + # Stale after 5 s, then eight polls that each time out after 1 s + flow_count = len(meter.states["flow"]) + await _wait_for(log, escalated, ACTIVE_STALE_S + 8 + 4) + + restarted = log.next(ACTIVE_STARTED) + values = _active_values(meter, STARTUP_TIMEOUT) + meter.set_silent(False) + await _wait_for(log, restarted, STARTUP_TIMEOUT) + await _gather(meter, values) + + stale_index = log.index(STALE) + escalated_index = log.index(ESCALATED, stale_index) + assert ( + log.tx_between(stale_index, escalated_index) + == [SET_PASSIVE_MODE] + [READ_SENSOR_DATA] * 8 + ) + # A reset (resent once if the meter was still silent), then active mode; the software + # version is already known, so it is not read again + after = log.tx_between( + escalated_index, log.index(ACTIVE_STARTED, escalated_index) + ) + assert after in ( + [RESET_DEVICE, SET_ACTIVE_MODE], + [RESET_DEVICE, RESET_DEVICE, SET_ACTIVE_MODE], + ), after + # Unknown when the stream went stale and again on the re-init, nothing in between; + # a frame already in flight when the meter went silent may come first + flow = meter.states["flow"][flow_count:] + first_unknown = next(i for i, value in enumerate(flow) if _is_nan(value)) + assert _is_nan(flow[first_unknown + 1]), flow + assert flow[first_unknown + 2] == pytest.approx(ACTIVE_FLOW, abs=1e-4), flow + + assert meter.all_values("meter_id") == [METER_ID] + assert meter.all_values("software_version") == [SOFTWARE_VERSION] + + +@pytest.mark.shared_yaml("uart_mock_ufm01") +@pytest.mark.asyncio +async def test_uart_mock_ufm01_clear_action( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """The clear command is sent once the UART is idle; the automation continues at once.""" + log = _Log() + started = log.next(ACTIVE_STARTED) + acked = log.next(CLEAR_ACKED) + async with ( + run_compiled(yaml_config, line_callback=log.on_line), + api_client_connected() as client, + ): + meter = await _connect(client) + # A second entity listing would re-send the initial states and duplicate the first completion + (clear,) = meter.services + # Normally still inside the startup wait; a slow runner may already be streaming + queued_during_startup = not started.done() + + completed = meter.expect("clear_completions", _approx(1), STARTUP_TIMEOUT) + zeroed = meter.expect("accumulated_flow", _approx(0.0), STARTUP_TIMEOUT + 2) + await client.execute_service(clear, {}) + await _wait_for(log, acked, STARTUP_TIMEOUT) + await _gather(meter, [completed, zeroed]) + + # Queued during startup, sent only once the active stream started + if queued_during_startup: + assert CLEAR_ACCUMULATED_FLOW not in log.tx_between( + 0, log.index(ACTIVE_STARTED) + ) + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 1 + assert log.index(CLEAR_ACCUMULATED_FLOW) < log.index(CLEAR_ACKED) + + # Two requests in a row share one command; without an answer it is reported after the 500 ms ACK timeout + sent = log.next(CLEAR_ACCUMULATED_FLOW) + not_acked = log.next(CLEAR_NOT_ACKED) + completed = meter.expect("clear_completions", _approx(3), 3.0) + meter.set_silent(True) + await asyncio.sleep(0.2) + await client.execute_service(clear, {}) + await client.execute_service(clear, {}) + sent_at = await _wait_for(log, sent, 2.0) + not_acked_at = await _wait_for(log, not_acked, 2.0) + await _gather(meter, [completed]) + assert not_acked_at - sent_at >= 0.4 + assert log.tx.count(CLEAR_ACCUMULATED_FLOW) == 2 + assert meter.states["clear_completions"] == [1.0, 2.0, 3.0] From 16a474cce5de7e3ed506fea4ffcc618cdf88cb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:40:27 +0200 Subject: [PATCH 58/97] [core] Only treat action names as actions in has_non_synchronous_actions() (#20524) Co-authored-by: Claude Opus 5.5 --- esphome/automation.py | 24 +++++++--- tests/unit_tests/test_automation.py | 70 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/esphome/automation.py b/esphome/automation.py index 0105d1e1072..fea5769050e 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1033,18 +1033,30 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: Non-synchronous actions (delay, wait_until, script.wait, etc.) store trigger args for later execution, making non-owning types like StringRef unsafe. + + Accepts an action list, a single action, or an automation. Only the keys of + action entries are looked up as action names; the options of an action are + only searched for nested action lists (``then:``, ``else:``, ...), so an + option that shares a name with an action, such as the ``delay:`` option of + ``speaker_source.set_playlist_delay``, is not mistaken for that action. """ if isinstance(actions, list): return any(has_non_synchronous_actions(item) for item in actions) if isinstance(actions, dict): - for key in actions: + for key, options in actions.items(): if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous: return True - return any( - has_non_synchronous_actions(v) - for v in actions.values() - if isinstance(v, (list, dict)) - ) + if _options_have_non_synchronous_actions(options): + return True + return False + + +def _options_have_non_synchronous_actions(options: Any) -> bool: + """Search an action's options for nested action lists, without treating option keys as actions.""" + if isinstance(options, list): + return has_non_synchronous_actions(options) + if isinstance(options, dict): + return any(_options_have_non_synchronous_actions(v) for v in options.values()) return False diff --git a/tests/unit_tests/test_automation.py b/tests/unit_tests/test_automation.py index 351afe541d6..f2d46280448 100644 --- a/tests/unit_tests/test_automation.py +++ b/tests/unit_tests/test_automation.py @@ -216,6 +216,76 @@ def test_has_non_synchronous_actions_dict_input( assert has_non_synchronous_actions({"logger.log": "hello"}) is False +def test_has_non_synchronous_actions_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """An option that shares a name with a deferring action is not that action.""" + actions = [{"logger.log": {"message": "hello", "delay": "1s"}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_nested_option_named_like_action( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Option keys in nested option dicts are not looked up as action names either.""" + actions = [{"logger.log": {"settings": {"wait_until": {"condition": {}}}}}] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_option_named_like_action_inside_then( + mock_registry: dict[str, RegistryEntry], +) -> None: + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": {"message": "hello", "delay": "1s"}}], + } + } + ] + assert has_non_synchronous_actions(actions) is False + + +def test_has_non_synchronous_actions_in_else( + mock_registry: dict[str, RegistryEntry], +) -> None: + """Action lists in any option of an action are searched, not only then:.""" + actions = [ + { + "if": { + "condition": {}, + "then": [{"logger.log": "hello"}], + "else": [{"delay": "1s"}], + } + } + ] + assert has_non_synchronous_actions(actions) is True + + +def test_has_non_synchronous_actions_automation_dict( + mock_registry: dict[str, RegistryEntry], +) -> None: + """A single automation, as validate_automation(single=True) returns it.""" + assert has_non_synchronous_actions({CONF_THEN: [{"delay": "1s"}]}) is True + assert has_non_synchronous_actions({CONF_THEN: [{"logger.log": "hi"}]}) is False + + +def test_has_non_synchronous_actions_set_playlist_delay() -> None: + """speaker_source.set_playlist_delay is synchronous despite its delay: option.""" + import esphome.components.speaker_source.media_player # noqa: F401 # registers the action + + actions = [ + { + "speaker_source.set_playlist_delay": { + CONF_ID: "player", + "pipeline": 0, + "delay": 1000, + } + } + ] + assert has_non_synchronous_actions(actions) is False + + def test_validate_no_deferring_actions_accepts_synchronous( mock_registry: dict[str, RegistryEntry], ) -> None: From 5c5737303f4f5344c9fbbb1bd7285bebc0a4611a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:02 +0200 Subject: [PATCH 59/97] [teleinfo] Store the frame as bytes and read with read_byte() (#20531) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 25 ++++++++++++------------ esphome/components/teleinfo/teleinfo.h | 4 ++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 91a0d0e16f5..b4e474dea4b 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -6,30 +6,30 @@ namespace esphome::teleinfo { ESPHOME_LOG_TAG(TAG, "teleinfo"); /* Helpers */ -static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int max_len) { - char *field_end; +static int get_field(char *dest, uint8_t *buf_start, uint8_t *buf_end, int sep, int max_len) { + uint8_t *field_end; int len; - field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); + field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); if (!field_end) return 0; len = field_end - buf_start; if (len >= max_len) return len; - strncpy(dest, buf_start, len); + memcpy(dest, buf_start, len); dest[len] = '\0'; return len; } /* TeleInfo methods */ -bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { +bool TeleInfo::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { int grp_len = grp_end - grp; if (grp_len < this->checksum_area_end_) { ESP_LOGE(TAG, "Empty or too short group"); return false; } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = sum_bytes(reinterpret_cast(grp), grp_len - this->checksum_area_end_); + uint8_t crc_tmp = sum_bytes(grp, grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; @@ -46,7 +46,8 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { while (available() > 0 && j < 128) { j++; - received = read(); + if (!read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -87,9 +88,9 @@ void TeleInfo::loop() { state_ = END_FRAME_RECEIVED; break; case END_FRAME_RECEIVED: - char *buf_finger; - char *grp_end; - char *buf_end; + uint8_t *buf_finger; + uint8_t *grp_end; + uint8_t *buf_end; int field_len; buf_finger = buf_; @@ -116,7 +117,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -126,7 +127,7 @@ void TeleInfo::loop() { buf_finger += 1; /* Group len */ - grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); + grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index b1bf586e9c2..39f02874113 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -33,7 +33,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { protected: int checksum_area_end_; int separator_; - char buf_[MAX_BUF_SIZE]; + uint8_t buf_[MAX_BUF_SIZE]; uint32_t buf_index_{0}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; @@ -45,7 +45,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { END_FRAME_RECEIVED, } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); - bool check_crc_(const char *grp, const char *grp_end); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const std::string &tag, const std::string &val); }; } // namespace esphome::teleinfo From b10eef537ea70f75f8b64136756cd0dd21739ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:28 +0200 Subject: [PATCH 60/97] [teleinfo] Log the tag name for an invalid timestamp (#20532) Co-authored-by: Claude Opus 5.5 --- esphome/components/teleinfo/teleinfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index b4e474dea4b..1d3813d59e7 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -154,7 +154,7 @@ void TeleInfo::loop() { if (std::count(buf_finger, grp_end, separator_) == 2 && strcmp(tag_, "DATE") != 0 && separator_ != 0x20) { field_len = get_field(timestamp_, buf_finger, grp_end, separator_, MAX_TIMESTAMP_SIZE); if (!field_len || field_len >= MAX_TIMESTAMP_SIZE) { - ESP_LOGE(TAG, "Invalid timestamp for tag %s", timestamp_); + ESP_LOGE(TAG, "Invalid timestamp for tag %s", tag_); continue; } From da43b6292dbc7a51a1ac8b5056e190371e424e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:41:58 +0200 Subject: [PATCH 61/97] [mk2pvrouter] Store the frame as bytes and read with read_byte() (#20530) Co-authored-by: Claude Opus 5.5 --- .../components/mk2pvrouter/mk2pvrouter.cpp | 24 +++++++++---------- esphome/components/mk2pvrouter/mk2pvrouter.h | 8 +++---- .../mk2pvrouter/mk2pvrouter_test.cpp | 15 ++++++++---- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index b759758019a..98dd0b74ece 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -19,8 +19,8 @@ constexpr uint8_t CRC_OFFSET = 0x20; // Extracts a TAB-delimited field from [buf_start, buf_end) into dest. // Returns the field length, or 0 if no TAB was found, or the (uncopied) field // length if it's >= max_len. -static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { - const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); +static size_t get_field(char *dest, const uint8_t *buf_start, const uint8_t *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); if (!field_end) return 0; const size_t len = field_end - buf_start; @@ -34,13 +34,13 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, return len; } -// Calculates the CRC (checksum) for a given group of characters. -uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - return (sum_bytes(reinterpret_cast(grp), grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; +// Calculates the CRC (checksum) for a given group of bytes. +uint8_t Mk2PVRouter::calculate_crc_(const uint8_t *grp, size_t grp_len) { + return (sum_bytes(grp, grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. -bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { +bool Mk2PVRouter::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { const auto grp_len = grp_end - grp; if (grp_len < static_cast(CRC_SUFFIX_LEN)) { ESP_LOGE(TAG, "Empty or too short group"); @@ -58,7 +58,7 @@ bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { } // Validates, parses, and publishes a single tag/value group. -void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { +void Mk2PVRouter::process_group_(const uint8_t *grp, const uint8_t *grp_end) { if (!this->check_crc_(grp, grp_end)) return; @@ -83,9 +83,9 @@ bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { size_t j{0}; while (this->available() > 0 && j++ < MAX_ITERATIONS) { - const auto received = this->read(); - if (received < 0) - continue; + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -127,10 +127,10 @@ void Mk2PVRouter::loop() { // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) // CRC is computed over "Tag | TAB | Data | TAB". - while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { ++buf_finger; // Skip LF to the start of the group. - auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index 1a6af4b1b39..061a9a22e94 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.h +++ b/esphome/components/mk2pvrouter/mk2pvrouter.h @@ -57,12 +57,12 @@ class Mk2PVRouter : public Component, public uart::UARTDevice { State state_{State::WAITING_FOR_START}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; - char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + uint8_t buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding bool read_chars_until_(bool drop, uint8_t c); - uint8_t calculate_crc_(const char *grp, size_t grp_len); - bool check_crc_(const char *grp, const char *grp_end); - void process_group_(const char *grp, const char *grp_end); + uint8_t calculate_crc_(const uint8_t *grp, size_t grp_len); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); + void process_group_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const char *tag, const char *val); }; } // namespace esphome::mk2pvrouter diff --git a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp index 84106eb0b77..a2fb33a2a33 100644 --- a/tests/components/mk2pvrouter/mk2pvrouter_test.cpp +++ b/tests/components/mk2pvrouter/mk2pvrouter_test.cpp @@ -8,6 +8,9 @@ namespace esphome::mk2pvrouter::testing { namespace { +// The parser works on raw frame bytes; the groups below are written as text for readability. +const uint8_t *as_bytes(const char *str) { return reinterpret_cast(str); } + class TestListener final : public Mk2PVRouterListener { public: explicit TestListener(const char *tag) : Mk2PVRouterListener(tag) {} @@ -25,7 +28,9 @@ class Mk2PVRouterTest : public ::testing::Test { void SetUp() override { this->sut_.register_mk2pvrouter_listener(&this->listener_); } // Feeds one "tagvaluecrc" literal, as the frame parser does after END_FRAME. - template void process_(const char (&group)[N]) { this->sut_.process_group_(group, group + N - 1); } + template void process_(const char (&group)[N]) { + this->sut_.process_group_(as_bytes(group), as_bytes(group) + N - 1); + } TestableMk2PVRouter sut_; TestListener listener_{"P1"}; @@ -40,22 +45,22 @@ TEST(Mk2PVRouterListenerTest, GetTagReturnsConstructorTag) { TEST_F(Mk2PVRouterTest, CalculateCrcMatchesKnownGroup) { // "P1\t1234\t" sums to 0x3D ('=') per the mk2pvrouter CRC algorithm. const char grp[] = "P1\t1234\t="; - EXPECT_EQ(this->sut_.calculate_crc_(grp, sizeof(grp) - 1), '='); + EXPECT_EQ(this->sut_.calculate_crc_(as_bytes(grp), sizeof(grp) - 1), '='); } TEST_F(Mk2PVRouterTest, CheckCrcAcceptsMatchingCrc) { const char grp[] = "P1\t1234\t="; - EXPECT_TRUE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_TRUE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsMismatchedCrc) { const char grp[] = "P1\t1234\t!"; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp + sizeof(grp) - 1)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp) + sizeof(grp) - 1)); } TEST_F(Mk2PVRouterTest, CheckCrcRejectsEmptyGroup) { const char grp[] = ""; - EXPECT_FALSE(this->sut_.check_crc_(grp, grp)); + EXPECT_FALSE(this->sut_.check_crc_(as_bytes(grp), as_bytes(grp))); } TEST_F(Mk2PVRouterTest, ProcessGroupPublishesValidGroupToMatchingListener) { From b73f151619b2a62ce73130470987c42eccb071e4 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Sun, 11 Oct 2026 21:49:06 +0200 Subject: [PATCH 62/97] [pmsx003] add support for luftmy ld15 (#15296) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pmsx003/pmsx003.cpp | 78 ++++++++++--------- esphome/components/pmsx003/pmsx003.h | 1 + esphome/components/pmsx003/sensor.py | 11 +++ tests/components/pmsx003/common-luftmy.yaml | 22 ++++++ tests/components/pmsx003/test.esp32-idf.yaml | 3 +- .../components/pmsx003/test.esp8266-ard.yaml | 3 +- tests/components/pmsx003/test.rp2040-ard.yaml | 3 +- .../pmsx003/validate-luftmy.esp32-idf.yaml | 7 ++ 8 files changed, 85 insertions(+), 43 deletions(-) create mode 100644 tests/components/pmsx003/common-luftmy.yaml create mode 100644 tests/components/pmsx003/validate-luftmy.esp32-idf.yaml diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index 14b1f60df54..f99e5adaa2c 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -189,6 +189,7 @@ bool PMSX003Component::check_payload_length_(uint16_t payload_length) { case Type::PMS5003ST: // Data 16 not set/reserved return payload_length == 36; // 2*17+2 case Type::PMS9003M: + case Type::LUFTMY: return payload_length == 28; // 2*13+2 } return false; @@ -219,40 +220,48 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { } void PMSX003Component::parse_data_() { - // Particle Matter + // PM mass concentrations const uint16_t pm_1_0_std_concentration = this->get_16_bit_uint_(4); const uint16_t pm_2_5_std_concentration = this->get_16_bit_uint_(6); const uint16_t pm_10_0_std_concentration = this->get_16_bit_uint_(8); - const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); - const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); - const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); - - const uint16_t pm_particles_03um = this->get_16_bit_uint_(16); - const uint16_t pm_particles_05um = this->get_16_bit_uint_(18); - const uint16_t pm_particles_10um = this->get_16_bit_uint_(20); - const uint16_t pm_particles_25um = this->get_16_bit_uint_(22); - - ESP_LOGD(TAG, - "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " - "Concentration: %u µg/m³, PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 " - "Concentration: %u µg/m³", - pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration, pm_1_0_concentration, - pm_2_5_concentration, pm_10_0_concentration); - if (this->pm_1_0_std_sensor_ != nullptr) this->pm_1_0_std_sensor_->publish_state(pm_1_0_std_concentration); if (this->pm_2_5_std_sensor_ != nullptr) this->pm_2_5_std_sensor_->publish_state(pm_2_5_std_concentration); if (this->pm_10_0_std_sensor_ != nullptr) this->pm_10_0_std_sensor_->publish_state(pm_10_0_std_concentration); + ESP_LOGD(TAG, + "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " + "Concentration: %u µg/m³", + pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration); - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + // The Luftmy LD10, LD11, LD13, LD15 and LD16 report no atmospheric PM, so their particle counts start at byte 10. + // All PMS types have atmospheric PM at bytes 10-15, so their particle counts start at byte 16. + const bool is_luftmy = this->type_ == Type::LUFTMY; + const uint8_t pc_offset = is_luftmy ? 10 : 16; + + if (!is_luftmy) { + const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); + const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); + const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); + + ESP_LOGD(TAG, "Got PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 Concentration: %u µg/m³", + pm_1_0_concentration, pm_2_5_concentration, pm_10_0_concentration); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + } + + // Particle counts at pc_offset: >0.3, >0.5, >1.0, >2.5µm + const uint16_t pm_particles_03um = this->get_16_bit_uint_(pc_offset); + const uint16_t pm_particles_05um = this->get_16_bit_uint_(pc_offset + 2); + const uint16_t pm_particles_10um = this->get_16_bit_uint_(pc_offset + 4); + const uint16_t pm_particles_25um = this->get_16_bit_uint_(pc_offset + 6); if (this->pm_particles_03um_sensor_ != nullptr) this->pm_particles_03um_sensor_->publish_state(pm_particles_03um); @@ -262,22 +271,17 @@ void PMSX003Component::parse_data_() { this->pm_particles_10um_sensor_->publish_state(pm_particles_10um); if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); + ESP_LOGD(TAG, + "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " + "PM2.5 Particles: %u Count/0.1L", + pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - if (this->type_ == Type::PMS5003T) { - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - } else { - // Note the pm particles 50um & 100um are not returned, - // as PMS5003T uses those data values for temperature and humidity. - const uint16_t pm_particles_50um = this->get_16_bit_uint_(24); - const uint16_t pm_particles_100um = this->get_16_bit_uint_(26); + // PMS5003T uses pc_offset+8 and pc_offset+10 for temperature/humidity, not particle counts + if (this->type_ != Type::PMS5003T) { + const uint16_t pm_particles_50um = this->get_16_bit_uint_(pc_offset + 8); + const uint16_t pm_particles_100um = this->get_16_bit_uint_(pc_offset + 10); - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L, PM5.0 Particles: %u Count/0.1L, PM10.0 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um, pm_particles_50um, + ESP_LOGD(TAG, "Got PM5.0 Particles: %u Count/0.1L, PM10.0 Particles: %u Count/0.1L", pm_particles_50um, pm_particles_100um); if (this->pm_particles_50um_sensor_ != nullptr) diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index c62960e7c31..c123a42b9e6 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -15,6 +15,7 @@ enum class Type : uint8_t { PMS5003T, PMS5003ST, PMS9003M, + LUFTMY, // Luftmy LD10, LD11, LD13, LD15, LD16: standard PM + particle counts, no atmospheric PM values }; enum class Command : uint8_t { diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index 5eac5415ed3..723b99a5040 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -51,6 +51,7 @@ TYPE_PMS5003S = "PMS5003S" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" TYPE_PMS9003M = "PMS9003M" +TYPE_LUFTMY = "LUFTMY" Type = pmsx003_ns.enum("Type", is_class=True) @@ -62,6 +63,7 @@ PMSX003_TYPES = { TYPE_PMS5003T: Type.PMS5003T, TYPE_PMS5003ST: Type.PMS5003ST, TYPE_PMS9003M: Type.PMS9003M, + TYPE_LUFTMY: Type.LUFTMY, } SENSORS_TO_TYPE = { @@ -73,6 +75,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5_STD: [ TYPE_PMS1003, @@ -82,6 +85,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0_STD: [ TYPE_PMS1003, @@ -91,6 +95,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0: [ TYPE_PMS1003, @@ -126,6 +131,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_0_5UM: [ TYPE_PMS1003, @@ -134,6 +140,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0UM: [ TYPE_PMS1003, @@ -142,6 +149,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5UM: [ TYPE_PMS1003, @@ -150,6 +158,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_5_0UM: [ TYPE_PMS1003, @@ -157,6 +166,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0UM: [ TYPE_PMS1003, @@ -164,6 +174,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST], CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST], diff --git a/tests/components/pmsx003/common-luftmy.yaml b/tests/components/pmsx003/common-luftmy.yaml new file mode 100644 index 00000000000..538b2481a49 --- /dev/null +++ b/tests/components/pmsx003/common-luftmy.yaml @@ -0,0 +1,22 @@ +sensor: + - platform: pmsx003 + type: LUFTMY + pm_1_0_std: + name: PM 1.0 Standard Atmospheric Concentration + pm_2_5_std: + name: PM 2.5 Standard Atmospheric Concentration + pm_10_0_std: + name: PM 10.0 Standard Atmospheric Concentration + pm_0_3um: + name: Particulate Count >0.3um + pm_0_5um: + name: Particulate Count >0.5um + pm_1_0um: + name: Particulate Count >1.0um + pm_2_5um: + name: Particulate Count >2.5um + pm_5_0um: + name: Particulate Count >5.0um + pm_10_0um: + name: Particulate Count >10.0um + update_interval: 30s diff --git a/tests/components/pmsx003/test.esp32-idf.yaml b/tests/components/pmsx003/test.esp32-idf.yaml index b415125e84b..9e5b478a31e 100644 --- a/tests/components/pmsx003/test.esp32-idf.yaml +++ b/tests/components/pmsx003/test.esp32-idf.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp32-idf.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.esp8266-ard.yaml b/tests/components/pmsx003/test.esp8266-ard.yaml index 96ab4ef6aca..8e7bd3ebe86 100644 --- a/tests/components/pmsx003/test.esp8266-ard.yaml +++ b/tests/components/pmsx003/test.esp8266-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/test.rp2040-ard.yaml b/tests/components/pmsx003/test.rp2040-ard.yaml index b28f2b5e05e..58779cc0956 100644 --- a/tests/components/pmsx003/test.rp2040-ard.yaml +++ b/tests/components/pmsx003/test.rp2040-ard.yaml @@ -4,5 +4,4 @@ substitutions: packages: uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml - -<<: !include common.yaml + pmsx003: !include common.yaml diff --git a/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml new file mode 100644 index 00000000000..c0c648e0389 --- /dev/null +++ b/tests/components/pmsx003/validate-luftmy.esp32-idf.yaml @@ -0,0 +1,7 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + pmsx003: !include common-luftmy.yaml From f562d7a6a41ed78b166eccac29c6f2b6aaad4605 Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Sun, 11 Oct 2026 15:59:44 -0400 Subject: [PATCH 63/97] [socket] Add join_multicast_group() and set_ipv6_multicast_if() helpers (#17045) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston --- esphome/components/socket/socket.cpp | 207 ++++++++++++++++++ esphome/components/socket/socket.h | 22 ++ .../socket/test-ipv6.bk72xx-ard.yaml | 5 + .../components/socket/test_multicast_host.cpp | 96 ++++++++ 4 files changed, 330 insertions(+) create mode 100644 tests/components/socket/test-ipv6.bk72xx-ard.yaml create mode 100644 tests/components/socket/test_multicast_host.cpp diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index f6d1daa1240..89002cdd19e 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -11,6 +11,16 @@ #ifdef USE_HOST #include "esphome/core/wake.h" #endif +#if defined(USE_HOST) +#include +#include +#elif defined(USE_ZEPHYR) +#ifndef CLANG_TIDY // NCS 2.9.2 net_if.h has a BUILD_ASSERT clang rejects; gone in NCS 3.4 +#include +#endif +#else +#include "lwip/netif.h" +#endif namespace esphome::socket { @@ -139,6 +149,203 @@ std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { } #endif +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +// Callback must be a temporary lambda +template static bool foreach_eligible_ipv6_if(F &&callback) { +#if defined(USE_HOST) + struct ifaddrs *ifaddr; + if (getifaddrs(&ifaddr) != 0) { + return false; + } + bool found_any = false; + for (struct ifaddrs *ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr || ifa->ifa_addr->sa_family != AF_INET6) { + continue; + } + if ((ifa->ifa_flags & IFF_LOOPBACK) || !(ifa->ifa_flags & IFF_UP) || !(ifa->ifa_flags & IFF_MULTICAST)) { + continue; + } + unsigned int idx = if_nametoindex(ifa->ifa_name); + if (idx == 0) { + continue; + } + found_any = true; + if (callback(idx)) { + break; + } + } + freeifaddrs(ifaddr); + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#elif defined(USE_ZEPHYR) && defined(CLANG_TIDY) + (void) callback; + return false; +#elif defined(USE_ZEPHYR) + // Zephyr has no getifaddrs()/ifaddrs.h; enumerate interfaces via the native net_if API. + // NET_IF_IPV6_NO_MLD is the direct analog of the LwIP branch's NETIF_FLAG_MLD6 check below. + struct EligibleIfContext { + F *callback; + bool found_any = false; + bool done = false; + }; + EligibleIfContext ctx{&callback}; + net_if_foreach( + [](struct net_if *iface, void *user_data) { + auto *ctx = static_cast(user_data); + if (ctx->done) { + return; + } + if (!net_if_flag_is_set(iface, NET_IF_UP) || !net_if_flag_is_set(iface, NET_IF_IPV6) || + net_if_flag_is_set(iface, NET_IF_IPV6_NO_MLD)) { + return; + } + int idx = net_if_get_by_iface(iface); + if (idx <= 0) { + return; + } + ctx->found_any = true; + if ((*ctx->callback)(static_cast(idx))) { + ctx->done = true; + } + }, + &ctx); + if (!ctx.found_any) { + errno = EADDRNOTAVAIL; + } + return ctx.found_any; +#else + bool found_any = false; + struct netif *netif; + NETIF_FOREACH(netif) { + if (netif->name[0] == 'l' && netif->name[1] == 'o') { + continue; + } + if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { + continue; + } + found_any = true; + if (callback(netif_get_index(netif))) { + break; + } + } + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#endif +} + +bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out) { + if (strchr(ip_address, ':') == nullptr) { + struct in_addr ipv4mc {}; +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET, ip_address, &ipv4mc) != 1) { +#else + if (inet_aton(ip_address, &ipv4mc) == 0) { +#endif + errno = EINVAL; + return false; + } +#ifdef USE_ZEPHYR + // Zephyr's setsockopt(IP_ADD_MEMBERSHIP) requires exactly sizeof(struct ip_mreqn); it has + // no plain "struct ip_mreq" at all (unlike POSIX/lwIP). + struct ip_mreqn imreq {}; + imreq.imr_address.s_addr = ESPHOME_INADDR_ANY; + imreq.imr_ifindex = 0; +#else + struct ip_mreq imreq {}; + imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; +#endif + imreq.imr_multiaddr = ipv4mc; + if (sock->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)) < 0) { + return false; + } + if (if_index_out != nullptr) { + *if_index_out = 0; + } + return true; + } +#if USE_NETWORK_IPV6 + struct ipv6_mreq imreq6 {}; +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#else + if (inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#endif + errno = EINVAL; + return false; + } +#else + ip6_addr_t ip6; + if (!inet6_aton(ip_address, &ip6)) { + errno = EINVAL; + return false; + } + memcpy(imreq6.ipv6mr_multiaddr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); +#endif + // POSIX: interface=0 fails for link-local multicast (ff02::) and may select loopback for + // other scopes. LwIP: NETIF_NO_INDEX=0 is always rejected. + bool joined = false; + foreach_eligible_ipv6_if([&](unsigned int idx) { +#ifdef USE_ZEPHYR + // This Zephyr fork's struct ipv6_mreq has no "ipv6mr_interface" (POSIX name) and no + // IPV6_JOIN_GROUP alias -- only the RFC 3678 legacy ipv6mr_ifindex/IPV6_ADD_MEMBERSHIP names. + imreq6.ipv6mr_ifindex = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_ADD_MEMBERSHIP, &imreq6, sizeof(imreq6)) == 0) { +#else + imreq6.ipv6mr_interface = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, &imreq6, sizeof(imreq6)) == 0) { +#endif + joined = true; + return true; + } + return false; + }); + if (!joined) { + return false; + } + if (if_index_out != nullptr) { +#ifdef USE_ZEPHYR + *if_index_out = imreq6.ipv6mr_ifindex; +#else + *if_index_out = imreq6.ipv6mr_interface; +#endif + } + return true; +#else + errno = EINVAL; + return false; +#endif // USE_NETWORK_IPV6 +} + +#if USE_NETWORK_IPV6 +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { +#if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + // Upstream lwIP (Zephyr's fork, LibreTiny) has no IPV6_MULTICAST_IF; outgoing + // multicast follows the default netif. Zephyr gains ZSOCK_IPV6_MULTICAST_IF in NCS 3.4.0. + (void) sock; + (void) if_index_in; + return true; +#else + uint32_t ifindex = if_index_in; + if (ifindex == 0) { + foreach_eligible_ipv6_if([&](unsigned int idx) { + ifindex = idx; + return true; + }); + if (ifindex == 0) { + return false; + } + } + return sock->setsockopt(IPPROTO_IPV6, IPV6_MULTICAST_IF, &ifindex, sizeof(ifindex)) == 0; +#endif +} +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 if (strchr(ip_address, ':') != nullptr) { diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 9d7dedbbbf9..d1a70c5aa84 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -94,6 +94,7 @@ inline bool Socket::ready() const { /// Create a socket of the given domain, type and protocol. std::unique_ptr socket(int domain, int type, int protocol); /// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol. +/// Not registered with the main loop's select() - use socket_ip_loop_monitored() if you need that. std::unique_ptr socket_ip(int type, int protocol); /// Create a socket and monitor it for data in the main loop. @@ -149,6 +150,27 @@ inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const st /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +/// Join a multicast group on the given socket. +/// @param sock The socket to join on +/// @param ip_address Null-terminated multicast address string (IPv4 or IPv6) +/// @param if_index_out If non-null, receives the interface index used (IPv4 always writes 0). For +/// link-local IPv6 groups, the caller must also copy this into sin6_scope_id +/// before bind() -- POSIX requires it, LwIP ignores it. +/// @return true on success, false on failure (errno set) +bool join_multicast_group(Socket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); + +#if USE_NETWORK_IPV6 +/// Configure the outgoing IPv6 multicast interface on the given socket. +/// @param sock The socket to configure +/// @param if_index_in Non-zero: use this interface index directly. Zero (default): probe for the first eligible +/// interface via foreach_eligible_ipv6_if(). On single-interface devices (typical for ESP32/ESP8266) this is always +/// correct. On multi-homed hosts the "first eligible" interface is arbitrary; pass the desired index explicitly. +/// @return true on success, false on failure (errno set). A no-op returning true on LWIP_SOCKETS and Zephyr. +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in = 0); +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + /// Poll a connect() that returned EINPROGRESS. On error, err_out is SO_ERROR (or /// errno) on fd implementations and the failure the callbacks recorded on raw lwip. #ifdef USE_SOCKET_IMPL_LWIP_TCP diff --git a/tests/components/socket/test-ipv6.bk72xx-ard.yaml b/tests/components/socket/test-ipv6.bk72xx-ard.yaml new file mode 100644 index 00000000000..6075623b54a --- /dev/null +++ b/tests/components/socket/test-ipv6.bk72xx-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + network_enable_ipv6: "true" + +packages: + socket: !include common.yaml diff --git a/tests/components/socket/test_multicast_host.cpp b/tests/components/socket/test_multicast_host.cpp new file mode 100644 index 00000000000..31fe286a865 --- /dev/null +++ b/tests/components/socket/test_multicast_host.cpp @@ -0,0 +1,96 @@ +#include + +#include + +#include "esphome/components/socket/socket.h" + +#ifdef USE_HOST + +namespace esphome::socket::testing { + +// ========================================================================= +// join_multicast_group — EINVAL on malformed input +// ========================================================================= + +TEST(JoinMulticastGroup, MalformedIPv4YieldsEINVAL) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "not-an-ip")); + EXPECT_EQ(errno, EINVAL); +} + +TEST(JoinMulticastGroup, MalformedIPv6YieldsEINVAL) { + // ':' present but parse fails — EINVAL regardless of USE_NETWORK_IPV6 + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_FALSE(join_multicast_group(sock.get(), "gg::not-ipv6")); + EXPECT_EQ(errno, EINVAL); +} + +// ========================================================================= +// join_multicast_group — IPv4 IP_ADD_MEMBERSHIP +// ========================================================================= + +TEST(JoinMulticastGroup, IPv4JoinSetsIfIndexToZero) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 99; + // A host with no multicast route (e.g. an isolated container) cannot join + if (join_multicast_group(sock.get(), "239.0.60.53", &if_index)) { + EXPECT_EQ(if_index, 0u); + } else { + EXPECT_EQ(if_index, 99u); + } +} + +TEST(JoinMulticastGroup, IPv4JoinNullIfIndexOut) { + auto sock = esphome::socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_TRUE(join_multicast_group(sock.get(), "239.0.60.53", nullptr) || errno != 0); +} + +// ========================================================================= +// join_multicast_group — IPv6 and set_ipv6_multicast_if +// ========================================================================= + +#if USE_NETWORK_IPV6 + +TEST(JoinMulticastGroup, IPv6JoinSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + uint32_t if_index = 0; + bool ok = join_multicast_group(sock.get(), "ff12::cafe", &if_index); + if (ok) { + EXPECT_GT(if_index, 0u); + } else { + EXPECT_EQ(errno, EADDRNOTAVAIL); + } +} + +TEST(SetIPv6MulticastIf, ProbeWithZeroIndexSucceedsOrNoInterface) { + auto sock = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock, nullptr); + errno = 0; + EXPECT_TRUE(set_ipv6_multicast_if(sock.get(), 0) || errno == EADDRNOTAVAIL); +} + +TEST(SetIPv6MulticastIf, ExplicitIndexFromJoinSucceeds) { + auto sock_join = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + auto sock_send = esphome::socket::socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + ASSERT_NE(sock_join, nullptr); + ASSERT_NE(sock_send, nullptr); + uint32_t if_index = 0; + bool joined = join_multicast_group(sock_join.get(), "ff12::cafe", &if_index); + if (joined && if_index != 0) { + EXPECT_TRUE(set_ipv6_multicast_if(sock_send.get(), if_index)); + } +} + +#endif // USE_NETWORK_IPV6 + +} // namespace esphome::socket::testing + +#endif // USE_HOST From 153c51e39f118be0183c3703448d9c8a95fe987c Mon Sep 17 00:00:00 2001 From: sh1der Date: Sun, 11 Oct 2026 22:24:50 +0200 Subject: [PATCH 64/97] [gree] Keep display light on by default for YAN/YAA/YAC/YAC1FB9 (#18516) 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/gree/gree.cpp | 2 -- esphome/components/gree/gree.h | 8 ++++++- esphome/components/gree/switch/__init__.py | 25 ++++++++++++++++------ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index f47be4aea03..0e02f20080c 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -37,7 +37,6 @@ void GreeClimate::transmit_state() { remote_state[1] = this->temperature_(); if (model == GREE_YAN) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[4] = this->vertical_swing_(); } @@ -61,7 +60,6 @@ void GreeClimate::transmit_state() { } if (model == GREE_YAA || model == GREE_YAC || model == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index c2616295735..a85878e764d 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00; static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9. +static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10; +static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20; +static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40; +static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80; + // Model codes enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; - uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] + uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise }; } // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 9bec3751d6b..b48d32b9001 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -17,12 +17,22 @@ CONF_HEALTH = "health" CONF_XFAN = "xfan" CONF_GREE_ID = "gree_id" -# Switch configurations: (config_key, display_name, bit_mask, icon) +# Switch configurations: (config_key, display_name, mode_bit, icon) SWITCH_CONFIGS = ( - (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), - (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), - (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), - (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), + ( + CONF_TURBO, + "Gree Turbo Switch", + gree_ns.GREE_MODE_BIT_TURBO, + "mdi:car-turbocharger", + ), + (CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"), + ( + CONF_XFAN, + "Gree X-FAN Switch", + gree_ns.GREE_MODE_BIT_XFAN, + "mdi:wall-sconce-flat", + ), ) SUPPORTED_MODELS = { @@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(key): switch.switch_schema( GreeModeBitSwitch, icon=icon, - default_restore_mode="RESTORE_DEFAULT_OFF", + # The display light was on before the switches existed, so it stays on by default + default_restore_mode="RESTORE_DEFAULT_ON" + if key == CONF_LIGHT + else "RESTORE_DEFAULT_OFF", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, ) From bea0ef264aad87fa2be22b3789920d1c6b6eaf46 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:36:32 +1100 Subject: [PATCH 65/97] [lvgl] Migrate to 9.6.0 (#20257) Co-authored-by: Claude --- esphome/components/lvgl/__init__.py | 40 +++++-- esphome/components/lvgl/automation.py | 5 +- .../components/lvgl/binary_sensor/__init__.py | 4 +- esphome/components/lvgl/defines.py | 27 ++++- esphome/components/lvgl/lvgl_esphome.cpp | 35 +++++- esphome/components/lvgl/lvgl_esphome.h | 3 + esphome/components/lvgl/schemas.py | 1 - esphome/components/lvgl/trigger.py | 6 +- esphome/components/lvgl/widgets/__init__.py | 106 +++++++++--------- esphome/components/lvgl/widgets/arc.py | 4 +- esphome/components/lvgl/widgets/canvas.py | 2 +- esphome/components/lvgl/widgets/lv_list.py | 3 +- esphome/components/lvgl/widgets/meter.py | 3 +- esphome/components/lvgl/widgets/msgbox.py | 7 +- esphome/idf_component.yml | 2 +- platformio.ini | 4 +- .../lvgl/config/lv_conf_check_second.yaml | 41 +++++++ .../lvgl/config/lv_conf_check_warn.yaml | 27 +++++ .../lvgl/config/lv_conf_defaults.yaml | 26 +++++ .../lvgl/config/lv_conf_options.yaml | 29 +++++ tests/component_tests/lvgl/test_list.py | 5 +- tests/component_tests/lvgl/test_lv_conf.py | 80 +++++++++++++ .../lvgl/test_multi_conf_validate.py | 11 +- tests/components/lvgl/lvgl-package.yaml | 11 ++ .../lvgl/test-idf6.esp32-p4-idf.yaml | 22 ++++ tests/components/lvgl/test.esp32-p4-idf.yaml | 1 + tests/components/lvgl/test.host.yaml | 7 +- .../integration/test_lvgl_headless_render.py | 2 +- 28 files changed, 413 insertions(+), 101 deletions(-) create mode 100644 tests/component_tests/lvgl/config/lv_conf_check_second.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_check_warn.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_defaults.yaml create mode 100644 tests/component_tests/lvgl/config/lv_conf_options.yaml create mode 100644 tests/component_tests/lvgl/test_lv_conf.py create mode 100644 tests/components/lvgl/test-idf6.esp32-p4-idf.yaml diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 66aaac5301f..9b2f41e0635 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, ) from esphome.components.image import ( CONF_OPAQUE, @@ -158,7 +159,7 @@ def as_macro(macro, value): return f"#define {macro} {value}" -LVGL_VERSION = "9.5.0" +LVGL_VERSION = "9.6.0" LV_CONF_FILENAME = "lv_conf.h" LV_CONF_H_FORMAT = """\ #pragma once @@ -324,10 +325,16 @@ async def to_code(configs): # Skip compiling lvgl examples add_idf_sdkconfig_option("CONFIG_LV_BUILD_EXAMPLES", False) add_idf_sdkconfig_option("CONFIG_LV_BUILD_DEMOS", False) + # Match LV_USE_STDLIB_MALLOC in lv_conf.h. This also drops the builtin malloc + # options, which LVGL 9.5 builds leave set to deprecated values. + add_idf_sdkconfig_option("CONFIG_LV_USE_CUSTOM_MALLOC", True) if get_esp32_variant() == VARIANT_ESP32P4: add_idf_sdkconfig_option("CONFIG_LV_DRAW_BUF_ALIGN", 64) - # disable use of PPA for fills until upstream bugs fixed - df.add_define("LV_USE_PPA", "0") + # The LVGL PPA driver uses cache functions added in ESP-IDF 6.0 + if idf_version() >= cv.Version(6, 0, 0): + df.add_define("LV_USE_PPA", "1") + else: + df.add_define("LV_USE_PPA", "0") df.add_define("LV_DRAW_BUF_ALIGN", "64") else: df.add_define("LV_DRAW_BUF_ALIGN", "32") @@ -337,6 +344,8 @@ async def to_code(configs): cg.add_library("lvgl/lvgl", LVGL_VERSION) 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 + df.add_define("LV_DRAW_SW_DRAW_UNIT_CNT", "1") df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") @@ -356,15 +365,22 @@ async def to_code(configs): "LVGL_LOG_LEVEL", cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) - df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) + # Big-endian displays get LVGL to render directly in swapped byte order + display_format = ( + "RGB565_SWAPPED" if config_0[CONF_BYTE_ORDER] == BYTE_ORDER_BIG else "RGB565" + ) + df.add_define("LV_COLOR_FORMAT_DEFAULT", f"LV_COLOR_FORMAT_{display_format}") for font in df.get_lv_fonts_used(): df.add_define(f"LV_FONT_{font.upper()}") - - if config_0[CONF_COLOR_DEPTH] == 16: + df.add_define("LV_OBJ_STYLE_CACHE", "1") + if any(config[df.CONF_CHECK_ARGS] for config in configs): + df.add_define("LV_USE_CHECK_ARG", "1") df.add_define( - "LV_COLOR_16_SWAP", - "1" if config_0[CONF_BYTE_ORDER] == "big_endian" else "0", + "LV_CHECK_ARG_LOG_MODE", + f"LV_CHECK_ARG_LOG_MODE_{df.LV_CHECK_ARG_LOG_MODES[config_0[CONF_LOG_LEVEL]]}", ) + else: + df.add_define("LV_USE_CHECK_ARG", "0") df.add_define( "LV_COLOR_CHROMA_KEY", await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]), @@ -504,8 +520,9 @@ async def to_code(configs): if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): df.add_define("LV_THEME_DEFAULT_DARK", "1") - # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending - lv_image_formats = {"RGB565", "ARGB8888"} + # The display buffer format is always needed, RGB565 is used by canvas buffers and + # ARGB8888 for layer blending + lv_image_formats = {"RGB565", display_format, "ARGB8888"} for image_id in get_lv_images_used(): await cg.get_variable(image_id) @@ -519,7 +536,7 @@ async def to_code(configs): if image_type == ImageRGB565: lv_image_formats.add("RGB565A8" if transparent else "RGB565") if image_type == ImageRGB: - lv_image_formats.add("ARGB8888" if transparent else "RGB8888") + lv_image_formats.add("ARGB8888" if transparent else "RGB888") if df.is_defined("LV_GRADIENT_MAX_STOPS"): lv_image_formats.add("RGB888") for fmt in lv_image_formats: @@ -595,6 +612,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), + cv.Optional(df.CONF_CHECK_ARGS, default=False): cv.boolean, cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index d797688a7e8..4e8698f33c0 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTION, CONF_GROUP, + CONF_HIDDEN, CONF_ID, CONF_POSITION, CONF_ROTATION, @@ -354,7 +355,7 @@ async def obj_enable_to_code(config, action_id, template_arg, args): ) async def obj_hide_to_code(config, action_id, template_arg, args): async def do_hide(widget: Widget): - widget.add_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, True) widgets = [widget.outer or widget for widget in await get_widgets(config)] return await action_to_code(widgets, do_hide, action_id, template_arg, args) @@ -365,7 +366,7 @@ async def obj_hide_to_code(config, action_id, template_arg, args): ) async def obj_show_to_code(config, action_id, template_arg, args): async def do_show(widget: Widget): - widget.clear_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, False) if widget.move_to_foreground: lv_obj.move_foreground(widget.obj) diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index fc7317f8b97..e658735c750 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -6,7 +6,7 @@ from esphome.components.binary_sensor import ( import esphome.config_validation as cv from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +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 @@ -41,7 +41,7 @@ async def to_code(config): ctx.add(sensor.publish_initial_state(test_expr)) if is_pressed: events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] - widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + widget.set_flag(CONF_CLICKABLE, True) else: events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index c019dff96e0..854ef0fb494 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -477,6 +477,16 @@ LV_LOG_LEVELS = { "NONE": "NONE", } +# Log output for failed LVGL argument checks, chosen from the LVGL log level +LV_CHECK_ARG_LOG_MODES = { + "VERBOSE": "VERBOSE", + "DEBUG": "VERBOSE", + "INFO": "MINIMAL", + "WARN": "MINIMAL", + "ERROR": "NONE", + "NONE": "NONE", +} + LV_LONG_MODES = LvConstant( "LV_LABEL_LONG_", "WRAP", @@ -606,13 +616,20 @@ OBJ_FLAGS = ( "ignore_layout", "floating", "overflow_visible", + "send_draw_task_events", + "event_trickle", + "state_trickle", + "radio_button", + "flex_in_new_track", "layout_1", "layout_2", - "send_draw_task_events", "widget_1", "widget_2", ) -LV_OBJ_FLAG = LvConstant("LV_OBJ_FLAG_", *OBJ_FLAGS) +# Flags whose lv_obj_set_() setter has a different name +FLAG_SETTER_ALIASES = {"layout_1": "flex_in_new_track"} +# Custom flags that have no lv_obj_set_() setter +CUSTOM_FLAGS = ("layout_2", "widget_1", "widget_2") ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL") BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") @@ -703,6 +720,7 @@ CONF_DISP_BG_OPA = "disp_bg_opa" CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" +CONF_CLICKABLE = "clickable" CONF_CLOSE_BUTTON = "close_button" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" @@ -727,6 +745,7 @@ CONF_FLEX_ALIGN_CROSS = "flex_align_cross" CONF_FLEX_ALIGN_TRACK = "flex_align_track" CONF_FLEX_GROW = "flex_grow" CONF_FREEZE = "freeze" +CONF_CHECK_ARGS = "check_args" CONF_DARK_MODE = "dark_mode" CONF_FULL_REFRESH = "full_refresh" CONF_GRADIENTS = "gradients" @@ -795,6 +814,7 @@ CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" @@ -891,7 +911,8 @@ def get_part_state_selector(part: str, state: str) -> MockObj: # fmt: off LV_COLOR_FORMATS = ( - "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", + "RGB565", "RGB565_SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "ARGB8888_PREMULTIPLIED", "L8", "AL88", + "A8", "I1", ) LV_DEFINES = ( diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index e35cc0c48bf..0b375404d74 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -6,6 +6,7 @@ #include "core/lv_global.h" #include "core/lv_obj_class_private.h" +#include "core/lv_obj_private.h" #include @@ -225,6 +226,36 @@ void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_ev add_event_cb(obj, callback, event3, user_data); } +void LvglComponent::lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value) { + // LVGL 9.6 keeps these flags in spec_attr->user_flags, in the same bits as its deprecated lv_obj_add_flag() + uint8_t mask; + switch (flag) { + case LV_OBJ_FLAG_LAYOUT_2: + mask = 1 << 1; + break; + case LV_OBJ_FLAG_WIDGET_1: + mask = 1 << 2; + break; + case LV_OBJ_FLAG_WIDGET_2: + mask = 1 << 3; + break; + default: + return; + } + if (value) { + lv_obj_spec_attr_t *spec_attr = lv_obj_allocate_spec_attr(obj); + if (spec_attr == nullptr) + return; + spec_attr->user_flags |= mask; + } else if (obj->spec_attr != nullptr) { + obj->spec_attr->user_flags &= ~mask; + } + if (flag == LV_OBJ_FLAG_LAYOUT_2) { + lv_obj_mark_layout_as_dirty(lv_obj_get_parent(obj)); + lv_obj_mark_layout_as_dirty(obj); + } +} + void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); @@ -822,7 +853,7 @@ void LvglComponent::setup() { } this->draw_buf_ = static_cast(buffer); this->set_resolution_(); - lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_RGB565); + lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_DEFAULT); lv_display_set_flush_cb(this->disp_, static_flush_cb); lv_display_set_user_data(this->disp_, this); lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); @@ -994,7 +1025,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) { static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) { LV_TRACE_OBJ_CREATE("begin"); - lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_clickable(obj, false); LV_UNUSED(class_p); } diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8b7397c4cd2..4da8a6a1a30 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -269,6 +269,9 @@ class LvglComponent final : public PollingComponent { static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3, void *user_data = nullptr); + // Set or clear LV_OBJ_FLAG_LAYOUT_2, LV_OBJ_FLAG_WIDGET_1 or LV_OBJ_FLAG_WIDGET_2, which have no setter in LVGL 9.6 + static void lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value); + // change the state of a widget and fire an event if changed (only needed for CHECKED) static void lv_obj_set_state_value(lv_obj_t *obj, lv_state_t state, bool value) { diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 924c17fb547..2d879e8d43a 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -391,7 +391,6 @@ SET_STATE_SCHEMA = cv.Schema( ) # Setting object flags FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS}) -FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of) VALUE_TRIGGER_SCHEMA = { cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of( diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 56dcf81a797..cd118595f1a 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -17,6 +17,8 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, + CONF_CLICKABLE, + CONF_SCROLLABLE, DIRECTIONS, LV_DISPLAY_EVENT_MAP, LV_DISPLAY_EVENT_TRIGGERS, @@ -72,7 +74,7 @@ async def generate_triggers(): event: conf for event, conf in config.items() if event in all_triggers }.items(): conf = conf[0] - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger(conf, w, event) for event, conf in { @@ -82,7 +84,7 @@ async def generate_triggers(): dir = event[9:].upper() dir = {"UP": "TOP", "DOWN": "BOTTOM"}.get(dir, dir) dir = DIRECTIONS.mapper(dir) - w.clear_flag("LV_OBJ_FLAG_SCROLLABLE") + w.set_flag(CONF_SCROLLABLE, False) selected = literal( f"lv_indev_get_gesture_dir(lv_indev_active()) == {dir}" ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index d69e90056fd..b48123b9022 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable import sys from typing import Any @@ -37,6 +37,8 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + CUSTOM_FLAGS, + FLAG_SETTER_ALIASES, LOGGER, OBJ_FLAGS, PARTS, @@ -47,13 +49,11 @@ from ..defines import ( get_part_state_selector, get_styles_used, get_widget_map, - join_enums, literal, next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( - LvConditional, add_line_marks, lv, lv_add, @@ -295,7 +295,9 @@ class Widget: This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ - def __init__(self, var, wtype: WidgetType, config: dict = None): + def __init__( + self, var: MockObj | ID, wtype: WidgetType, config: dict | None = None + ) -> None: self.var = var self.type = wtype self.config = config @@ -314,39 +316,50 @@ class Widget: self.parent = None @staticmethod - def create(name, var, wtype: WidgetType, config: dict = None): + def create( + name: ID | str, var: MockObj, wtype: WidgetType, config: dict | None = None + ) -> "Widget": w = Widget(var, wtype, config) get_widget_map()[name] = w return w - def set_state(self, state: MockObj, value: bool | Expression): + def set_state(self, state: MockObj, value: bool | Expression) -> None: lv_add(lvgl_static.lv_obj_set_state_value(self.obj, state, value)) - def has_state(self, state: MockObj): + def has_state(self, state: MockObj) -> MockObj: return lv_expr.obj_has_state(self.obj, state) - def is_pressed(self): + def is_pressed(self) -> MockObj: return self.has_state(LV_STATE.PRESSED) - def is_checked(self): + def is_checked(self) -> MockObj: return self.has_state(LV_STATE.CHECKED) - def add_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.add_flag(self.obj, literal(flag)) + def set_flag(self, flag: str, value: bool | Expression) -> None: + """ + Set or clear an object flag, given as the lower case name without the LV_OBJ_FLAG_ prefix + """ + flag = FLAG_SETTER_ALIASES.get(flag, flag) + if flag in CUSTOM_FLAGS: + lv_add( + lvgl_static.lv_obj_set_custom_flag( + self.obj, literal(f"LV_OBJ_FLAG_{flag.upper()}"), value + ) + ) + else: + lv_obj.call(f"set_{flag}", self.obj, value) - def clear_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.remove_flag(self.obj, literal(flag)) - - def add_style(self, style_id, state=LV_STATE.DEFAULT): + def add_style(self, style_id: ID, state: MockObj | str = LV_STATE.DEFAULT) -> None: lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( - self, prop, value, animated: bool = None, lv_name=None, processor=None - ): + self, + prop: str, + value: Any, + animated: bool | None = None, + lv_name: str | None = None, + processor: LValidator | Callable[[Any], Awaitable[Any]] | None = None, + ) -> None: """ Set a property of the widget. :param prop: The property name @@ -387,11 +400,13 @@ class Widget: literal("LV_ANIM_ON" if animated else "LV_ANIM_OFF"), ) - def get_property(self, prop, ltype=None): + def get_property(self, prop: str, ltype: str | None = None) -> cg.RawExpression: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop: str, value, state=LV_STATE.DEFAULT): + def set_style( + self, prop: str, value: Any, state: MockObj | str = LV_STATE.DEFAULT + ) -> None: if value is None: return get_styles_used().add(prop) @@ -399,22 +414,22 @@ class Widget: value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) - def __type_base(self): + def __type_base(self) -> str: wtype = self.type.w_type base = str(wtype) if base.startswith("Lv"): return f"{wtype}".removeprefix("Lv").removesuffix("Type").lower() return f"{wtype}".removeprefix("lv_").removesuffix("_t") - def __str__(self): + def __str__(self) -> str: return f"({self.var}, {self.type})" - def get_args(self): + def get_args(self) -> list[tuple[MockObj, str]]: if isinstance(self.type.w_type, LvType): return self.type.w_type.args.copy() return [(lv_obj_t_ptr, "obj")] - def get_value(self): + def get_value(self) -> Expression: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -422,7 +437,7 @@ class Widget: return result return self.obj - def get_values(self): + def get_values(self) -> list[Expression]: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -430,13 +445,13 @@ class Widget: return [result] return [self.obj] - def get_number_value(self): + def get_number_value(self) -> Expression: value = self.type.mock_obj.get_value(self.obj) if self.scale == 1.0: return value return value / float(self.scale) - def is_selected(self): + def is_selected(self) -> MockObj | None: """ Overridable property to determine if the widget is selected. Will be None except for matrix buttons @@ -444,7 +459,7 @@ class Widget: """ return - def get_scale(self): + def get_scale(self) -> float: return self.type.get_scale(self.config) @@ -630,29 +645,14 @@ async def set_obj_properties(w: Widget, config): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) props = parts[CONF_MAIN][CONF_DEFAULT] - lambs = {} - flag_set = set() - flag_clr = set() - for prop, value in {k: v for k, v in props.items() if k in OBJ_FLAGS}.items(): + for flag, value in props.items(): + if flag not in OBJ_FLAGS: + continue if isinstance(value, cv.Lambda): - lambs[prop] = value - elif value: - flag_set.add(prop) - else: - flag_clr.add(prop) - if flag_set: - adds = join_enums(flag_set, "LV_OBJ_FLAG_") - w.add_flag(adds) - if flag_clr: - clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") - w.clear_flag(clrs) - for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) - flag = f"LV_OBJ_FLAG_{key.upper()}" - with LvConditional(call_lambda(lamb)) as cond: - w.add_flag(flag) - cond.else_() - w.clear_flag(flag) + value = call_lambda( + await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) + ) + w.set_flag(flag, value) for key, value in config.get(CONF_STATE, {}).items(): if isinstance(value, cv.Lambda): diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index ac993cc3829..4739412e09e 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -13,12 +13,12 @@ from ..defines import ( ARC_MODES, CONF_ADJUSTABLE, CONF_CHANGE_RATE, + CONF_CLICKABLE, CONF_END_ANGLE, CONF_INDICATOR, CONF_KNOB, CONF_MAIN, CONF_START_ANGLE, - LV_OBJ_FLAG, LV_PART, ) from ..lv_validation import ( @@ -85,7 +85,7 @@ class ArcType(NumberType): if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) - w.clear_flag(LV_OBJ_FLAG.CLICKABLE) + w.set_flag(CONF_CLICKABLE, False) elif CONF_GROUP not in config: # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) diff --git a/esphome/components/lvgl/widgets/canvas.py b/esphome/components/lvgl/widgets/canvas.py index 2137acb3f6a..9dd5e84419e 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -99,7 +99,7 @@ class CanvasType(WidgetType): if config[CONF_TRANSPARENT]: color_format = "LV_COLOR_FORMAT_ARGB8888" else: - color_format = "LV_COLOR_FORMAT_NATIVE" + color_format = "LV_COLOR_FORMAT_RGB565" # LVGL 9.4: LV_CANVAS_BUF_SIZE(width, height, bits_per_pixel, stride) # stride is 0 for default (width * bytes_per_pixel) diff --git a/esphome/components/lvgl/widgets/lv_list.py b/esphome/components/lvgl/widgets/lv_list.py index f41cb8e4e96..98e1838f0f3 100644 --- a/esphome/components/lvgl/widgets/lv_list.py +++ b/esphome/components/lvgl/widgets/lv_list.py @@ -20,6 +20,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from ..automation import action_to_code from ..defines import ( CONF_ALIGN_TO, + CONF_CLICKABLE, CONF_MAIN, CONF_PAD_ROW, CONF_SCROLLBAR, @@ -441,7 +442,7 @@ async def _wire_dynamic_triggers(w: Widget, config: dict) -> None: for event, conf in { event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS }.items(): - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger( conf[0], event_target, event, attach_obj=w.obj, user_data=user_data ) diff --git a/esphome/components/lvgl/widgets/meter.py b/esphome/components/lvgl/widgets/meter.py index fedc79383dc..a49d58dc4db 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -38,7 +38,6 @@ from ..defines import ( CONF_SRC, CONF_START_VALUE, CONF_TICKS, - LV_OBJ_FLAG, LV_PART, LV_SCALE_MODE, add_lv_use, @@ -457,7 +456,7 @@ class MeterType(WidgetType): LV_EVENT.DRAW_TASK_ADDED, nullptr, ) - lv.obj_add_flag(scale_var, LV_OBJ_FLAG.SEND_DRAW_TASK_EVENTS) + lv_obj.set_send_draw_task_events(scale_var, True) if t == CONF_LINE: # Needle represented by a line diff --git a/esphome/components/lvgl/widgets/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index 29087009cb9..44d16bf6141 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -1,5 +1,5 @@ from esphome import codegen as cg, config_validation as cv -from esphome.const import CONF_BUTTON, CONF_ID, CONF_TEXT +from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT from esphome.core import ID from esphome.cpp_generator import MockObjClass from esphome.cpp_types import nullptr @@ -14,7 +14,6 @@ from ..defines import ( CONF_MSGBOXES, CONF_SRC, CONF_TITLE, - LV_OBJ_FLAG, TYPE_FLEX, add_lv_use, add_warning, @@ -141,7 +140,7 @@ async def msgbox_to_code(top_layer, conf): percent100 = await pixels_or_percent.process(1.0) lv_obj.set_size(outer, percent100, percent100) outer_widget.add_style(await OUTER_STYLE.get_var()) - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) lv.msgbox_add_title(msgbox, title) lv.msgbox_add_text(msgbox, text) lv_obj.set_style_align(msgbox, literal("LV_ALIGN_CENTER"), 0) @@ -153,7 +152,7 @@ async def msgbox_to_code(top_layer, conf): await widget_to_code(button, header_button_spec, msgbox) async with LambdaContext(EVENT_ARG, where=messagebox_id) as close_action: - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) if close_button: with LocalVariable( "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 8ac76cd41fa..670145cfe01 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -107,7 +107,7 @@ dependencies: rules: - if: "$ESPHOME_ARDUINO_COMPONENT == 0" lvgl/lvgl: - version: 9.5.0 + version: 9.6.0 fastled/FastLED: git: https://github.com/FastLED/FastLED.git version: d44c800a9e876a8394caefc2ce4915dd96dac77b diff --git a/platformio.ini b/platformio.ini index 7534b748d1b..7b3414a32a7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -108,7 +108,7 @@ build_unflags = lib_deps = esphome/dlms_parser@2.2.0 ; dlms_meter bblanchon/ArduinoJson@7.4.3 ; json - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl ; This are common settings for the ESP8266 using Arduino. [common:esp8266-arduino] @@ -646,7 +646,7 @@ extends = common platform = platformio/native lib_deps = esphome/noise-c@0.1.31 ; used by noise (api, ota) - lvgl/lvgl@9.5.0 ; lvgl + lvgl/lvgl@9.6.0 ; lvgl build_flags = ${common.build_flags} -DUSE_HOST diff --git a/tests/component_tests/lvgl/config/lv_conf_check_second.yaml b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml new file mode 100644 index 00000000000..a5ccd0e68d4 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_second.yaml @@ -0,0 +1,41 @@ +esphome: + name: test-lv-conf-check-second + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + id: display_a + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + - platform: mipi_spi + id: display_b + data_rate: 20MHz + model: st7735 + cs_pin: GPIO4 + dc_pin: GPIO5 + +lvgl: + - id: lvgl_a + displays: display_a + widgets: + - label: + text: Hello + - id: lvgl_b + displays: display_b + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml new file mode 100644 index 00000000000..769032f8393 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_check_warn.yaml @@ -0,0 +1,27 @@ +esphome: + name: test-lv-conf-check-warn + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + check_args: true + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_defaults.yaml b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml new file mode 100644 index 00000000000..94950a3b438 --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_defaults.yaml @@ -0,0 +1,26 @@ +esphome: + name: test-lv-conf-defaults + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/config/lv_conf_options.yaml b/tests/component_tests/lvgl/config/lv_conf_options.yaml new file mode 100644 index 00000000000..969091c086a --- /dev/null +++ b/tests/component_tests/lvgl/config/lv_conf_options.yaml @@ -0,0 +1,29 @@ +esphome: + name: test-lv-conf-options + +esp32: + board: lolin_c3_mini + +spi: + mosi_pin: + number: GPIO2 + ignore_strapping_warning: true + clk_pin: GPIO1 + +display: + - platform: mipi_spi + data_rate: 20MHz + model: st7735 + byte_order: little_endian + cs_pin: + number: GPIO8 + ignore_strapping_warning: true + dc_pin: + number: GPIO3 + +lvgl: + check_args: true + log_level: DEBUG + widgets: + - label: + text: Hello diff --git a/tests/component_tests/lvgl/test_list.py b/tests/component_tests/lvgl/test_list.py index 368a3a49905..e93d858ad5c 100644 --- a/tests/component_tests/lvgl/test_list.py +++ b/tests/component_tests/lvgl/test_list.py @@ -287,10 +287,7 @@ def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None: def test_add_button_with_checkable_flag(main_cpp: str) -> None: assert "lv_obj_t *dyn_button_VAR_ = lv_button_create(test_list);" in main_cpp - assert ( - "lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));" - in main_cpp - ) + assert "lv_obj_set_checkable(dyn_button_VAR_, true);" in main_cpp assert ( 'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp ) diff --git a/tests/component_tests/lvgl/test_lv_conf.py b/tests/component_tests/lvgl/test_lv_conf.py new file mode 100644 index 00000000000..8b93c011661 --- /dev/null +++ b/tests/component_tests/lvgl/test_lv_conf.py @@ -0,0 +1,80 @@ +"""Tests for the LVGL configuration defines written to lv_conf.h.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.components.lvgl import defines as df +from esphome.components.lvgl.schemas import WIDGET_TYPES + +# LVGL 9.6 stops the build with #error when an enabled option "selects" another +# option that is disabled. ESPHome sets every unused LV_USE_* to 0, so each widget +# must list these in get_uses(). +LVGL_SELECTS = ( + ("SLIDER", "BAR"), + ("BARCODE", "CANVAS"), + ("QRCODE", "CANVAS"), + ("CANVAS", "IMAGE"), + ("DROPDOWN", "LABEL"), + ("MSGBOX", "LABEL"), + ("ROLLER", "LABEL"), + ("TEXTAREA", "LABEL"), +) + + +def test_default_lv_conf_defines( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("lv_conf_defaults.yaml")) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == "LV_COLOR_FORMAT_RGB565_SWAPPED" + assert defines["LV_DRAW_SW_SUPPORT_RGB565_SWAPPED"] == "1" + assert defines["LV_OBJ_STYLE_CACHE"] == "1" + assert defines["LV_USE_CHECK_ARG"] == "0" + assert "LV_CHECK_ARG_LOG_MODE" not in defines + # Both are deprecated in LVGL 9.6 and produce build warnings + assert "LV_COLOR_DEPTH" not in defines + assert "LV_COLOR_16_SWAP" not in defines + + +@pytest.mark.parametrize( + ("yaml_file", "color_format", "log_mode"), + [ + ("lv_conf_options.yaml", "RGB565", "VERBOSE"), + ("lv_conf_check_warn.yaml", "RGB565_SWAPPED", "MINIMAL"), + # Only the second LVGL instance sets check_args + ("lv_conf_check_second.yaml", "RGB565_SWAPPED", "MINIMAL"), + ], +) +def test_lv_conf_options( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + yaml_file: str, + color_format: str, + log_mode: str, +) -> None: + generate_main(component_config_path(yaml_file)) + defines = df.get_defines() + assert defines["LV_COLOR_FORMAT_DEFAULT"] == f"LV_COLOR_FORMAT_{color_format}" + assert defines["LV_USE_CHECK_ARG"] == "1" + assert defines["LV_CHECK_ARG_LOG_MODE"] == f"LV_CHECK_ARG_LOG_MODE_{log_mode}" + + +def test_check_arg_log_modes_cover_all_log_levels() -> None: + assert df.LV_CHECK_ARG_LOG_MODES.keys() == df.LV_LOG_LEVELS.keys() + + +@pytest.mark.parametrize("widget_name", sorted(WIDGET_TYPES)) +def test_widget_uses_satisfy_lvgl_selects(widget_name: str) -> None: + widget_type = WIDGET_TYPES[widget_name] + df.add_lv_use(widget_type.name, *widget_type.get_uses()) + enabled = {use.upper() for use in df.get_lv_uses()} + for option, required in LVGL_SELECTS: + if option in enabled: + assert required in enabled, ( + f"'{widget_name}' enables LV_USE_{option}, which needs LV_USE_{required}" + ) diff --git a/tests/component_tests/lvgl/test_multi_conf_validate.py b/tests/component_tests/lvgl/test_multi_conf_validate.py index c44b40317e8..9ec73ca6182 100644 --- a/tests/component_tests/lvgl/test_multi_conf_validate.py +++ b/tests/component_tests/lvgl/test_multi_conf_validate.py @@ -10,11 +10,15 @@ from esphome.config_validation import Invalid def _config( - displays: list[str], theme: dict | None = None, debug_outline: bool = False + displays: list[str], + theme: dict | None = None, + debug_outline: bool = False, + check_args: bool = False, ) -> dict: config = { df.CONF_DISPLAYS: displays, "log_level": "WARN", + df.CONF_CHECK_ARGS: check_args, "color_depth": 16, "byte_order": "big_endian", df.CONF_TRANSPARENCY_KEY: 0x000400, @@ -73,3 +77,8 @@ class TestDebugOutlineOnMultipleInstances: _config(["disp_b"], debug_outline=True), ] multi_conf_validate(configs) + + +def test_check_args_may_differ_between_instances() -> None: + configs = [_config(["disp_a"], check_args=True), _config(["disp_b"])] + multi_conf_validate(configs) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index 18a7fd0913a..681eb41b5b5 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -53,6 +53,7 @@ lvgl: id: lvgl_id rotation: 90 log_level: debug + check_args: true resume_on_input: true paused: true update_when_display_idle: true @@ -976,6 +977,16 @@ lvgl: - spinner: align: right_mid send_draw_task_events: true + event_trickle: true + state_trickle: true + radio_button: true + flex_in_new_track: !lambda return true; + - spinner: + align: top_mid + layout_1: true + layout_2: true + widget_1: !lambda return false; + widget_2: false - image: id: lv_image src: cat_image diff --git a/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml new file mode 100644 index 00000000000..e0332caaed0 --- /dev/null +++ b/tests/components/lvgl/test-idf6.esp32-p4-idf.yaml @@ -0,0 +1,22 @@ +# LVGL only uses the PPA to draw on the ESP32-P4 with ESP-IDF 6.0 or later +esp32: + framework: + version: 6.0.1 + +display: + - platform: mipi_dsi + model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C + +lvgl: + widgets: + - obj: + bg_color: 0x2040C0 + widgets: + - label: + text: PPA + +psram: + +esp_ldo: + - channel: 3 + voltage: 2.5V diff --git a/tests/components/lvgl/test.esp32-p4-idf.yaml b/tests/components/lvgl/test.esp32-p4-idf.yaml index 5fd93702554..a7a8d13ffc5 100644 --- a/tests/components/lvgl/test.esp32-p4-idf.yaml +++ b/tests/components/lvgl/test.esp32-p4-idf.yaml @@ -3,6 +3,7 @@ display: model: WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C lvgl: byte_order: little_endian + check_args: true rotation: 90 psram: diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 22d051da8eb..1df27d82f73 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -1,5 +1,5 @@ display: - - platform: sdl + - platform: snapshot id: sdl0 auto_clear_enabled: false dimensions: @@ -11,11 +11,6 @@ display: width: 480 height: 480 -touchscreen: - - platform: sdl - display: sdl0 - sdl_id: sdl0 - lvgl: - id: lvgl_0 default_font: space16 diff --git a/tests/integration/test_lvgl_headless_render.py b/tests/integration/test_lvgl_headless_render.py index 1c60e49604d..5a668a23027 100644 --- a/tests/integration/test_lvgl_headless_render.py +++ b/tests/integration/test_lvgl_headless_render.py @@ -30,7 +30,7 @@ EXPECTED_SHA256 = "a995b002dd1d183c47514da15ab9a60a3e7d788c2e24386a02fddd4865509 # Bundled LVGL version (esphome/components/lvgl/__init__.py, LVGL_VERSION) the hash above was # generated against. A version bump can shift anti-aliasing enough to change the hash even though # nothing is actually wrong -- if this test fails, check that first before regenerating the hash. -EXPECTED_LVGL_VERSION = "9.5.0" +EXPECTED_LVGL_VERSION = "9.6.0" @pytest.mark.asyncio From 2ab06fa937d93af213307efab50f2e8360d4f519 Mon Sep 17 00:00:00 2001 From: Max Khrul'ov Date: Sun, 11 Oct 2026 23:39:22 +0300 Subject: [PATCH 66/97] [pipsolar] Add QET/QLT total energy sensors (#17347) Co-authored-by: Claude Opus 4.8 Co-authored-by: J. Nick Koston --- esphome/components/pipsolar/pipsolar.cpp | 17 +++++++++++++++++ esphome/components/pipsolar/pipsolar.h | 11 +++++++++++ .../components/pipsolar/sensor/__init__.py | 19 +++++++++++++++++++ .../pipsolar/text_sensor/__init__.py | 4 ++++ tests/components/pipsolar/common.yaml | 12 ++++++++++++ 5 files changed, 63 insertions(+) diff --git a/esphome/components/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index ade93b87359..ea7ebaed0ae 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -286,6 +286,12 @@ void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const c case POLLING_QMN: handle_qmn_(message); break; + case POLLING_QET: + handle_single_int_(message, this->last_qet_, this->total_pv_generated_energy_); + break; + case POLLING_QLT: + handle_single_int_(message, this->last_qlt_, this->total_output_load_energy_); + break; default: break; } @@ -688,6 +694,17 @@ void Pipsolar::handle_qmn_(const char *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); + } + + size_t pos = 0; + this->skip_start_(message, &pos); + this->read_int_sensor_(message, &pos, value); +} + void Pipsolar::skip_start_(const char *message, size_t *pos) { if (message[*pos] == '(') { (*pos)++; diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 06c920a6e49..4695e39829a 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -19,6 +19,8 @@ enum ENUMPollingCommand { POLLING_QPIWS = 4, POLLING_QT = 5, POLLING_QMN = 6, + POLLING_QET = 7, + POLLING_QLT = 8, }; struct PollingCommand { uint8_t *command; @@ -116,6 +118,12 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_SENSOR(pv_ok_condition_for_parallel, QPIRI) PIPSOLAR_SENSOR(pv_power_balance, QPIRI) + // QET value + PIPSOLAR_SENSOR(total_pv_generated_energy, QET) + + // QLT value + PIPSOLAR_SENSOR(total_output_load_energy, QLT) + // QMOD values PIPSOLAR_TEXT_SENSOR(device_mode, QMOD) @@ -175,6 +183,8 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_TEXT_SENSOR(last_qpiws, QPIWS) PIPSOLAR_TEXT_SENSOR(last_qt, QT) PIPSOLAR_TEXT_SENSOR(last_qmn, QMN) + PIPSOLAR_TEXT_SENSOR(last_qet, QET) + PIPSOLAR_TEXT_SENSOR(last_qlt, QLT) PIPSOLAR_SWITCH(output_source_priority_utility_switch, QPIRI) PIPSOLAR_SWITCH(output_source_priority_solar_switch, QPIRI) @@ -213,6 +223,7 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { void handle_qpiws_(const char *message); void handle_qt_(const char *message); void handle_qmn_(const char *message); + void handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value); void skip_start_(const char *message, size_t *pos); void skip_field_(const char *message, size_t *pos); diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 5a697157b7e..02f9e239c7c 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -17,6 +18,7 @@ from esphome.const import ( ICON_FLASH, ICON_GAUGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -80,6 +83,10 @@ CONF_BATTERY_VOLTAGE_OFFSET_FOR_FANS_ON = "battery_voltage_offset_for_fans_on" CONF_EEPROM_VERSION = "eeprom_version" CONF_PV_CHARGING_POWER = "pv_charging_power" +# QET / QLT sensors +CONF_TOTAL_PV_GENERATED_ENERGY = "total_pv_generated_energy" +CONF_TOTAL_OUTPUT_LOAD_ENERGY = "total_output_load_energy" + TYPES = { CONF_GRID_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, @@ -319,6 +326,18 @@ TYPES = { device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + CONF_TOTAL_PV_GENERATED_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_TOTAL_OUTPUT_LOAD_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index cc7477395ba..0986120dcfc 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -15,6 +15,8 @@ CONF_LAST_QFLAG = "last_qflag" CONF_LAST_QPIWS = "last_qpiws" CONF_LAST_QT = "last_qt" CONF_LAST_QMN = "last_qmn" +CONF_LAST_QET = "last_qet" +CONF_LAST_QLT = "last_qlt" TYPES = [ CONF_DEVICE_MODE, @@ -25,6 +27,8 @@ TYPES = [ CONF_LAST_QPIWS, CONF_LAST_QT, CONF_LAST_QMN, + CONF_LAST_QET, + CONF_LAST_QLT, ] CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/tests/components/pipsolar/common.yaml b/tests/components/pipsolar/common.yaml index 819764a7eab..883b9d289d3 100644 --- a/tests/components/pipsolar/common.yaml +++ b/tests/components/pipsolar/common.yaml @@ -204,6 +204,12 @@ sensor: pv_charging_power: id: inverter0_pv_charging_power name: inverter0_pv_charging_power + total_pv_generated_energy: + id: inverter0_total_pv_generated_energy + name: inverter0_total_pv_generated_energy + total_output_load_energy: + id: inverter0_total_output_load_energy + name: inverter0_total_output_load_energy switch: - platform: pipsolar @@ -241,3 +247,9 @@ text_sensor: last_qflag: id: inverter0_last_qflag name: inverter0_last_qflag + last_qet: + id: inverter0_last_qet + name: inverter0_last_qet + last_qlt: + id: inverter0_last_qlt + name: inverter0_last_qlt From eb56ce2b7620f3ce229b045e96c8287f79b224c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:05:09 +0200 Subject: [PATCH 67/97] [haier] Reject deferring actions in on_status_message (#20539) Co-authored-by: Claude Opus 5.5 --- esphome/components/haier/climate.py | 7 ++- tests/component_tests/haier/__init__.py | 0 .../haier/test_status_message_automation.py | 61 +++++++++++++++++++ tests/components/haier/common.yaml | 6 ++ 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/component_tests/haier/__init__.py create mode 100644 tests/component_tests/haier/test_status_message_automation.py diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index ba760536865..db915e9c7f4 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -186,7 +186,12 @@ def _base_config_schema(class_: MockObjClass) -> cv.Schema: cv.Optional( CONF_ANSWER_TIMEOUT, ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_STATUS_MESSAGE): automation.validate_automation({}), + # data points into the protocol's receive buffer, reused for the next frame + cv.Optional( + CONF_ON_STATUS_MESSAGE + ): automation.validate_synchronous_automation( + "on_status_message", "the received frame" + ), } ) .extend(uart.UART_DEVICE_SCHEMA) diff --git a/tests/component_tests/haier/__init__.py b/tests/component_tests/haier/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/haier/test_status_message_automation.py b/tests/component_tests/haier/test_status_message_automation.py new file mode 100644 index 00000000000..5f4927fe10c --- /dev/null +++ b/tests/component_tests/haier/test_status_message_automation.py @@ -0,0 +1,61 @@ +"""Tests that haier on_status_message automations reject deferring actions. + +The trigger passes a pointer into the protocol's receive buffer, which is reused for the next +frame, so the deferring-actions guard is a safety property. +""" + +from collections.abc import Callable + +import pytest + +from esphome import config_validation as cv +from esphome.components.haier.climate import ( + CONF_ON_STATUS_MESSAGE, + HonClimate, + Smartair2Climate, + _base_config_schema, +) +from esphome.core import Lambda +from esphome.cpp_generator import MockObjClass +from esphome.types import ConfigType + +CLIMATE_CLASSES = [HonClimate, Smartair2Climate] + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _trigger_schema( + class_: MockObjClass, +) -> Callable[[list[ConfigType]], list[ConfigType]]: + """The validator the protocol's schema applies to on_status_message.""" + return _base_config_schema(class_).schema[cv.Optional(CONF_ON_STATUS_MESSAGE)] + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_synchronous_automation_accepted(class_: MockObjClass) -> None: + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": [SYNCHRONOUS_ACTION, DEFERRING_ACTION]}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_nested_deferring_action_rejected(class_: MockObjClass) -> None: + actions = [{"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}}] + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)([{"then": actions}]) + + +@pytest.mark.parametrize("class_", CLIMATE_CLASSES) +def test_deferring_action_in_second_automation_rejected(class_: MockObjClass) -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + _trigger_schema(class_)( + [{"then": [SYNCHRONOUS_ACTION]}, {"then": [DEFERRING_ACTION]}] + ) diff --git a/tests/components/haier/common.yaml b/tests/components/haier/common.yaml index 926a1d1b7a9..ab39dfa61d5 100644 --- a/tests/components/haier/common.yaml +++ b/tests/components/haier/common.yaml @@ -43,6 +43,12 @@ climate: level: DEBUG format: "Alarm deactivated. Code: %d. Message: \"%s\"" args: [code, message] + on_status_message: + then: + - logger.log: + level: DEBUG + format: "Status message: %u bytes" + args: [(unsigned) data_size] sensor: - platform: haier From 5cdefa910f54bf7e49b9839b446d9b3173bfd6db Mon Sep 17 00:00:00 2001 From: Fabio Pugliese Ornellas Date: Sun, 11 Oct 2026 22:06:05 +0100 Subject: [PATCH 68/97] [cse7761] Add new sensors (#17531) Co-authored-by: J. Nick Koston --- esphome/components/cse7761/cse7761.cpp | 90 +++++++++++++++++++++----- esphome/components/cse7761/cse7761.h | 19 +++++- esphome/components/cse7761/sensor.py | 55 ++++++++++++---- tests/components/cse7761/common.yaml | 8 +++ 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index c4073d27821..a6101812223 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -18,21 +18,32 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc static constexpr int CSE7761_IREF = 52241; // RmsIAC static constexpr int CSE7761_PREF = 44513; // PowerPAC +// System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz +static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f; + static constexpr uint8_t CSE7761_REG_SYSCON = 0x00; // (2) System Control Register (0x0A04) static constexpr uint8_t CSE7761_REG_EMUCON = 0x01; // (2) Metering control register (0x0000) +static constexpr uint8_t CSE7761_REG_HFCONST = 0x02; // (2) Pulse frequency register (0x1000) static constexpr uint8_t CSE7761_REG_EMUCON2 = 0x13; // (2) Metering control register 2 (0x0001) static constexpr uint8_t CSE7761_REG_PULSE1SEL = 0x1D; // (2) Pin function output select register (0x3210) -static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) -static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register +static constexpr uint8_t CSE7761_REG_UFREQ = 0x23; // (2) Voltage frequency (0x0000) +static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) +static constexpr uint8_t CSE7761_REG_POWERFACTOR = 0x27; // (3) Power factor of the selected channel (0x7FFFFF) +static constexpr uint8_t CSE7761_REG_ENERGY_PA = 0x28; // (3) Channel A active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_ENERGY_PB = 0x29; // (3) Channel B active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register static constexpr uint8_t CSE7761_REG_COEFFCHKSUM = 0x6F; // (2) Coefficient checksum static constexpr uint8_t CSE7761_REG_RMSIAC = 0x70; // (2) Channel A effective current conversion coefficient +// Datasheet: Wh = pulses * EnergyXC * HFConst * 1000 / 2^41 with K1 = K2 = 1 +static constexpr float CSE7761_ENERGY_WH_SCALE = 1000.0f / 2199023255552.0f; + static constexpr uint8_t CSE7761_SPECIAL_COMMAND = 0xEA; // Start special command static constexpr uint8_t CSE7761_CMD_RESET = 0x96; // Reset command, after receiving the command, the chip resets static constexpr uint8_t CSE7761_CMD_CLOSE_WRITE = 0xDC; // Close write operation @@ -58,6 +69,15 @@ void CSE7761Component::dump_config() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current 1", this->current_sensor_1_); + LOG_SENSOR(" ", "Current 2", this->current_sensor_2_); + LOG_SENSOR(" ", "Power 1", this->power_sensor_1_); + LOG_SENSOR(" ", "Power 2", this->power_sensor_2_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Power Factor 1", this->power_factor_sensor_1_); + LOG_SENSOR(" ", "Energy 1", this->energy_sensors_[0]); + LOG_SENSOR(" ", "Energy 2", this->energy_sensors_[1]); } void CSE7761Component::update() { @@ -123,17 +143,18 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { return true; } -uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { - bool result = false; // Start loop - uint8_t retry = 3; // Retry up to three times - uint32_t value = 0; // Default no value - while (!result && retry > 0) { - retry--; - if (this->read_once_(reg, size, &value)) - return value; +bool CSE7761Component::read_(uint8_t reg, uint8_t size, uint32_t *value) { + for (uint8_t retry = 0; retry < 3; retry++) { + if (this->read_once_(reg, size, value)) + return true; } ESP_LOGE(TAG, "Reading register %hhu failed!", reg); - return value; + return false; +} + +uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { + uint32_t value = 0; + return this->read_(reg, size, &value) ? value : 0; } uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) { @@ -159,12 +180,25 @@ bool CSE7761Component::chip_init_() { calc_chksum += this->data_.coefficient[i]; } calc_chksum = ~calc_chksum; + const bool energy_configured = this->energy_sensors_[0] != nullptr || this->energy_sensors_[1] != nullptr; uint16_t coeff_chksum = this->read_(CSE7761_REG_COEFFCHKSUM, 2); if ((calc_chksum != coeff_chksum) || (!calc_chksum)) { ESP_LOGD(TAG, "Default calibration"); this->data_.coefficient[RMS_IAC] = CSE7761_IREF; this->data_.coefficient[RMS_UC] = CSE7761_UREF; this->data_.coefficient[POWER_PAC] = CSE7761_PREF; + if (energy_configured) { + ESP_LOGW(TAG, "No energy calibration, energy unavailable"); + } + } else if (energy_configured) { + uint32_t hf_const = 0; + if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) { + ESP_LOGW(TAG, "HFConst read failed, energy unavailable"); + } + for (uint8_t channel = 0; channel < 2; channel++) { + uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const; + this->energy_[channel].wh_per_pulse = static_cast(scale) * CSE7761_ENERGY_WH_SCALE; + } } this->write_(CSE7761_SPECIAL_COMMAND, CSE7761_CMD_ENABLE_WRITE); @@ -173,7 +207,8 @@ bool CSE7761Component::chip_init_() { if (sys_status & 0x10) { // Write enable to protected registers (WREN) this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04); this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183); - this->write_(CSE7761_REG_EMUCON2 | 0x80, 0x0FC1); + // WaveEN|ZxEN, needed for Ufreq + this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1); this->write_(CSE7761_REG_PULSE1SEL | 0x80, 0x3290); } else { ESP_LOGD(TAG, "Write failed at chip_init"); @@ -210,12 +245,35 @@ void CSE7761Component::get_data_() { this->voltage_sensor_->publish_state(voltage); } + uint32_t raw_reg = 0; + if (this->frequency_sensor_ != nullptr && this->read_(CSE7761_REG_UFREQ, 2, &raw_reg)) { + // Ufreq highest bit set means no signal + float frequency = (raw_reg == 0 || raw_reg >= 0x8000) ? 0.0f : CSE7761_UFREQ_CLOCK / raw_reg; + this->frequency_sensor_->publish_state(frequency); + } + + if (this->power_factor_sensor_1_ != nullptr && this->read_(CSE7761_REG_POWERFACTOR, 3, &raw_reg)) { + // ADC2ON makes the chip report channel A + int24_t pf{static_cast(raw_reg)}; + this->power_factor_sensor_1_->publish_state(static_cast(static_cast(pf)) / 0x800000); + } + for (uint8_t channel = 0; channel < 2; channel++) { // Active power = PowerPA * PowerPAC * 1000 / 0x80000000 float active_power = static_cast(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W float amps = static_cast(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps); + sensor::Sensor *energy_sensor = this->energy_sensors_[channel]; + CSE7761EnergyChannel &energy = this->energy_[channel]; + uint8_t energy_reg = (channel == 0) ? CSE7761_REG_ENERGY_PA : CSE7761_REG_ENERGY_PB; + uint32_t raw = 0; + if (energy_sensor != nullptr && energy.wh_per_pulse > 0.0f && this->read_(energy_reg, 3, &raw)) { + // 24-bit counter, not cleared on read + energy.pulses += (raw - energy.last_pulses) & 0xFFFFFF; + energy.last_pulses = raw; + energy_sensor->publish_state(energy.pulses * energy.wh_per_pulse); + } if (channel == 0) { if (this->power_sensor_1_ != nullptr) { this->power_sensor_1_->publish_state(active_power); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index e08ebf09cc9..557de0e3422 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -3,11 +3,11 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" #include "esphome/core/component.h" +#include "esphome/core/datatypes.h" namespace esphome::cse7761 { struct CSE7761DataStruct { - uint32_t frequency = 0; uint32_t voltage_rms = 0; uint32_t current_rms[2] = {0}; int32_t active_power[2] = {0}; @@ -15,14 +15,26 @@ struct CSE7761DataStruct { bool ready = false; }; +struct CSE7761EnergyChannel { + uint32_t last_pulses{0}; + uint32_t pulses{0}; + float wh_per_pulse{0.0f}; +}; + /// This class implements support for the CSE7761 UART power sensor. class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } + void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; } + void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) { + power_factor_sensor_1_ = power_factor_sensor_1; + } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } void set_current_1_sensor(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } + void set_energy_1_sensor(sensor::Sensor *energy_sensor_1) { this->energy_sensors_[0] = energy_sensor_1; } void set_active_power_2_sensor(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; } void set_current_2_sensor(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; } + void set_energy_2_sensor(sensor::Sensor *energy_sensor_2) { this->energy_sensors_[1] = energy_sensor_2; } void setup() override; void dump_config() override; void update() override; @@ -30,14 +42,19 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice protected: // Sensors sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *frequency_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_1_{nullptr}; sensor::Sensor *power_sensor_1_{nullptr}; sensor::Sensor *current_sensor_1_{nullptr}; sensor::Sensor *power_sensor_2_{nullptr}; sensor::Sensor *current_sensor_2_{nullptr}; + sensor::Sensor *energy_sensors_[2]{nullptr, nullptr}; CSE7761DataStruct data_; + CSE7761EnergyChannel energy_[2]; void write_(uint8_t reg, uint16_t data); bool read_once_(uint8_t reg, uint8_t size, uint32_t *value); + bool read_(uint8_t reg, uint8_t size, uint32_t *value); uint32_t read_(uint8_t reg, uint8_t size); uint32_t coefficient_by_unit_(uint32_t unit); bool chip_init_(); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 5f79be0255b..8ffbb44771a 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -2,15 +2,22 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( + CONF_FREQUENCY, CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_HERTZ, UNIT_VOLT, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -26,6 +33,9 @@ CONF_CURRENT_1 = "current_1" CONF_CURRENT_2 = "current_2" CONF_ACTIVE_POWER_1 = "active_power_1" CONF_ACTIVE_POWER_2 = "active_power_2" +CONF_ENERGY_1 = "energy_1" +CONF_ENERGY_2 = "energy_2" +CONF_POWER_FACTOR_1 = "power_factor_1" CONFIG_SCHEMA = ( cv.Schema( @@ -37,6 +47,17 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_FREQUENCY): sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR_1): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional(CONF_CURRENT_1): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=2, @@ -61,6 +82,18 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_ENERGY_1): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_ENERGY_2): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -83,15 +116,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - for key in [ - CONF_VOLTAGE, - CONF_CURRENT_1, - CONF_CURRENT_2, - CONF_ACTIVE_POWER_1, - CONF_ACTIVE_POWER_2, - ]: - if key not in config: - continue - conf = config[key] - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR_1, var.set_power_factor_1_sensor) + await sensors(CONF_CURRENT_1, var.set_current_1_sensor) + await sensors(CONF_CURRENT_2, var.set_current_2_sensor) + await sensors(CONF_ACTIVE_POWER_1, var.set_active_power_1_sensor) + await sensors(CONF_ACTIVE_POWER_2, var.set_active_power_2_sensor) + await sensors(CONF_ENERGY_1, var.set_energy_1_sensor) + await sensors(CONF_ENERGY_2, var.set_energy_2_sensor) diff --git a/tests/components/cse7761/common.yaml b/tests/components/cse7761/common.yaml index 77b19957b46..ae3a642709e 100644 --- a/tests/components/cse7761/common.yaml +++ b/tests/components/cse7761/common.yaml @@ -2,6 +2,10 @@ sensor: - platform: cse7761 voltage: name: CSE7761 Voltage + frequency: + name: CSE7761 Frequency + power_factor_1: + name: CSE7761 Power Factor 1 current_1: name: CSE7761 Current 1 current_2: @@ -10,3 +14,7 @@ sensor: name: CSE7761 Active Power 1 active_power_2: name: CSE7761 Active Power 2 + energy_1: + name: CSE7761 Energy 1 + energy_2: + name: CSE7761 Energy 2 From 36fbf5bf22d21802833c8ab21c554845383bfc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:07:39 +0200 Subject: [PATCH 69/97] [emontx] Add pause/resume actions to let another component take over the UART (#18756) Co-authored-by: Claude --- esphome/components/emontx/__init__.py | 13 ++++ esphome/components/emontx/emontx.cpp | 20 +++++- esphome/components/emontx/emontx.h | 11 +++- tests/components/emontx/common-actions.yaml | 18 ++++++ tests/components/emontx/emontx_test.cpp | 67 +++++++++++++++++++++ 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 79b0e83405b..2146545db52 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -157,3 +157,16 @@ automation.register_apply_action( CONF_COMMAND, "send_command", cg.std_string, const_fn=_plain_literal ), ) + + +# Let another component (e.g. serial_proxy) take exclusive ownership of the UART. + +EMONTX_ID_SCHEMA = automation.maybe_simple_id({cv.GenerateID(): cv.use_id(EmonTx)}) + +automation.register_apply_action( + "emontx.pause", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(true)") +) +automation.register_apply_action( + "emontx.resume", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(false)") +) +automation.register_apply_condition("emontx.is_paused", EMONTX_ID_SCHEMA, "is_paused()") diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 675744b5f58..a307ac0de2a 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -16,6 +16,20 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } * 2. If line starts with '{', parse as JSON and update sensors/callbacks */ void EmonTx::loop() { + if (this->is_paused()) { + this->was_paused_ = true; + return; + } + if (this->was_paused_) { + // Drop what arrived during the pause and resync at the next line start + this->was_paused_ = false; + this->buffer_pos_ = 0; + this->discarding_line_ = true; + uint8_t dropped; + while (this->available() > 0 && this->read_byte(&dropped)) + this->discarding_line_ = dropped != '\n'; + } + // Read all available data to prevent UART buffer overflow while (this->available() > 0) { uint8_t received; @@ -45,7 +59,7 @@ void EmonTx::loop() { } } } else if (received == '\r' || this->discarding_line_) { - // Ignore CR, and the rest of a line that overflowed the buffer + // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; @@ -100,6 +114,10 @@ void EmonTx::dump_config() { * @param command The command string to send (LF will be appended automatically). */ void EmonTx::send_command(const char *command) { + if (this->is_paused()) { + ESP_LOGW(TAG, "Not sending command, UART is paused: %s", command); + return; + } ESP_LOGD(TAG, "Sending command to emonTx: %s", command); this->write_str(command); this->write_byte('\n'); diff --git a/esphome/components/emontx/emontx.h b/esphome/components/emontx/emontx.h index f97c5aa6cb6..83ee4fdbabe 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -8,6 +8,7 @@ #include "esphome/components/json/json_util.h" #include +#include #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" @@ -41,6 +42,12 @@ class EmonTx final : public Component, public uart::UARTDevice { void send_command(const char *command); void send_command(const std::string &command) { this->send_command(command.c_str()); } + /// Stop reading the UART and sending commands, so another component can use it. + /// Atomic, so it can be called from any task. Hold the pause for at least one loop() pass. + void set_paused(bool paused) { this->paused_.store(paused, std::memory_order_relaxed); } + + bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } + #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } void register_sensor(const char *tag_name, sensor::Sensor *sensor); @@ -55,7 +62,9 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; - bool discarding_line_{false}; // Set after an overflow until the end of that line + std::atomic paused_{false}; + bool was_paused_{false}; + bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line std::array buffer_{}; }; diff --git a/tests/components/emontx/common-actions.yaml b/tests/components/emontx/common-actions.yaml index af8d44896be..294e7367fbd 100644 --- a/tests/components/emontx/common-actions.yaml +++ b/tests/components/emontx/common-actions.yaml @@ -5,3 +5,21 @@ button: - emontx.send_command: id: test_emontx command: !lambda return "l"; + - platform: template + name: Pause emontx + on_press: + - if: + condition: + emontx.is_paused: test_emontx + then: + - emontx.resume: test_emontx + else: + - emontx.pause: test_emontx + - platform: template + name: Pause emontx explicit id + on_press: + - emontx.pause: + id: test_emontx + - delay: 1s + - emontx.resume: + id: test_emontx diff --git a/tests/components/emontx/emontx_test.cpp b/tests/components/emontx/emontx_test.cpp index 20d3adf0dc0..8530b0a0036 100644 --- a/tests/components/emontx/emontx_test.cpp +++ b/tests/components/emontx/emontx_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -91,4 +92,70 @@ TEST_F(EmonTxTest, OverflowAcrossLoopCalls) { EXPECT_EQ(this->lines_[0], "MSG:3"); } +TEST_F(EmonTxTest, LoopLeavesBytesUntouchedWhenPaused) { + this->uart_.push_rx("{\"P1\":123}\n"); + this->sut_.set_paused(true); + this->sut_.loop(); + + EXPECT_EQ(this->uart_.available(), std::strlen("{\"P1\":123}\n")); + EXPECT_TRUE(this->lines_.empty()); +} + +TEST_F(EmonTxTest, SendCommandIsSuppressedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.send_command("x"); + EXPECT_TRUE(this->uart_.tx.empty()); + + this->sut_.set_paused(false); + this->sut_.send_command("x"); + EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "x\n"); +} + +// Neither the part before the pause nor the part after it may reach on_data +TEST_F(EmonTxTest, ResumeDiscardsInterruptedLine) { + this->uart_.push_rx("{\"P1\":4"); + this->sut_.loop(); + this->sut_.set_paused(true); + this->sut_.loop(); + this->sut_.set_paused(false); + + this->uart_.push_rx("56}\n"); + this->sut_.loop(); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("{\"P1\":789}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":789}"); +} + +TEST_F(EmonTxTest, ResumeDropsBacklogReceivedWhilePaused) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n{\"P1\":2}\n{\"P1\":"); + this->sut_.set_paused(false); + + this->sut_.loop(); + EXPECT_EQ(this->uart_.available(), 0u); + EXPECT_TRUE(this->lines_.empty()); + + this->uart_.push_rx("3}\n{\"P1\":4}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":4}"); +} + +TEST_F(EmonTxTest, ResumeAfterCompleteBacklogKeepsNextLine) { + this->sut_.set_paused(true); + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":1}\n"); + this->sut_.set_paused(false); + + this->sut_.loop(); + this->uart_.push_rx("{\"P1\":2}\n"); + this->sut_.loop(); + ASSERT_EQ(this->lines_.size(), 1u); + EXPECT_EQ(this->lines_[0], "{\"P1\":2}"); +} + } // namespace esphome::emontx::testing From f2db62adf277e398b68e1ff9715587309f2a9224 Mon Sep 17 00:00:00 2001 From: Maciej Kucharek Date: Sun, 11 Oct 2026 23:08:02 +0200 Subject: [PATCH 70/97] [mipi_dsi] Add JC1060P470-V2 (#18695) Co-authored-by: J. Nick Koston --- esphome/components/mipi_dsi/models/guition.py | 57 +++++++++++++++++++ .../mipi_dsi/validate-v2.esp32-p4-idf.yaml | 18 ++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 914361a4ac5..3ebaf53f5b7 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,3 +1,5 @@ +from esphome.components.mipi import SLPOUT, delay + from . import DsiDriverChip # fmt: off @@ -34,6 +36,61 @@ DsiDriverChip( ], ) +# JC1060P470-V2: 2026 batch of the JC1060P470 with a different 7" panel behind the same JD9165 driver. +# The rear label shows "V2" after the material number. Timings and init sequence taken from Guition's +# reference driver JC1060P470C_I_W_Y.zip (2026-07-13), +# 1-Demo/.../JC1060P470C_I_W_Y_New_Panel/lvgl_demo_v8/src/lcd/esp_lcd_jd9165.{c,h} +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 1024x600 +# * PCLK Frequency: 52 MHz +# * DSI Lane Bit Rate: 750 Mbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=24, hsync_back_porch=136, hsync_front_porch=160) +# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=21, vsync_front_porch=12) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC1060P470-V2", + width=1024, + height=600, + hsync_back_porch=136, + hsync_pulse_width=24, + hsync_front_porch=160, + vsync_back_porch=21, + vsync_pulse_width=2, + vsync_front_porch=12, + pclk_frequency="52MHz", + lane_bit_rate="750Mbps", + color_order="RGB", + # LCD_RST is GPIO0 on the JC1060P470C_I_W_Y schematic (FPC1 pin 4); the JD9165 needs 120 ms after reset + # before it accepts the vendor table and 120 ms after SLPOUT before DISPON (Guition esp_lcd_jd9165.c); + # the framework adds another 10 ms after SLPOUT. + # SLPOUT is in the init sequence with its own delay, so the framework must not append a second one. + reset_pin=0, + no_slpout=True, + initsequence=[ + delay(120), + (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x08), (0x0B, 0x11), + (0x20, 0x04), (0x1F, 0x05), (0x23, 0x38), (0x28, 0x18), (0x29, 0x29), (0x2A, 0x01), (0x2B, 0x29), + (0x2C, 0x01), (0x30, 0x02), (0x00, 0x05), (0x01, 0x22), (0x02, 0x08), (0x03, 0x12), (0x04, 0x16), + (0x05, 0x64), (0x06, 0x00), (0x07, 0x00), (0x08, 0x78), (0x09, 0x00), (0x0A, 0x04), + (0x0B, 0x16, 0x17, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x07, 0x07, 0x09), + (0x0C, 0x09, 0x1E, 0x1E, 0x1C, 0x1C, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0D, 0x0A, 0x05, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x06, 0x06, 0x08), + (0x0E, 0x08, 0x1F, 0x1F, 0x1D, 0x1D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0F, 0x0A, 0x05, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1D, 0x1D, 0x1F), + (0x10, 0x1F, 0x08, 0x08, 0x06, 0x06, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x11, 0x16, 0x17, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1C, 0x1C, 0x1E), + (0x12, 0x1E, 0x09, 0x09, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x13, 0x00, 0x00, 0x00, 0x00), (0x14, 0x00, 0x00, 0x41, 0x41), (0x15, 0x00, 0x00, 0x00, 0x00), + (0x17, 0x00), (0x18, 0x85), (0x19, 0x06, 0x09), (0x1A, 0x05, 0x08), (0x1B, 0x0A, 0x04), (0x26, 0x00), + (0x27, 0x00), (0x30, 0x06), + (0x12, 0x3F, 0x25, 0x27, 0x35, 0x1D, 0x1B, 0x1B, 0x1A, 0x18, 0x0A, 0x2A, 0x21, 0x19, 0x30), + (0x13, 0x3F, 0x26, 0x27, 0x35, 0x1E, 0x1C, 0x1C, 0x1A, 0x18, 0x0B, 0x2A, 0x21, 0x19, 0x30), + (0x30, 0x0A), (0x02, 0x4F), (0x0B, 0x40), (0x30, 0x0D), (0x0D, 0x04), (0x10, 0x05), (0x11, 0x0C), + (0x12, 0x05), (0x13, 0x0C), (0x30, 0x00), + (SLPOUT,), delay(120), + ], +) + # JC4880P443 Driver Configuration (ST7701) # Using parameters from esp_lcd_st7701.h and the working full init sequence diff --git a/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml new file mode 100644 index 00000000000..bbb4a7269a8 --- /dev/null +++ b/tests/components/mipi_dsi/validate-v2.esp32-p4-idf.yaml @@ -0,0 +1,18 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-p4-idf.yaml + +esp_ldo: + - id: ldo_id + channel: 3 + voltage: 2.5V + +display: + - platform: mipi_dsi + model: JC1060P470-V2 + enable_pin: GPIO22 + +#light: + #- platform: mipi_dsi + #id: backlight_id + +psram: From 99988490091efe78a10e64c4ebfa4289075a9848 Mon Sep 17 00:00:00 2001 From: heikkikii <11836264+heikkikii@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:10:50 +1100 Subject: [PATCH 71/97] [seeed_mr24hpc1] Wait for the module to boot before sending UART traffic (#18860) Co-authored-by: J. Nick Koston --- .../components/seeed_mr24hpc1/seeed_mr24hpc1.cpp | 15 ++++++++++++--- .../components/seeed_mr24hpc1/seeed_mr24hpc1.h | 1 + .../components/seeed_mr24hpc1/test.esp32-idf.yaml | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 tests/components/seeed_mr24hpc1/test.esp32-idf.yaml diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index 1bc3e0aeb42..8d5103cbbaf 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -9,6 +9,8 @@ namespace esphome::seeed_mr24hpc1 { ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); +static constexpr uint32_t STARTUP_DELAY_MS = 1000; + // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. void MR24HPC1Component::dump_config() { @@ -76,8 +78,7 @@ void MR24HPC1Component::setup() { this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); } #endif - this->set_custom_end_mode(); - this->poll_time_base_func_check_ = true; + this->poll_time_base_func_check_ = false; this->check_dev_inf_sign_ = true; this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE; this->sg_data_len_ = 0; @@ -92,7 +93,13 @@ void MR24HPC1Component::setup() { memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE); memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE); - this->set_interval(8000, [this]() { this->update_(); }); + // The module locks up when queried during its own boot, so hold all TX for a second + this->set_timeout(STARTUP_DELAY_MS, [this]() { + this->module_ready_ = true; + this->set_custom_end_mode(); + this->poll_time_base_func_check_ = true; + this->set_interval(8000, [this]() { this->update_(); }); + }); } // Timed polling of radar data @@ -680,6 +687,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { // Sending data frames void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) { + if (!this->module_ready_) + return; this->write_array(query, string_length); } diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b231bab33e5..23cecfc9863 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component, int sg_start_query_data_; bool check_dev_inf_sign_; bool poll_time_base_func_check_; + bool module_ready_{false}; void update_(); void r24_split_data_frame_(uint8_t value); diff --git a/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml new file mode 100644 index 00000000000..3853bf3e8dc --- /dev/null +++ b/tests/components/seeed_mr24hpc1/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml + seeed_mr24hpc1: !include common.yaml From 9b34db14d44d531b1d99eec31f9da742b0d28a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:15:15 +0200 Subject: [PATCH 72/97] [mqtt] Reject deferring actions in on_json_message (#20538) Co-authored-by: Claude Opus 5.5 --- esphome/components/mqtt/__init__.py | 21 ++++---- tests/component_tests/mqtt/__init__.py | 0 .../mqtt/test_json_message_automation.py | 52 +++++++++++++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 tests/component_tests/mqtt/__init__.py create mode 100644 tests/component_tests/mqtt/test_json_message_automation.py diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index b21dc2e2f00..826d4e32ef2 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -224,6 +224,17 @@ def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: return config +# The parsed JSON document is freed once the automation returns +JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( + "on_json_message", + "the parsed JSON document", + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), + cv.Required(CONF_TOPIC): cv.subscribe_topic, + cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, + }, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -304,15 +315,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PAYLOAD): cv.string_strict, } ), - cv.Optional(CONF_ON_JSON_MESSAGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTJsonMessageTrigger - ), - cv.Required(CONF_TOPIC): cv.subscribe_topic, - cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, - } - ), + cv.Optional(CONF_ON_JSON_MESSAGE): JSON_MESSAGE_AUTOMATION_SCHEMA, cv.Optional(CONF_PUBLISH_NAN_AS_NONE, default=False): cv.boolean, cv.Optional(CONF_WAIT_FOR_CONNECTION, default=False): cv.boolean, } diff --git a/tests/component_tests/mqtt/__init__.py b/tests/component_tests/mqtt/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mqtt/test_json_message_automation.py b/tests/component_tests/mqtt/test_json_message_automation.py new file mode 100644 index 00000000000..a6bb84c7c71 --- /dev/null +++ b/tests/component_tests/mqtt/test_json_message_automation.py @@ -0,0 +1,52 @@ +"""Tests that mqtt on_json_message automations reject deferring actions. + +The trigger passes a view into the parsed JSON document, which is freed once the automation +returns, so the deferring-actions guard is a safety property. +""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.mqtt import CONFIG_SCHEMA, JSON_MESSAGE_AUTOMATION_SCHEMA +from esphome.const import CONF_ON_JSON_MESSAGE +from esphome.core import Lambda +from esphome.types import ConfigType + +DEFERRING_ACTION = {"delay": "1s"} +SYNCHRONOUS_ACTION = {"lambda": Lambda('ESP_LOGD("test", "ran");')} +TRUE_CONDITION = {"lambda": Lambda("return true;")} + +DEFER_MESSAGE = "Deferring actions" + + +def _automation(*actions: ConfigType) -> ConfigType: + return {"topic": "test/topic", "then": list(actions)} + + +def test_schema_wired_to_on_json_message() -> None: + schema = CONFIG_SCHEMA.validators[0].schema + assert schema[cv.Optional(CONF_ON_JSON_MESSAGE)] is JSON_MESSAGE_AUTOMATION_SCHEMA + + +def test_synchronous_automation_accepted() -> None: + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(SYNCHRONOUS_ACTION)]) + + +def test_deferring_action_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION, DEFERRING_ACTION)] + ) + + +def test_nested_deferring_action_rejected() -> None: + action = {"if": {"condition": TRUE_CONDITION, "then": [DEFERRING_ACTION]}} + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA([_automation(action)]) + + +def test_deferring_action_in_second_automation_rejected() -> None: + with pytest.raises(cv.Invalid, match=DEFER_MESSAGE): + JSON_MESSAGE_AUTOMATION_SCHEMA( + [_automation(SYNCHRONOUS_ACTION), _automation(DEFERRING_ACTION)] + ) From 3035a08b0a61055c4ae8b6b52523495223b953cf Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:19:43 +0200 Subject: [PATCH 73/97] [zigbee] Improve basic cluster and add manufacturer and app version setting (#20519) --- esphome/components/zigbee/__init__.py | 64 ++++++++++++++++++- esphome/components/zigbee/const.py | 2 + esphome/components/zigbee/zigbee_esp32.cpp | 72 +++++++++++++--------- esphome/components/zigbee/zigbee_esp32.h | 6 +- esphome/components/zigbee/zigbee_esp32.py | 10 ++- esphome/components/zigbee/zigbee_zephyr.py | 14 +++-- tests/components/zigbee/common.yaml | 8 +++ tests/components/zigbee/common_esp32.yaml | 2 + tests/components/zigbee/common_nrf52.yaml | 2 + 9 files changed, 140 insertions(+), 40 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index d01eddb9ff7..1c85553c57d 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -3,6 +3,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import DOMAIN as ESP32_DOMAIN, only_on_variant from esphome.components.esp32.const import ( VARIANT_ESP32C5, @@ -14,14 +15,19 @@ from esphome.components.esp32.const import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ESPHOME, CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START, CONF_POLLING_INTERVAL, + CONF_PROJECT, + CONF_VERSION, + __version__ as ESPHOME_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( @@ -36,6 +42,8 @@ from .const import ( CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, REPORT, ZigbeeComponent, @@ -143,12 +151,33 @@ def _validate_router_sleepy(config: ConfigType) -> ConfigType: return config +def _validate_ascii(value): + """Validate that a string contains only ascii characters.""" + if not isinstance(value, str): + raise cv.Invalid("expected string") + if not value.isascii(): + raise cv.Invalid("Only ASCII characters are allowed") + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), cv.Optional(CONF_MODEL, default=CORE.name): cv.All( - cv.string, cv.Length(max=31) + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional( + CONF_MANUFACTURER, default="esphome", visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional(CONF_VERSION, default=0): cv.All( + cv.int_range(min=0x00, max=0xFF), ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), @@ -207,7 +236,7 @@ CONFIG_SCHEMA = cv.All( def validate_number_of_ep(config: ConfigType) -> ConfigType: if not CORE.is_nrf52: return config - if KEY_ZIGBEE not in CORE.data: + if KEY_EP_NUMBER not in CORE.data.get(KEY_ZIGBEE, {}): raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) if count == 1: @@ -220,7 +249,38 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: return config +def final_validate_strings(config: ConfigType) -> ConfigType: + version_str = ESPHOME_VERSION + if ( + proj_version := fv.full_config.get() + .get(CONF_ESPHOME, {}) + .get(CONF_PROJECT, {}) + .get(CONF_VERSION) + ): + version_str = proj_version.encode("ascii", "ignore")[:15].decode() + if version_str != proj_version: + _LOGGER.warning( + "esphome.project.version %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + proj_version, + version_str, + ) + area_str = "" + if CORE.area: + area_str = CORE.area.encode("ascii", "ignore")[:15].decode() + if area_str != CORE.area: + _LOGGER.warning( + "esphome.area %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + CORE.area, + area_str, + ) + data: dict[str, Any] = CORE.data.setdefault(KEY_ZIGBEE, {}) + data[KEY_ZIGBEE_VERSION_STR] = version_str + data[KEY_ZIGBEE_AREA_STR] = area_str + return config + + FINAL_VALIDATE_SCHEMA = cv.All( + final_validate_strings, validate_number_of_ep, final_validate_esp32, ) diff --git a/esphome/components/zigbee/const.py b/esphome/components/zigbee/const.py index aad2b5c0e39..109b953de5c 100644 --- a/esphome/components/zigbee/const.py +++ b/esphome/components/zigbee/const.py @@ -85,6 +85,8 @@ POWER_SOURCE = { } KEY_ZIGBEE = "zigbee" +KEY_ZIGBEE_AREA_STR = "zigbee_area_str" +KEY_ZIGBEE_VERSION_STR = "zigbee_version_str" # BACnet engineering units mapping (ZCL uses BACnet unit codes) # See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 7c197133c2b..ffb0d7ec7a7 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -238,7 +238,8 @@ void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } -void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, const char *location, + const char *sw_build_id, uint8_t power_source, uint8_t app_version) { char date_buf[16]; time_t time_val = App.get_build_time(); struct tm *timeinfo = localtime(&time_val); @@ -247,7 +248,10 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu .model = get_zcl_string(model, 31), .manufacturer = get_zcl_string(manufacturer, 31), .date = get_zcl_string(date_buf, 15), + .location = get_zcl_string(location, 15), + .sw_build_id = get_zcl_string(sw_build_id, 15), .power_source = power_source, + .app_version = app_version, }; } @@ -267,6 +271,16 @@ void ZigbeeComponent::update_basic_cluster_(ezb_af_ep_desc_t ep_desc) { ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_MODEL_IDENTIFIER_ID, this->basic_cluster_data_.model); ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_DATE_CODE_ID, this->basic_cluster_data_.date); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_APPLICATION_VERSION_ID, + &(this->basic_cluster_data_.app_version)); + uint8_t stack_version = 0; + uint8_t hw_version = 0; + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_STACK_VERSION_ID, &stack_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_HW_VERSION_ID, &hw_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_LOCATION_DESCRIPTION_ID, + this->basic_cluster_data_.location); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_SW_BUILD_ID_ID, + this->basic_cluster_data_.sw_build_id); } bool ZigbeeComponent::register_device() { @@ -420,37 +434,35 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %.*s\n" + " Manufacturer: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " App Version: %u", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), + this->basic_cluster_data_.manufacturer[0], + reinterpret_cast(this->basic_cluster_data_.manufacturer + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + this->basic_cluster_data_.app_version); if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - ESP_LOGCONFIG( - TAG, - "Zigbee\n" - " Model: %.*s\n" -#ifdef CONFIG_ZB_ZCZR - " Router: %s\n" -#else - " Sleepy end device: %s\n" - " Poll interval: %" PRIu32 " ms\n" -#endif - " Device is joined to the network: %s\n" - " Current channel: %d\n" - " Short addr: 0x%04X\n" - " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), -#ifdef CONFIG_ZB_ZCZR - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), -#else - YESNO(this->sleepy_), this->keep_alive_, -#endif - YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); - esp_zigbee_lock_release(); - } else { ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER)); + " Device is joined to the network: %s\n" + " Current channel: %d\n" + " Short addr: 0x%04X\n" + " Short pan id: 0x%04X", + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), + ezb_nwk_get_panid()); + esp_zigbee_lock_release(); } } diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index 28ea64c8cea..f5c7ff8de3b 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -44,7 +44,8 @@ class ZigbeeComponent final : public Component { void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } - void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); + void set_basic_cluster(const char *model, const char *manufacturer, const char *location, const char *sw_build_id, + uint8_t power_source, uint8_t app_version); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); bool register_device(); @@ -80,7 +81,10 @@ class ZigbeeComponent final : public Component { uint8_t *model; uint8_t *manufacturer; uint8_t *date; + uint8_t *location; + uint8_t *sw_build_id; uint8_t power_source; + uint8_t app_version; } basic_cluster_data_; #ifdef CONFIG_ZB_ZED ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index 68136d57192..fec8ceba744 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -4,6 +4,7 @@ import re from typing import Any import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, add_idf_component, @@ -28,6 +29,7 @@ from esphome.const import ( CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, + CONF_VERSION, CONF_WIFI, DEVICE_CLASS_OUTLET, ) @@ -49,6 +51,8 @@ from .const import ( CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, ) @@ -418,12 +422,14 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": cg.add(var.set_sleepy(True)) if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: cg.add(var.set_keep_alive(keep_alive)) - cg.add( var.set_basic_cluster( config[CONF_MODEL], - "esphome", + config[CONF_MANUFACTURER], + zb_data[KEY_ZIGBEE_AREA_STR], + zb_data[KEY_ZIGBEE_VERSION_STR], POWER_SOURCE[config[CONF_POWER_SOURCE]], + config[CONF_VERSION], ) ) for ep_num, ep in ep_dict.items(): diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index 8197796b6db..d468c9881d0 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -2,6 +2,7 @@ import datetime import random import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( @@ -9,7 +10,7 @@ from esphome.const import ( CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - __version__, + CONF_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.cpp_generator import ( @@ -27,6 +28,8 @@ from .const import ( CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, AnalogAttrs, AnalogAttrsOutput, @@ -158,15 +161,16 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list + zb_data = CORE.data.get(KEY_ZIGBEE, {}) basic_attrs = zigbee_new_variable("zigbee_basic_attrs", ZB_ZCL_BASIC_ATTRS_EXT_T) zigbee_new_attr_list( "zigbee_basic_attrib_list", "ZB_ZCL_DECLARE_BASIC_ATTRIB_LIST_EXT", zigbee_assign(basic_attrs.zcl_version, cg.RawExpression("ZB_ZCL_VERSION")), - zigbee_assign(basic_attrs.app_version, 0), + zigbee_assign(basic_attrs.app_version, config[CONF_VERSION]), zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), - zigbee_set_string(basic_attrs.mf_name, "esphome"), + zigbee_set_string(basic_attrs.mf_name, config[CONF_MANUFACTURER]), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( basic_attrs.date_code, @@ -178,11 +182,11 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.power_source, POWER_SOURCE[config[CONF_POWER_SOURCE]], ), - zigbee_set_string(basic_attrs.location_id, ""), + zigbee_set_string(basic_attrs.location_id, zb_data[KEY_ZIGBEE_AREA_STR]), zigbee_assign( basic_attrs.ph_env, cg.RawExpression("ZB_ZCL_BASIC_ENV_UNSPECIFIED") ), - zigbee_set_string(basic_attrs.sw_ver, __version__), + zigbee_set_string(basic_attrs.sw_ver, zb_data[KEY_ZIGBEE_VERSION_STR]), ) # Create the identify attributes structure and attribute list diff --git a/tests/components/zigbee/common.yaml b/tests/components/zigbee/common.yaml index 8ef49570729..4851ec7dea0 100644 --- a/tests/components/zigbee/common.yaml +++ b/tests/components/zigbee/common.yaml @@ -1,3 +1,11 @@ +esphome: + area: + id: testing_area + name: Testing Area + project: + name: esphome.test + version: "1.1" + binary_sensor: - platform: template name: "Garage Door Open 1" diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 84b79bb84f0..9df43d957bb 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -58,6 +58,8 @@ switch: zigbee: model: zigbee_test + manufacturer: test + version: 1 sleepy: true polling_interval: 2s power_source: BATTERY diff --git a/tests/components/zigbee/common_nrf52.yaml b/tests/components/zigbee/common_nrf52.yaml index 2da65706182..a99f6bd92e3 100644 --- a/tests/components/zigbee/common_nrf52.yaml +++ b/tests/components/zigbee/common_nrf52.yaml @@ -3,6 +3,8 @@ packages: zigbee: model: zigbee_test + manufacturer: test + version: 1 wipe_on_boot: true on_join: then: From 2ef95bbc28f1fa112d13d292b315f9f2e4264011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:22:52 +0200 Subject: [PATCH 74/97] [core] Copy trigger arguments in wait_until and script.wait (#20536) Co-authored-by: Claude Opus 5.5 --- esphome/components/script/script.h | 8 +- esphome/core/automation.h | 8 ++ esphome/core/base_automation.h | 21 ++-- .../core/test_wait_until_action.cpp | 109 ++++++++++++++++++ .../script/test_script_wait_action.cpp | 96 +++++++++++++++ 5 files changed, 229 insertions(+), 13 deletions(-) create mode 100644 tests/components/core/test_wait_until_action.cpp create mode 100644 tests/components/script/test_script_wait_action.cpp diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 97b27af498d..f00ad7f4afa 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -306,7 +306,7 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, std::make_index_sequence{}); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -323,12 +323,8 @@ template class ScriptWaitAction final : public Action void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->play_next_(std::get(tuple)...); - } - C *script_; - std::list> param_queue_; + std::list...>> param_queue_; }; } // namespace esphome::script diff --git a/esphome/core/automation.h b/esphome/core/automation.h index d3f1fe59e59..35c2a17c247 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 @@ -494,6 +495,13 @@ template class TemplatableValue { }; }; +/// How deferring actions store a trigger argument: a copy, since a referenced object may be gone once the trigger +/// returns, unless the type cannot be copied (e.g. an entity, which outlives the wait). A copyable `Base &` would be +/// stored sliced, so entity-like argument types must not be copyable. +template +using stored_trigger_arg_t = + std::conditional_t>, std::remove_cvref_t, T>; + /** Base class for all automation conditions. * * @tparam Ts The template parameters to pass when executing. diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 8762cbe7110..69def222da2 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -500,7 +501,7 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, x...); // Do immediate check with fresh timestamp - don't call loop() synchronously! // Let the event loop call it to avoid reentrancy issues @@ -532,7 +533,13 @@ template class WaitUntilAction : public Action, public Co } protected: - using QueueItem = std::tuple, std::tuple>; + struct QueueItem { + QueueItem(uint32_t start, optional timeout, const Ts &...x) + : start(start), timeout(timeout), args(x...) {} + uint32_t start; + optional timeout; + std::tuple...> args; + }; // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items @@ -546,14 +553,14 @@ template class WaitUntilAction : public Action, public Co std::list pending; while (!queue.empty()) { auto it = queue.begin(); - auto start = std::get(*it); - auto timeout = std::get>(*it); + auto start = it->start; + auto timeout = it->timeout; // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) { pending.splice(pending.end(), queue, it); continue; } @@ -564,7 +571,7 @@ template class WaitUntilAction : public Action, public Co std::list completed; completed.splice(completed.begin(), queue, it); uint8_t generation = this->stop_generation_; - this->play_next_tuple_(std::get>(completed.front())); + std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args); if (generation != this->stop_generation_) { // stop() ran inside the chain - the items still held locally were cancelled pending.clear(); @@ -581,7 +588,7 @@ template class WaitUntilAction : public Action, public Co Condition *condition_; std::list var_queue_{}; - // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_() uint8_t stop_generation_{0}; }; diff --git a/tests/components/core/test_wait_until_action.cpp b/tests/components/core/test_wait_until_action.cpp new file mode 100644 index 00000000000..56cfa10d513 --- /dev/null +++ b/tests/components/core/test_wait_until_action.cpp @@ -0,0 +1,109 @@ +#include + +#include +#include + +#include "esphome/core/base_automation.h" + +namespace esphome::testing { + +class FlagCondition : public Condition { + public: + bool check(const std::string & /*value*/) override { return this->ready; } + bool ready{false}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(WaitUntilActionTest, KeepsCopyOfReferenceArguments) { + FlagCondition condition; + WaitUntilAction wait(&condition); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + condition.ready = true; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class CounterCondition : public Condition { + public: + bool check(Counter & /*counter*/) override { return this->ready; } + bool ready{false}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +// A non-const reference argument used to fail to compile +TEST(WaitUntilActionTest, AcceptsMutableReferenceArguments) { + CounterCondition condition; + WaitUntilAction wait(&condition); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + condition.ready = true; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityCondition : public Condition { + public: + bool check(Entity & /*entity*/) override { return this->ready; } + bool ready{false}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(WaitUntilActionTest, KeepsReferenceToNonCopyableArguments) { + EntityCondition condition; + WaitUntilAction wait(&condition); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + condition.ready = true; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::testing diff --git a/tests/components/script/test_script_wait_action.cpp b/tests/components/script/test_script_wait_action.cpp new file mode 100644 index 00000000000..17f553996ea --- /dev/null +++ b/tests/components/script/test_script_wait_action.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include + +#include "esphome/components/script/script.h" + +namespace esphome::script::testing { + +class FakeScript { + public: + bool is_running() const { return this->running; } + bool running{true}; +}; + +class RecordAction : public Action { + public: + void play(const std::string &value) override { this->seen = value; } + std::string seen; +}; + +// A trigger argument passed as `const T &` must be copied while waiting: the +// referenced object is gone once the trigger has returned. +TEST(ScriptWaitActionTest, KeepsCopyOfReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + RecordAction record; + ActionList actions; + actions.add_actions({&wait, &record}); + + { + auto value = std::make_unique("argument longer than the small string buffer"); + actions.play(*value); + } + EXPECT_TRUE(record.seen.empty()); + + script.running = false; + wait.loop(); + EXPECT_EQ(record.seen, "argument longer than the small string buffer"); +} + +class Counter { + public: + int value{0}; +}; + +class IncrementAction : public Action { + public: + void play(Counter &counter) override { this->seen = ++counter.value; } + int seen{0}; +}; + +TEST(ScriptWaitActionTest, KeepsCopyOfMutableReferenceArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + IncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Counter counter; + actions.play(counter); + script.running = false; + wait.loop(); + EXPECT_EQ(increment.seen, 1); + // Like delay, the actions after the wait work on a copy + EXPECT_EQ(counter.value, 0); +} + +class Entity { + public: + Entity() = default; + Entity(const Entity &) = delete; + int value{0}; +}; + +class EntityIncrementAction : public Action { + public: + void play(Entity &entity) override { entity.value++; } +}; + +// A type that cannot be copied, such as an entity, is kept by reference +TEST(ScriptWaitActionTest, KeepsReferenceToNonCopyableArguments) { + FakeScript script; + ScriptWaitAction wait(&script); + EntityIncrementAction increment; + ActionList actions; + actions.add_actions({&wait, &increment}); + + Entity entity; + actions.play(entity); + script.running = false; + wait.loop(); + EXPECT_EQ(entity.value, 1); +} + +} // namespace esphome::script::testing From 7d7db6bf90c8f716d857e294efcdeb17dcab7471 Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:31:49 +0200 Subject: [PATCH 75/97] [zigbee] add report action on esp32 (#20488) Co-authored-by: J. Nick Koston --- esphome/components/zigbee/__init__.py | 4 +-- .../zigbee/zigbee_attribute_esp32.cpp | 11 ++++++++ .../zigbee/zigbee_attribute_esp32.h | 1 + esphome/components/zigbee/zigbee_ep_esp32.py | 21 +------------- esphome/components/zigbee/zigbee_esp32.cpp | 6 ++++ esphome/components/zigbee/zigbee_esp32.h | 1 + esphome/components/zigbee/zigbee_esp32.py | 28 +++++++++++++++++-- tests/components/zigbee/common_esp32.yaml | 4 +++ 8 files changed, 52 insertions(+), 24 deletions(-) diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 1c85553c57d..44f83696f25 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -78,9 +78,9 @@ CONFLICTS_WITH = ["openthread"] def _check_report_deprecation(value: str) -> str: - if str(value).lower() in ("coordinator", "enable"): + if str(value).lower() == "coordinator": _LOGGER.warning( - "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + "Report option 'coordinator' is deprecated and will be removed in a future release. Use 'default' instead." ) return value diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index b73c26a8361..e2ff542976b 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -62,6 +62,9 @@ void ZigbeeAttribute::report_(bool has_lock) { } void ZigbeeAttribute::set_report(ZigbeeReportT report) { + if (report == ZigbeeReportT::ZIGBEE_REPORT_DEFAULT || report == ZigbeeReportT::ZIGBEE_REPORT_COORDINATOR) { + return; + } this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { this->force_report_ = true; @@ -73,6 +76,14 @@ void ZigbeeAttribute::set_report(ZigbeeReportT report) { }); } +void ZigbeeAttribute::report() { + if (!this->report_enabled) { + return; + } + this->report_requested_ = true; + this->enable_loop(); +} + void ZigbeeAttribute::loop() { if (this->set_attr_requested_) { this->set_attr_(); diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index 305febf171a..adaa60646d1 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.h +++ b/esphome/components/zigbee/zigbee_attribute_esp32.h @@ -53,6 +53,7 @@ class ZigbeeAttribute final : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); + void report(); template void add_on_value_callback(F &&callback) { on_value_callback_.add(std::forward(callback)); } void on_value(ezb_zcl_attribute_t attribute) { diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 88f49ef3c16..1616ce0cebf 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -27,13 +27,7 @@ from esphome.const import ( ) from esphome.core import CORE, Lambda -from .const import ( - CONF_MAX_EP_NUMBER, - CONF_REPORT, - CONF_USE_DEVICE_TYPE, - KEY_ZIGBEE, - REPORT, -) +from .const import CONF_MAX_EP_NUMBER, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE from .const_esp32 import ( ALLOWED_UNITS, CONF_ATTRIBUTE_ID, @@ -58,7 +52,6 @@ ANALOG_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -87,7 +80,6 @@ ANALOG_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -117,7 +109,6 @@ BINARY_INPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -146,7 +137,6 @@ BINARY_OUTPUT_EP = { { CONF_ATTRIBUTE_ID: 0x55, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -178,7 +168,6 @@ def _pressure_ep(device_type: bool = False) -> dict[str, Any]: { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_HECTOPASCAL: 1, @@ -208,7 +197,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "INT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -226,7 +214,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), SCALE: 100, CONNECT: True, }, @@ -247,7 +234,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: { UNIT_LITRE_PER_HOUR: 0.01, @@ -269,7 +255,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "UINT16", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONF_LAMBDA: cv.lambda_( Lambda( "if (x < 0.0f || std::isnan(x)) return 0xFFFF;" # NaN @@ -294,7 +279,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -316,7 +300,6 @@ SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0000, CONF_TYPE: "SINGLE", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, SCALE: 0.000001, }, @@ -339,7 +322,6 @@ BINARY_SENSOR_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "MAP8", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, { @@ -369,7 +351,6 @@ SWITCH_EP_CONFIGS: dict[str, dict[str, Any]] = { { CONF_ATTRIBUTE_ID: 0x0, CONF_TYPE: "BOOL", - CONF_REPORT: cv.enum(REPORT, lower=True)("default"), CONNECT: True, }, ], diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index ffb0d7ec7a7..68c0d005b57 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,6 +49,12 @@ void ZigbeeComponent::factory_reset() { esp_zigbee_lock_release(); } +void ZigbeeComponent::report() { + for (const auto &[_, attribute] : this->attributes_) { + attribute->report(); + } +} + void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, diff --git a/esphome/components/zigbee/zigbee_esp32.h b/esphome/components/zigbee/zigbee_esp32.h index f5c7ff8de3b..d5ed7439bd2 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -61,6 +61,7 @@ class ZigbeeComponent final : public Component { static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); void factory_reset(); + void report(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } diff --git a/esphome/components/zigbee/zigbee_esp32.py b/esphome/components/zigbee/zigbee_esp32.py index fec8ceba744..99f075ecb2f 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -3,10 +3,12 @@ import logging import re from typing import Any +from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, + DOMAIN as ESP32_DOMAIN, add_idf_component, add_idf_sdkconfig_option, add_partition, @@ -55,6 +57,7 @@ from .const import ( KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, + ZigbeeComponent, ) from .const_esp32 import ( ALLOWED_UNITS, @@ -204,8 +207,11 @@ def setup_attributes(config: ConfigType, clusters: list[dict[str, Any]]) -> None attr[CONF_VALUE] = str(name) attr[CONF_MAX_LENGTH] = len(str(name)) if attr.get(CONNECT): # connect device - if CONF_REPORT in config: - attr[CONF_REPORT] = config[CONF_REPORT] + if (report := config.get(CONF_REPORT, "default")) not in ( + "default", + "coordinator", + ): + attr[CONF_REPORT] = report attr[CONF_ID] = cv.declare_id(ZigbeeAttribute)(None) attr_list = config.setdefault(KEY_ZIGBEE_ATTRIBUTES, []) attr_list.append(attr) @@ -460,3 +466,21 @@ async def add_component(entity: cg.MockObj, config: ConfigType) -> None: cg.add(zb_attr.connect(template_arg, entity, lambda_)) else: cg.add(zb_attr.connect(template_arg, entity)) + + +ZIGBEE_ACTION_SCHEMA_ESP32 = automation.maybe_simple_id( + cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(ZigbeeComponent), + }, + ), + cv.requires_component(ESP32_DOMAIN), + ) +) + +automation.register_apply_action( + "zigbee.report", + ZIGBEE_ACTION_SCHEMA_ESP32, + automation.ApplyCall("report()"), +) diff --git a/tests/components/zigbee/common_esp32.yaml b/tests/components/zigbee/common_esp32.yaml index 9df43d957bb..2b6fc3b6c54 100644 --- a/tests/components/zigbee/common_esp32.yaml +++ b/tests/components/zigbee/common_esp32.yaml @@ -16,6 +16,9 @@ binary_sensor: name: "Garage Door Open 13" endpoint: 2 use_device_type: false + on_press: + then: + - zigbee.report sensor: - platform: template @@ -26,6 +29,7 @@ sensor: endpoint: 1 use_device_type: true cluster: device_class + report: enable - platform: template name: "Light Sensor" lambda: return 100.0; From 8060dadd6c87d7944222875a9aeba23c8ee08921 Mon Sep 17 00:00:00 2001 From: embe <31192394+mbrudka@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:49:49 +0200 Subject: [PATCH 76/97] [micro_wake_word] Add opt-in internal RAM placement (#20529) Co-authored-by: J. Nick Koston --- .../components/micro_wake_word/__init__.py | 6 ++ .../micro_wake_word/micro_wake_word.cpp | 7 ++- .../micro_wake_word/micro_wake_word.h | 4 +- .../micro_wake_word/streaming_model.cpp | 56 +++++++++++++++++-- .../micro_wake_word/streaming_model.h | 16 ++++-- esphome/core/defines.h | 1 + tests/components/micro_wake_word/common.yaml | 1 + .../test-internal.esp32-idf.yaml | 9 +++ .../micro_wake_word/test.esp32-s3-idf.yaml | 4 +- 9 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 tests/components/micro_wake_word/test-internal.esp32-idf.yaml diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index f04f784ebcf..9477ebc850e 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -44,6 +44,7 @@ DOMAIN = "micro_wake_word" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" +CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" CONF_SLIDING_WINDOW_AVERAGE_SIZE = "sliding_window_average_size" CONF_SLIDING_WINDOW_SIZE = "sliding_window_size" @@ -426,6 +427,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." @@ -519,6 +521,8 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + if config[CONF_PREFER_INTERNAL_RAM]: + cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() if config.get(CONF_TASK_STACK_IN_PSRAM): @@ -569,6 +573,7 @@ async def to_code(config): cg.add( var.add_vad_model( prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_MICRO][CONF_TENSOR_ARENA_SIZE], @@ -581,6 +586,7 @@ async def to_code(config): model_parameters[CONF_ID], str(model_parameters[CONF_ID]), prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_WAKE_WORD], diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index 6d76e399868..4ae66d69bf0 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -386,9 +386,10 @@ WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { } #ifdef USE_MICRO_WAKE_WORD_VAD -void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { - this->vad_model_ = make_unique(model_start, probability_cutoff, sliding_window_size, tensor_arena_size); +void MicroWakeWord::add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { + this->vad_model_ = + make_unique(model_start, model_size, probability_cutoff, sliding_window_size, tensor_arena_size); } #endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 03f4a86fd47..862d40733bf 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -93,8 +93,8 @@ class MicroWakeWord final : public Component uint8_t get_features_step_size() const { return this->features_step_size_; } #ifdef USE_MICRO_WAKE_WORD_VAD - void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + void add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); // Intended for the voice assistant component to fetch VAD status bool get_vad_state() { return this->vad_state_; } diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 7cbd11711a2..14e6046ba04 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -4,6 +4,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM +#include +#endif ESPHOME_LOG_TAG(TAG, "micro_wake_word"); @@ -62,16 +66,43 @@ bool StreamingModel::load_model_() { } if (this->tensor_arena_ == nullptr) { +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + RAMAllocator preferred_allocator(RAMAllocator::PREFER_INTERNAL); + this->tensor_arena_ = preferred_allocator.allocate(this->tensor_arena_size_); + if (this->tensor_arena_ != nullptr && !esp_ptr_internal(this->tensor_arena_)) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for tensor arena; using default memory", + this->tensor_arena_size_); + } +#else this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); +#endif if (this->tensor_arena_ == nullptr) { ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); return false; } } +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (!esp_ptr_internal(this->model_start_) && this->model_copy_ == nullptr) { + RAMAllocator internal_allocator(RAMAllocator::ALLOC_INTERNAL); + this->model_copy_ = internal_allocator.make_unique_array_for_overwrite(this->model_size_); + if (this->model_copy_ == nullptr) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for model; using original model data", + this->model_size_); + } else { + std::memcpy(this->model_copy_.get(), this->model_start_, this->model_size_); + } + } +#endif + if (this->interpreter_ == nullptr) { + const uint8_t *model_data = this->model_start_; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (this->model_copy_ != nullptr) + model_data = this->model_copy_.get(); +#endif this->interpreter_ = - make_unique(tflite::GetModel(this->model_start_), this->streaming_op_resolver_, + make_unique(tflite::GetModel(model_data), this->streaming_op_resolver_, this->tensor_arena_, this->tensor_arena_size_, this->mrv_); if (this->interpreter_->AllocateTensors() != kTfLiteOk) { ESP_LOGE(TAG, "Failed to allocate tensors for the streaming model"); @@ -176,6 +207,9 @@ size_t StreamingModel::probe_arena_size_() { void StreamingModel::unload_model() { this->interpreter_.reset(); +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_copy_.reset(); +#endif RAMAllocator arena_allocator; @@ -255,11 +289,15 @@ void StreamingModel::reset_probabilities() { this->ignore_windows_ = -MIN_SLICES_BEFORE_DETECTION; } -WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only) { +WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, + bool internal_only) { this->id_ = id; this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_average_size; @@ -291,6 +329,9 @@ WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr m // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is // never loaded. this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = this->model_data_ ? this->model_data_->size() : 0; +#endif if (this->model_start_ == nullptr) { ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); } @@ -354,9 +395,12 @@ DetectionEvent WakeWordModel::determine_detected() { return detection_event; } -VADModel::VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { +VADModel::VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_size; diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 1cb9d6eba54..730afe32bf7 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -5,6 +5,7 @@ #include "preprocessor_settings.h" #include "model_data.h" +#include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include @@ -96,6 +97,10 @@ class StreamingModel { std::vector recent_streaming_probabilities_; const uint8_t *model_start_{nullptr}; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + size_t model_size_{0}; + RAMUniquePtr model_copy_; +#endif uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -108,6 +113,7 @@ class WakeWordModel final : public StreamingModel { /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer + /// @param model_size (size_t) size of the compiled-in model data /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling /// probability @@ -115,9 +121,9 @@ class WakeWordModel final : public StreamingModel { /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena /// @param default_enabled (bool) If true, it will be enabled by default on first boot /// @param internal_only (bool) If true, the model will not be exposed to HomeAssistant as an available model - WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only); + WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, const std::string &wake_word, + size_t tensor_arena_size, bool default_enabled, bool internal_only); /// @brief Constructs a wake word model object with a runtime-downloaded model /// @param id (std::string) identifier for this model @@ -173,8 +179,8 @@ class WakeWordModel final : public StreamingModel { class VADModel final : public StreamingModel { public: - VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); void log_model_config() override; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 730e17fb53a..c4c20e80a6d 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -523,6 +523,7 @@ #ifdef USE_ESP32 #define USE_MICRO_WAKE_WORD +#define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #define USE_OPENTHREAD diff --git a/tests/components/micro_wake_word/common.yaml b/tests/components/micro_wake_word/common.yaml index 9ac1056ba49..2b9df607c87 100644 --- a/tests/components/micro_wake_word/common.yaml +++ b/tests/components/micro_wake_word/common.yaml @@ -13,6 +13,7 @@ microphone: micro_wake_word: microphone: echo_microphone task_stack_in_psram: true + vad: on_wake_word_detected: - logger.log: "Wake word detected" - micro_wake_word.stop: diff --git a/tests/components/micro_wake_word/test-internal.esp32-idf.yaml b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml new file mode 100644 index 00000000000..bddab918c2a --- /dev/null +++ b/tests/components/micro_wake_word/test-internal.esp32-idf.yaml @@ -0,0 +1,9 @@ +substitutions: + mic_din_pin: GPIO36 + +packages: + i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml + micro_wake_word: !include common.yaml + +micro_wake_word: + prefer_internal_ram: true diff --git a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml index a1b7b754236..8806d8046d9 100644 --- a/tests/components/micro_wake_word/test.esp32-s3-idf.yaml +++ b/tests/components/micro_wake_word/test.esp32-s3-idf.yaml @@ -3,5 +3,7 @@ substitutions: packages: i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-s3-idf.yaml + micro_wake_word: !include common.yaml -<<: !include common.yaml +micro_wake_word: + prefer_internal_ram: true From 61d98534f64ec63ecea0c6bf919b13aba3f16c70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 11:56:21 -1000 Subject: [PATCH 77/97] [uart] Stop ESP8266 software serial RX ISR from blocking for a whole byte (#18519) --- .../uart/software_serial_rx_decoder.h | 179 +++++++++++ .../uart/uart_component_esp8266.cpp | 75 +++-- .../components/uart/uart_component_esp8266.h | 21 +- .../uart/test_software_serial_rx_decoder.cpp | 285 ++++++++++++++++++ 4 files changed, 533 insertions(+), 27 deletions(-) create mode 100644 esphome/components/uart/software_serial_rx_decoder.h create mode 100644 tests/components/uart/test_software_serial_rx_decoder.cpp diff --git a/esphome/components/uart/software_serial_rx_decoder.h b/esphome/components/uart/software_serial_rx_decoder.h new file mode 100644 index 00000000000..d80dbf1a854 --- /dev/null +++ b/esphome/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index a32677b3daa..b81c0af85f1 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -16,6 +16,12 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); +// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes. +static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200; +// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a +// CpuFrequencyBoost; the cycle counter does not. +static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000; +__attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; } bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { @@ -158,6 +164,11 @@ void ESP8266UartComponent::dump_config() { "\n Wake on data RX: ENABLED" #endif ); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX decoder: %s", + this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge") + : LOG_STR_LITERAL("start bit sampler")); + } } this->check_logger_conflict(); } @@ -239,8 +250,10 @@ UARTFlushResult ESP8266UartComponent::flush() { void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size) { + // load_settings() re-enters here + if (this->gpio_rx_pin_ != nullptr) + this->gpio_rx_pin_->detach_interrupt(); this->bit_time_ = F_CPU / baud_rate; - this->rx_buffer_size_ = rx_buffer_size; this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; @@ -254,8 +267,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); rx_pin_ = gpio_rx_pin_->to_isr(); - rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT - gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + uint8_t *buffer = this->rx_.buffer(); + if (this->rx_.buffer_size() != rx_buffer_size) { + delete[] buffer; // NOLINT + buffer = new uint8_t[rx_buffer_size]; // NOLINT + } + this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE, + stop_bits, buffer, rx_buffer_size); + this->rx_.reset(rx_now(), this->rx_pin_.digital_read()); + if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE); + } else { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + } } } // A byte can arrive while a CpuFrequencyBoost has an 80 MHz build at 160 MHz; the clock select bit doubles @@ -288,8 +312,7 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { if (arg->stop_bits_ == 2) arg->wait_(&wait, start, bit_time); - arg->rx_buffer_[arg->rx_in_pos_] = rec; - arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_; + arg->rx_.push_byte(rec); // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -299,6 +322,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { wake_loop_isrsafe(); #endif } +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) { + const uint32_t now = rx_now(); + const bool level = arg->rx_pin_.digital_read(); + [[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level); +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (wake) + wake_loop_isrsafe(); +#endif +} +void ESP8266SoftwareSerial::rx_finalize_pending_() { + if (!this->rx_.finalize_due(rx_now())) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Tail not elapsed; wake again only once drained + if (this->rx_.available() == 0) + wake_loop_threadsafe(); +#endif + return; + } + InterruptLock lock; + this->rx_.finalize(rx_now()); +} void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { ESP_LOGE(TAG, "UART doesn't have TX pins set!"); @@ -350,28 +394,19 @@ void IRAM_ATTR ESP8266SoftwareSerial::write_bit_(bool bit, uint32_t *wait, const this->wait_(wait, start, bit_time); } uint8_t ESP8266SoftwareSerial::read_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - uint8_t data = this->rx_buffer_[this->rx_out_pos_]; - this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_; - return data; + this->rx_sync_(); + return this->rx_.read_byte(); } uint8_t ESP8266SoftwareSerial::peek_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - return this->rx_buffer_[this->rx_out_pos_]; + this->rx_sync_(); + return this->rx_.peek_byte(); } void ESP8266SoftwareSerial::flush() { // Flush is a NO-OP with software serial, all bytes are written immediately. } size_t ESP8266SoftwareSerial::available() { - // Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR. - // When in >= out, data is contiguous: [out..in). - // When in < out, data wraps: [out..buf_size) + [0..in). - size_t in = this->rx_in_pos_; - if (in >= this->rx_out_pos_) - return in - this->rx_out_pos_; - return this->rx_buffer_size_ - this->rx_out_pos_ + in; + this->rx_sync_(); + return this->rx_.available(); } } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 0098e752b2d..3d4a2ba85bc 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -7,6 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" #include "uart_component.h" namespace esphome::uart { @@ -26,24 +27,30 @@ class ESP8266SoftwareSerial { size_t available(); protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(ESP8266SoftwareSerial *arg); void wait_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); bool read_bit_(uint32_t *wait, const uint32_t &start, uint32_t bit_time); void write_bit_(bool bit, uint32_t *wait, const uint32_t &start, uint32_t bit_time); + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding uint32_t bit_time_{0}; - uint8_t *rx_buffer_{nullptr}; - size_t rx_buffer_size_; - volatile size_t rx_in_pos_{0}; - size_t rx_out_pos_{0}; - uint8_t stop_bits_; - uint8_t data_bits_; - UARTParityOptions parity_; InternalGPIOPin *gpio_tx_pin_{nullptr}; ISRInternalGPIOPin tx_pin_; InternalGPIOPin *gpio_rx_pin_{nullptr}; ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; }; class ESP8266UartComponent final : public UARTComponent, public Component { diff --git a/tests/components/uart/test_software_serial_rx_decoder.cpp b/tests/components/uart/test_software_serial_rx_decoder.cpp new file mode 100644 index 00000000000..b65e8a659fd --- /dev/null +++ b/tests/components/uart/test_software_serial_rx_decoder.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include +#include + +#include "esphome/components/uart/software_serial_rx_decoder.h" + +namespace esphome::uart::testing { + +namespace { + +// Firmware time base: micros() << 4 +constexpr uint32_t TICKS_PER_SECOND = 16000000; +constexpr uint32_t TICKS_PER_US = 16; + +// Simulated line: frames become edges at exact baud timing plus jitter, truncated +// to whole microseconds like micros(); poll() emulates the main loop. + +class LineSim { + public: + LineSim(uint32_t baud, uint8_t data_bits, bool parity, bool odd, uint8_t stop_bits, size_t buffer_size = 64) + : data_bits_(data_bits), parity_(parity), odd_(odd), stop_bits_(stop_bits), buffer_(buffer_size) { + this->bit_ = (TICKS_PER_SECOND + baud / 2) / baud; + this->exact_bit_ = static_cast(TICKS_PER_SECOND) / baud; + this->dec_.setup(this->bit_, data_bits, parity, stop_bits, this->buffer_.data(), buffer_size); + this->dec_.reset(this->now_, true); + } + + uint32_t bit_ticks() const { return this->bit_; } + SoftwareSerialRxDecoder &decoder() { return this->dec_; } + const std::vector &received() const { return this->received_; } + + void edge(uint32_t at, bool level) { + this->now_ = at; + this->line_ = level; + this->dec_.on_edge(at, level); + } + + // Main loop pass at cycle `at`. + void poll(uint32_t at) { + this->now_ = at; + if (this->dec_.pending() && this->dec_.finalize_due(at)) + this->dec_.finalize(at); + while (this->dec_.available() > 0) + this->received_.push_back(this->dec_.read_byte()); + } + + // Emit one frame at `start` with per edge `jitter` in ticks; returns the end tick. + uint32_t send( + uint8_t value, uint32_t start, const std::function &jitter = [] { return 0; }) { + std::vector bits; + bits.push_back(false); + int ones = 0; + for (int i = 0; i < this->data_bits_; i++) { + bool b = (value >> i) & 1; + bits.push_back(b); + ones += b; + } + if (this->parity_) + bits.push_back(this->odd_ ? !(ones & 1) : (ones & 1)); + for (int i = 0; i < this->stop_bits_; i++) + bits.push_back(true); + double t = start; + for (bool b : bits) { + if (b != this->line_) { + const auto at = static_cast(static_cast(t) + jitter()); + this->edge(at / TICKS_PER_US * TICKS_PER_US, b); + } + t += this->exact_bit_; + } + return static_cast(t); + } + + protected: + uint8_t data_bits_; + bool parity_; + bool odd_; + uint8_t stop_bits_; + uint32_t bit_{0}; + double exact_bit_{0}; + uint32_t now_{1000}; + bool line_{true}; + std::vector buffer_; + std::vector received_; + SoftwareSerialRxDecoder dec_; +}; + +struct FrameFormat { + uint32_t baud; + uint8_t data_bits; + bool parity; + bool odd; + uint8_t stop_bits; +}; + +// Random bytes, back to back or with idle gaps, with bounded edge jitter. +void run_stream(const FrameFormat &f, double jitter_bits, uint32_t gap_bits, int count, unsigned seed) { + LineSim sim(f.baud, f.data_bits, f.parity, f.odd, f.stop_bits); + std::mt19937 rng(seed); + std::uniform_real_distribution jit(-jitter_bits, jitter_bits); + const uint8_t mask = static_cast((1U << f.data_bits) - 1); + std::vector sent; + uint32_t t = 5000; + for (int n = 0; n < count; n++) { + uint8_t b = rng() & mask; + sent.push_back(b); + t = sim.send(b, t, [&] { return static_cast(jit(rng) * sim.bit_ticks()); }); + if (gap_bits != 0 && n % 3 == 2) { + t += gap_bits * sim.bit_ticks(); + sim.poll(t); + } else if (rng() % 4 == 0) { + sim.poll(t); + } + } + sim.poll(t + 20 * sim.bit_ticks()); + EXPECT_EQ(sim.received(), sent) << "baud " << f.baud << " jitter " << jitter_bits; +} + +} // namespace + +TEST(SoftwareSerialRxDecoder, DecodesBackToBackFramesAtCommonBaudRates) { + for (uint32_t baud : {2400U, 4800U, 9600U, 19200U, 38400U}) { + run_stream({baud, 8, false, false, 1}, 0.0, 0, 300, baud); + } +} + +TEST(SoftwareSerialRxDecoder, ToleratesEdgeJitterUpToAQuarterBit) { + // A quarter bit per edge keeps each run within the half bit rounding budget. + run_stream({9600, 8, false, false, 1}, 0.24, 0, 2000, 1); + run_stream({9600, 8, false, false, 1}, 0.24, 7, 2000, 2); + run_stream({19200, 8, false, false, 1}, 0.24, 4, 2000, 3); // edge decoder limit +} + +TEST(SoftwareSerialRxDecoder, HandlesParityDataBitsAndStopBits) { + run_stream({9600, 8, true, false, 1}, 0.2, 3, 1000, 4); // 8E1 + run_stream({4800, 8, true, true, 2}, 0.2, 2, 1000, 5); // 8O2 + run_stream({19200, 7, false, false, 2}, 0.2, 5, 1000, 6); // 7N2 + run_stream({2400, 5, false, false, 1}, 0.2, 1, 1000, 7); // 5N1 +} + +TEST(SoftwareSerialRxDecoder, AllOnesByteCompletesOnlyByFinalize) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + // 0xFF: start bit, then the line stays high with no closing edge. + sim.edge(t, false); + sim.edge(t + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.poll(t + bit + 8 * bit); + EXPECT_TRUE(sim.received().empty()); + EXPECT_FALSE(sim.decoder().finalize_due(t + bit + 8 * bit)); + sim.poll(t + bit + 10 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xFF); + EXPECT_FALSE(sim.decoder().pending()); +} + +TEST(SoftwareSerialRxDecoder, LastByteOfBurstIsFinalizedThenNextFrameDecodes) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = sim.send(0xA5, 5000); + t = sim.send(0xF0, t); // ends high, needs finalize + EXPECT_TRUE(sim.received().empty()); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 2u); + EXPECT_EQ(sim.received()[0], 0xA5); + EXPECT_EQ(sim.received()[1], 0xF0); + // The stale last edge must not confuse the next start bit. + t = sim.send(0x3C, t + 50 * bit); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 3u); + EXPECT_EQ(sim.received()[2], 0x3C); +} + +TEST(SoftwareSerialRxDecoder, BreakConditionIsDroppedAndResyncs) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + uint32_t t = 5000; + sim.edge(t, false); + t += 25 * bit; // line held low for far longer than a frame + sim.edge(t, true); + t += 3 * bit; + sim.poll(t); + EXPECT_TRUE(sim.received().empty()); + t = sim.send(0xA5, t); + sim.poll(t + 2 * bit); + ASSERT_EQ(sim.received().size(), 1u); + EXPECT_EQ(sim.received()[0], 0xA5); +} + +TEST(SoftwareSerialRxDecoder, CollapsedEdgeIsIgnoredAndStreamRealignsAtIdle) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + // 0x31: lose the rising edge of bit 4, so the next edge repeats the last level. + uint32_t t = 5000; + sim.edge(t, false); // start + sim.edge(t + 1 * bit, true); // bit0 = 1 + sim.edge(t + 2 * bit, false); // bits1..3 = 0 + sim.decoder().on_edge(t + 7 * bit, false); // should have been bit6 falling edge; level still low + sim.edge(t + 9 * bit, true); // stop + t += 10 * bit; + // Next frame decodes correctly once the line has idled. + t = sim.send(0x5A, t + 12 * bit); + sim.poll(t + 2 * bit); + ASSERT_FALSE(sim.received().empty()); + EXPECT_EQ(sim.received().back(), 0x5A); +} + +TEST(SoftwareSerialRxDecoder, DropsBytesWhenBufferIsFullAndKeepsOldest) { + LineSim sim(9600, 8, false, false, 1, 8); + uint32_t t = 5000; + for (int n = 0; n < 20; n++) + t = sim.send(static_cast(n), t); + sim.poll(t + 20 * sim.bit_ticks()); + ASSERT_EQ(sim.received().size(), 7u); // capacity is size - 1 + for (int n = 0; n < 7; n++) + EXPECT_EQ(sim.received()[n], n); +} + +TEST(SoftwareSerialRxDecoder, SetupAgainDropsStaleStateAndUsesNewBufferAndFraming) { + // Mirrors load_settings(): buffered 8N1 bytes and an open frame, then setup() as 5E2. + LineSim sim(9600, 8, false, false, 1); + uint32_t t = 5000; + for (int n = 0; n < 10; n++) + t = sim.send(static_cast(0x40 + n), t); + sim.edge(t + 8 * sim.bit_ticks(), false); // open a frame, never closed + SoftwareSerialRxDecoder &dec = sim.decoder(); + ASSERT_GE(dec.available(), 9u); + + std::vector small(4, 0xEE); + const uint32_t bit = TICKS_PER_SECOND / 2400; + dec.setup(bit, 5, true, 2, small.data(), small.size()); + EXPECT_EQ(dec.available(), 0u); + EXPECT_FALSE(dec.pending()); + EXPECT_EQ(dec.read_byte(), 0); + + // Capacity of the 4 byte buffer is 3; the spare slot must stay untouched. + auto send_5e2 = [&](uint8_t value, uint32_t start) { + bool line = true; + uint32_t at = start; + auto put = [&](bool b) { + if (b != line) { + dec.on_edge(at, b); + line = b; + } + at += bit; + }; + put(false); + int ones = 0; + for (int i = 0; i < 5; i++) { + bool b = (value >> i) & 1; + ones += b; + put(b); + } + put(ones & 1); + put(true); + put(true); + return at; + }; + uint32_t t2 = 5000; + for (int n = 1; n <= 6; n++) + t2 = send_5e2(static_cast(n), t2); + dec.finalize(t2 + 20 * bit); + ASSERT_EQ(dec.available(), 3u); + EXPECT_EQ(dec.read_byte(), 1); + EXPECT_EQ(dec.read_byte(), 2); + EXPECT_EQ(dec.read_byte(), 3); + EXPECT_EQ(small[3], 0xEE); // capacity slot is never written +} + +TEST(SoftwareSerialRxDecoder, ResetDiscardsPartialFrame) { + LineSim sim(9600, 8, false, false, 1); + const uint32_t bit = sim.bit_ticks(); + sim.edge(5000, false); + sim.edge(5000 + bit, true); + EXPECT_TRUE(sim.decoder().pending()); + sim.decoder().reset(5000 + 2 * bit, true); + EXPECT_FALSE(sim.decoder().pending()); + sim.poll(5000 + 30 * bit); + EXPECT_TRUE(sim.received().empty()); +} + +} // namespace esphome::uart::testing From 4155b82f84dd6d6035355658dd627cb5b1221c0a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:58:31 -0400 Subject: [PATCH 78/97] [usb_uart] Fix invalid stop bits and parity enum mappings (#20408) --- esphome/components/uart/uart.cpp | 4 +- esphome/components/uart/uart_component.h | 3 ++ .../components/uart/uart_component_host.cpp | 2 + .../uart/uart_component_libretiny.cpp | 2 + esphome/components/usb_uart/__init__.py | 8 ++- esphome/components/usb_uart/ch34x.cpp | 13 ++--- esphome/components/usb_uart/cp210x.cpp | 4 +- esphome/components/usb_uart/ft23xx.cpp | 30 +---------- esphome/components/usb_uart/pl2303.cpp | 29 +--------- esphome/components/usb_uart/usb_uart.cpp | 50 +++++++++++++---- esphome/components/usb_uart/usb_uart.h | 53 +++++++++---------- 11 files changed, 91 insertions(+), 107 deletions(-) diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 20f6385b6a7..56b8e6401e8 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -10,8 +10,8 @@ namespace esphome::uart { ESPHOME_LOG_TAG(TAG, "uart"); -// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD -PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN"); +// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE +PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN"); void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, uint8_t data_bits) { diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 2543cb54768..6d446fd6a6f 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -18,6 +18,9 @@ enum UARTParityOptions { UART_CONFIG_PARITY_NONE, UART_CONFIG_PARITY_EVEN, UART_CONFIG_PARITY_ODD, + // Only USB serial adapters (usb_uart) support these + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, }; #ifdef USE_UART_DEBUGGER diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 9e2f273f47c..7fb704536dd 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -158,6 +158,8 @@ void HostUartComponent::setup() { options.c_cflag |= PARENB; options.c_cflag |= PARODD; break; + default: + break; }; // Set stop bits if (this->stop_bits_ == 2) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 5a77bf26d9e..802a88ba80d 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -38,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() { case UART_CONFIG_PARITY_ODD: config |= SERIAL_PARITY_ODD; break; + default: + break; } config |= (this->data_bits_ - 4) << 8; diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 2898576b323..581727b326a 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent +from esphome.components.uart import ( + CONF_DEBUG_PREFIX, + CONF_FLUSH_TIMEOUT, + UARTComponent, + UARTParityOptions, +) from esphome.components.usb_host import ( get_max_packet_size, register_usb_client, @@ -39,7 +44,6 @@ def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: ) -UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 00c5e0b0694..ed5a034b82f 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo factor = 256 - factor; uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2) value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; - } + // Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space + const LineParity parity = channel->line_parity_(); + if (parity != LineParity::LINE_PARITY_NONE) + value |= 8 | ((static_cast(parity) - 1) << 4); value |= channel->data_bits_ - 5; value <<= 8; value |= 0x8C; diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1d..e920da6c267 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); return true; case 1: { - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; + uint16_t line_control = static_cast(channel->line_stop_bits_()); + line_control |= static_cast(channel->line_parity_()) << 4; line_control |= channel->data_bits_ << 8; ESP_LOGD(TAG, "Line control value 0x%X", line_control); this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd95..e10da78aaf0 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b } case 2: { // set line properties (data bits / parity / stop bits) uint16_t value = channel->data_bits_; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - switch (channel->stop_bits_) { - default: // 1 bit - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } + value |= static_cast(channel->line_parity_()) << 8; + value |= static_cast(channel->line_stop_bits_()) << 11; value |= (0x00 << 14); this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, channel->cdc_dev_.bulk_interface_number + 1); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308d..36107c093c4 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b encode_baud_divisor(line_coding, baud); } - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } + line_coding[4] = static_cast(channel->line_stop_bits_()); - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } + line_coding[5] = static_cast(channel->line_parity_()); // Data bits line_coding[6] = channel->get_data_bits(); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 86dfa6d729f..f61642566cb 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -270,9 +270,10 @@ void USBUartComponent::dump_config() { " Flush Timeout: %" PRIu32 " ms\n" " Debug: %s\n" " Dummy receiver: %s", - channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_], - STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_), - YESNO(channel->dummy_receiver_)); + channel->index_, channel->baud_rate_, channel->data_bits_, + PARITY_NAMES[static_cast(channel->line_parity_())], + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], channel->flush_timeout_ms_, + YESNO(channel->debug_), YESNO(channel->dummy_receiver_)); } } void USBUartComponent::start_input(USBUartChannelBase *channel) { @@ -515,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // sends RSTACK. uint32_t baud = channel->baud_rate_; std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits + static_cast(baud & 0xFF), + static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), + static_cast((baud >> 24) & 0xFF), + static_cast(channel->line_stop_bits_()), // bCharFormat + static_cast(channel->line_parity_()), // bParityType + static_cast(channel->data_bits_), // bDataBits }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud, + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], + PARITY_NAMES[static_cast(channel->line_parity_())], channel->data_bits_); this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, line_coding); return true; @@ -675,6 +679,32 @@ bool USBUartComponent::run_config_machine_() { return true; } +LineStopBits USBUartChannelBase::line_stop_bits_() const { + switch (this->stop_bits_) { + case UART_CONFIG_STOP_BITS_1_5: + return LineStopBits::LINE_STOP_BITS_1_5; + case UART_CONFIG_STOP_BITS_2: + return LineStopBits::LINE_STOP_BITS_2; + default: + return LineStopBits::LINE_STOP_BITS_1; + } +} + +LineParity USBUartChannelBase::line_parity_() const { + switch (this->parity_) { + case uart::UART_CONFIG_PARITY_ODD: + return LineParity::LINE_PARITY_ODD; + case uart::UART_CONFIG_PARITY_EVEN: + return LineParity::LINE_PARITY_EVEN; + case uart::UART_CONFIG_PARITY_MARK: + return LineParity::LINE_PARITY_MARK; + case uart::UART_CONFIG_PARITY_SPACE: + return LineParity::LINE_PARITY_SPACE; + default: + return LineParity::LINE_PARITY_NONE; + } +} + void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index c6ae0b4eef5..4e52fb60e20 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -71,21 +71,33 @@ enum CH34xChipType : uint8_t { CHIP_UNKNOWN = 0xFF, }; -enum UARTParityOptions { - UART_CONFIG_PARITY_NONE = 0, - UART_CONFIG_PARITY_ODD, - UART_CONFIG_PARITY_EVEN, - UART_CONFIG_PARITY_MARK, - UART_CONFIG_PARITY_SPACE, -}; - +// 1 and 2 are the stop bit count the uart component uses, so a count set through the +// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count. enum UARTStopBitsOptions { - UART_CONFIG_STOP_BITS_1 = 0, - UART_CONFIG_STOP_BITS_1_5, - UART_CONFIG_STOP_BITS_2, + UART_CONFIG_STOP_BITS_1 = 1, + UART_CONFIG_STOP_BITS_2 = 2, + UART_CONFIG_STOP_BITS_1_5 = 3, }; +// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the +// CP210x, FTDI and PL2303 requests, all use these values +enum class LineStopBits : uint8_t { + LINE_STOP_BITS_1 = 0, + LINE_STOP_BITS_1_5 = 1, + LINE_STOP_BITS_2 = 2, +}; + +enum class LineParity : uint8_t { + LINE_PARITY_NONE = 0, + LINE_PARITY_ODD = 1, + LINE_PARITY_EVEN = 2, + LINE_PARITY_MARK = 3, + LINE_PARITY_SPACE = 4, +}; + +// Indexed by LineParity static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +// Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; class RingBuffer { @@ -156,22 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; - // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. - // MARK/SPACE have no uart:: equivalent and report as NONE. - switch (parity) { - case UART_CONFIG_PARITY_EVEN: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); - break; - case UART_CONFIG_PARITY_ODD: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); - break; - default: - uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); - break; - } - } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } @@ -194,6 +190,8 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented output_queue_; @@ -205,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_started_{true}; From 94894d60b77e36f7c6e72dc74116b175fbbb7b4b Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 12 Oct 2026 00:15:23 +0200 Subject: [PATCH 79/97] [nrf52] fix zigbee time component (#20527) Co-authored-by: Claude --- esphome/components/time/__init__.py | 8 +++++++ esphome/components/zigbee/time/__init__.py | 2 ++ .../zigbee/time/zigbee_time_zephyr.cpp | 22 ++++++++++++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index a017109e7d3..2ad3cd9c314 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -26,6 +26,8 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -455,6 +457,12 @@ async def register_time(time_var, config): async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("POSIX_CLOCK", True) + # From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(), + # which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time() + # always returns -1. + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0): + zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True) + zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True) cg.add_define("USE_TIME") cg.add_global(time_ns.using) diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 74f81df2e96..0572e591cfc 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -14,6 +14,7 @@ from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, get_slot_index, + zigbee_assign, zigbee_new_attr_list, zigbee_new_cluster_list, zigbee_new_variable, @@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None: # Create the binary attributes structure time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t") + zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE")) attr_list = zigbee_new_attr_list( attr_list_name, "ZB_ZCL_DECLARE_TIME_ATTR_LIST", diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 7f0a5f979ce..0de0ee0ee48 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include namespace esphome::zigbee { @@ -9,12 +10,14 @@ ESPHOME_LOG_TAG(TAG, "zigbee.time"); // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; +// Largest ZCL time that still fits the 32-bit epoch. Also rejects ZB_ZCL_TIME_TIME_INVALID_VALUE. +constexpr uint32_t MAX_ZCL_TIME = std::numeric_limits::max() - EPOCH_2000; ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t short_addr, zb_uint8_t endpoint, zb_uint32_t nw_time) { - if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT) { + if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT && nw_time <= MAX_ZCL_TIME) { global_time->set_epoch_time(nw_time + EPOCH_2000); } else if (status != RET_TIMEOUT || !global_time->has_time_) { ESP_LOGE(TAG, "Status: %d, auth_level: %u, short_addr: %d, endpoint: %d, nw_time: %u", status, auth_level, @@ -25,7 +28,6 @@ void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t void ZigbeeTime::setup() { global_time = this; this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); - synchronize_epoch_(EPOCH_2000); this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); } @@ -38,8 +40,12 @@ void ZigbeeTime::dump_config() { } void ZigbeeTime::update() { - time_t time = timestamp_now(); - this->cluster_attributes_->time = time - EPOCH_2000; + const time_t now = this->timestamp_now(); + if (now < EPOCH_2000) { + this->cluster_attributes_->time = ZB_ZCL_TIME_TIME_INVALID_VALUE; + } else { + this->cluster_attributes_->time = static_cast(now - EPOCH_2000); + } } void ZigbeeTime::set_epoch_time(uint32_t epoch) { @@ -61,8 +67,12 @@ void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { if (cluster_id == ZB_ZCL_CLUSTER_ID_TIME) { if (attr_id == ZB_ZCL_ATTR_TIME_TIME_ID) { zb_uint32_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data32; - ESP_LOGI(TAG, "Synchronize time to %u", value); - this->defer([this, value]() { synchronize_epoch_(value + EPOCH_2000); }); + if (value <= MAX_ZCL_TIME) { + ESP_LOGI(TAG, "Synchronize time to %u", value); + this->defer([this, value]() { this->synchronize_epoch_(value + EPOCH_2000); }); + } else { + ESP_LOGW(TAG, "Ignoring invalid time %u", value); + } } else if (attr_id == ZB_ZCL_ATTR_TIME_TIME_STATUS_ID) { zb_uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8; ESP_LOGI(TAG, "Time status %hd", value); From 96eaf423366847d8e1e9380c36785950352420fd Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:22:29 +1300 Subject: [PATCH 80/97] [api] Let a wizard input pick multiple entities (#20537) --- esphome/components/api/api.proto | 21 +++- esphome/components/api/api_wizard.cpp | 56 +++++++++-- esphome/components/api/api_wizard.h | 35 ++++++- esphome/components/api/wizard.py | 55 +++++++++-- esphome/core/defines.h | 1 + tests/component_tests/api/test_wizard.py | 118 ++++++++++++++++++++++- tests/components/api/__init__.py | 3 +- tests/components/api/common-wizard.yaml | 20 ++++ tests/components/api/test_wizard.cpp | 73 ++++++++++++++ 9 files changed, 360 insertions(+), 22 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index d4ae82fd9f1..1b06348e2ce 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -444,7 +444,7 @@ message DeviceWizardRequest { // {"version": 1, // "pages": [{"title": "...", "description": "...", // "entities": [{"key": 123, "device_id": 456, "description": "..."}], -// "inputs": [{"key": 789, "description": "...", +// "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, // "entity_filters": [{"integration": "...", "domain": ["..."], // "device_class": ["..."], "supported_features": ["..."]}]}]}]} // @@ -468,6 +468,14 @@ message DeviceWizardRequest { // an entity matches a filter when it satisfies every field that is set, and an entity // that matches any one of the filters is accepted. // +// An input with "multiple": true lets the user pick several entities. The client sends +// them in one WizardInputSetRequest as a comma separated list with no spaces +// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the +// list without them. "max_length" (only on a multiple input, 255 when left out) is the +// longest list in bytes the device accepts. The version stays 1: a client that does not +// know "multiple" ignores it, shows a single picker and sends one entity id, which a +// multiple input accepts. +// // The device keeps the entity id of an input in RAM only and does not store it across // restarts. The client therefore stores the choice itself and, after every connection // and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest @@ -483,8 +491,13 @@ message DeviceWizardResponse { // Sets the entity id of a wizard input (see WizardInputField.key). Clients send // this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no -// reply. The device ignores a request whose key matches no input or whose entity_id -// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// reply. The device ignores a request whose key matches no input. A single input is +// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple +// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no +// spaces, the device trims spaces around each id and stores the list without them. The +// whole request is ignored when any id is empty or has no '.', or when the list without +// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id +// or list in RAM, // not across restarts, and when the input belongs to a homeassistant platform entity // it subscribes to that entity's state. Clients send this for every input right // after connecting, before SubscribeHomeAssistantStatesRequest. A request that @@ -496,7 +509,7 @@ message WizardInputSetRequest { option (ifdef) = "USE_API_WIZARD_INPUTS"; fixed32 key = 1; - string entity_id = 2 [(max_data_length) = 255]; + string entity_id = 2 [(max_data_length) = 4095]; } message ListEntitiesRequest { diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp index 10a5cbc70cc..9467efcfa0c 100644 --- a/esphome/components/api/api_wizard.cpp +++ b/esphome/components/api/api_wizard.cpp @@ -14,26 +14,68 @@ namespace esphome::api { ESPHOME_LOG_TAG(TAG, "api.wizard"); #ifdef USE_API_WIZARD_INPUTS +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined +// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr. +static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) { + size_t joined = 0; + const char *end = value + length; + while (true) { + const char *comma = static_cast(memchr(value, ',', end - value)); + const char *id_end = comma != nullptr ? comma : end; + while (value < id_end && *value == ' ') + value++; + while (id_end > value && id_end[-1] == ' ') + id_end--; + size_t id_length = id_end - value; + if (id_length == 0 || memchr(value, '.', id_length) == nullptr) + return 0; + if (joined != 0) { + if (out != nullptr) + out[joined] = ','; + joined++; + } + if (out != nullptr) + memcpy(out + joined, value, id_length); + joined += id_length; + if (comma == nullptr) + return joined; + value = comma + 1; + } +} +#endif // USE_API_WIZARD_MULTIPLE_INPUTS + static bool wizard_entity_id_valid(const char *entity_id, size_t length) { return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; } const char *wizard_set_input(const WizardInputSetRequest &msg) { - if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { - ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); - return nullptr; - } + const char *value = msg.entity_id.c_str(); + const size_t length = msg.entity_id.size(); for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { // The table is in flash, which ESP8266 can only read through progmem_memcpy WizardInputEntry entry; progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); if (entry.key != msg.key) continue; - memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); - entry.entity_id[msg.entity_id.size()] = '\0'; +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + if (entry.size != 0) { + // Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged + const size_t joined = wizard_join_entity_ids(value, length, nullptr); + if (joined == 0 || joined >= entry.size) + break; + wizard_join_entity_ids(value, length, entry.entity_id); + entry.entity_id[joined] = '\0'; + return entry.entity_id; + } +#endif + if (!wizard_entity_id_valid(value, length)) + break; + memcpy(entry.entity_id, value, length); + entry.entity_id[length] = '\0'; return entry.entity_id; } - ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid"); return nullptr; } #endif // USE_API_WIZARD_INPUTS diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h index 7d5a566b550..6bdc1654bcb 100644 --- a/esphome/components/api/api_wizard.h +++ b/esphome/components/api/api_wizard.h @@ -6,6 +6,9 @@ #include #include +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +#include +#endif #include "esphome/core/hal.h" #include "proto.h" @@ -26,7 +29,10 @@ extern const uint8_t API_WIZARD_DATA[] PROGMEM; /// Where the entity id of an input is kept, found by the key the client uses for it. struct WizardInputEntry { uint32_t key; // FNV-1 hash of the ESPHome id of the input - char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity + char *entity_id; // RAM buffer, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + uint16_t size; // Buffer size of a multiple input, 0 for a single input +#endif }; /// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. @@ -44,9 +50,34 @@ const char *wizard_set_input(const WizardInputSetRequest &msg); class WizardInput { public: explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} - /// The Home Assistant entity ID, empty until the wizard sets one. + /// The Home Assistant entity ID, empty until the wizard sets one. For a multiple input this is the whole + /// comma separated list, which homeassistant.action data can use as it is. StringRef entity_id() const { return StringRef(this->entity_id_); } bool has_entity_id() const { return this->entity_id_[0] != '\0'; } +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + /// The number of entity IDs in the list, 0 until the wizard sets one. + size_t entity_id_count() const { + if (!this->has_entity_id()) + return 0; + size_t count = 1; + for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++) + count++; + return count; + } + /// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is + /// not null terminated, so print it with "%.*s". + template void for_each_entity_id(F &&callback) const { + // The device stores the list without spaces or empty entries + const char *start = this->entity_id_; + while (*start != '\0') { + const char *end = strchr(start, ','); + if (end == nullptr) + end = start + strlen(start); + callback(StringRef(start, end - start)); + start = *end == ',' ? end + 1 : end; + } + } +#endif protected: const char *entity_id_; diff --git a/esphome/components/api/wizard.py b/esphome/components/api/wizard.py index 3ab80a063ee..8e5c808e24c 100644 --- a/esphome/components/api/wizard.py +++ b/esphome/components/api/wizard.py @@ -20,6 +20,8 @@ from esphome.const import ( CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL, + CONF_MAX_LENGTH, + CONF_MULTIPLE, CONF_NAME, CONF_PAGES, CONF_PLATFORM, @@ -51,6 +53,10 @@ WizardInput = _API.class_("WizardInput") WIZARD_ENTITY_ID_BUFFER_SIZE = ( 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes ) +WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1 +# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266; +# api.proto max_data_length documents the same value +WIZARD_MULTIPLE_MAX_LENGTH = 4095 # One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, # Noise) and footer (16 bytes, Noise MAC) WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 @@ -106,6 +112,21 @@ WIZARD_ENTITY_SCHEMA = cv.Schema( ) +def _validate_multiple(conf: ConfigType) -> ConfigType: + if conf[CONF_MULTIPLE] and CONF_ENTITY in conf: + raise cv.Invalid( + "A linked input follows the state of exactly one Home Assistant entity, so multiple " + "is only for standalone inputs (id)", + path=[CONF_MULTIPLE], + ) + if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]: + raise cv.Invalid( + "max_length only applies to an input with multiple: true", + path=[CONF_MAX_LENGTH], + ) + return conf + + # An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no # entity_id of its own: Home Assistant sets it WIZARD_INPUT_SCHEMA = cv.All( @@ -114,6 +135,10 @@ WIZARD_INPUT_SCHEMA = cv.All( cv.Optional(CONF_ID): cv.declare_id(WizardInput), cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_MULTIPLE, default=False): cv.boolean, + cv.Optional(CONF_MAX_LENGTH): cv.int_range( + min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_MAX_LENGTH + ), cv.Optional(CONF_TARGET): cv.Schema( { cv.Required(CONF_ENTITY): cv.All( @@ -124,6 +149,7 @@ WIZARD_INPUT_SCHEMA = cv.All( } ), cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), + _validate_multiple, ) WIZARD_PAGE_SCHEMA = cv.All( @@ -248,6 +274,8 @@ def _wizard_defines(wizard: ConfigType) -> set[str]: defines.add("USE_API_WIZARD_LINKED_INPUTS") if any(CONF_ID in conf for conf in inputs): defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + if any(conf.get(CONF_MULTIPLE) for conf in inputs): + defines.add("USE_API_WIZARD_MULTIPLE_INPUTS") return defines @@ -371,6 +399,10 @@ def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigT document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} if description := conf.get(CONF_DESCRIPTION): document[CONF_DESCRIPTION] = description + if conf.get(CONF_MULTIPLE): + document["multiple"] = True + if (max_length := conf.get(CONF_MAX_LENGTH)) is not None: + document["max_length"] = max_length if filters := _wizard_input_filters(conf, config): document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] return document @@ -384,7 +416,7 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi {"version": 1, "pages": [{"title": "...", "description": "...", "entities": [{"key": 123, "device_id": 456, "description": "..."}], - "inputs": [{"key": 789, "description": "...", + "inputs": [{"key": 789, "description": "...", "multiple": true, "max_length": 1023, "entity_filters": [{"integration": "...", "domain": ["..."], "device_class": ["..."], "supported_features": ["..."]}]}]}]} @@ -396,6 +428,14 @@ def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> Confi the device it belongs to (esphome/core/config.py), and is left out for the main device. - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - multiple means the user may pick several entities. The client sends them in one + WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the + device trims spaces around each id and stores the list without them. The whole request is + ignored when any id is empty or has no '.', or when the list without spaces does not fit + max_length (only on a multiple input, 255 when left out). A single input is ignored when the id + is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know + multiple ignores it, shows a single picker and sends one entity id, which a multiple input + accepts. - entity_filters are the filters of the input, or the default filters of a linked switch, number, text, select or button. """ @@ -441,14 +481,15 @@ async def to_code(wizard: ConfigType) -> None: input_id = _wizard_input_id(conf) # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. buffer = _wizard_buffer_name(input_id) - cg.add_global( - cg.RawStatement( - f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' - ) - ) + size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1 + cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";')) if CONF_ID in conf: cg.new_Pvariable(input_id, cg.RawExpression(buffer)) - entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + key = fnv1_hash(input_id.id) + if conf[CONF_MULTIPLE]: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}")) + else: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}}}")) if entries: cg.extern_progmem_array( "esphome::api::API_WIZARD_INPUTS", diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c4c20e80a6d..d219296ca57 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -282,6 +282,7 @@ #define USE_API_WIZARD #define USE_API_WIZARD_INPUTS #define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_MULTIPLE_INPUTS #define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS diff --git a/tests/component_tests/api/test_wizard.py b/tests/component_tests/api/test_wizard.py index 112092482b2..f55a6974e7f 100644 --- a/tests/component_tests/api/test_wizard.py +++ b/tests/component_tests/api/test_wizard.py @@ -490,6 +490,7 @@ WIZARD_DEFINES = { "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", } @@ -550,9 +551,28 @@ WIZARD_DEFINES = { ), pytest.param( "- entities: [{id: sw}]\n inputs: [{entity: ha_sensor, target: {entity: [{domain: sensor}]}}, {id: standalone}]", - WIZARD_DEFINES, + WIZARD_DEFINES - {"USE_API_WIZARD_MULTIPLE_INPUTS"}, id="everything", ), + pytest.param( + "- inputs: [{id: calendars, multiple: true}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", + }, + id="multiple-input", + ), + pytest.param( + "- inputs: [{id: calendars, multiple: false}]", + { + "USE_API_WIZARD", + "USE_API_WIZARD_INPUTS", + "USE_API_WIZARD_STANDALONE_INPUTS", + }, + id="multiple-false", + ), ], ) def test_only_the_defines_the_wizard_needs_are_emitted( @@ -925,3 +945,99 @@ def test_an_entity_with_an_entity_id_that_is_no_input_is_valid( assert "api_wizard_input_ha_plain_sensor" not in main_cpp # Every buffer starts empty assert '] = "sensor.' not in main_cpp.split("API_WIZARD_INPUTS")[0] + + +@pytest.mark.parametrize( + "input_conf", + [ + "{id: cals, multiple: true}", + "{id: cals, multiple: true, max_length: 255}", + "{id: cals, multiple: true, max_length: 4095}", + "{id: cals, multiple: false}", + ], +) +def test_multiple_input_is_valid(tmp_path: Path, input_conf: str) -> None: + api = wizard_inputs(f"- {input_conf}") + + assert config_errors(write_config(tmp_path, ESP32_HEADER, api)) == [] + + +@pytest.mark.parametrize( + ("inputs", "message"), + [ + pytest.param( + "- {entity: ha_sensor, multiple: true}", + "multiple is only for standalone inputs", + id="multiple-linked", + ), + pytest.param( + "- {id: cals, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-without-multiple", + ), + pytest.param( + "- {id: cals, multiple: false, max_length: 1023}", + "max_length only applies to an input with multiple: true", + id="max-length-multiple-false", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 254}", + "value must be at least 255", + id="max-length-too-small", + ), + pytest.param( + "- {id: cals, multiple: true, max_length: 4096}", + "value must be at most 4095", + id="max-length-too-big", + ), + ], +) +def test_invalid_multiple_inputs_are_rejected( + tmp_path: Path, inputs: str, message: str +) -> None: + errors = config_errors(write_config(tmp_path, ESP32_HEADER, wizard_inputs(inputs))) + + assert any(message in error for error in errors), errors + + +def test_multiple_input_document_and_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + api = wizard_inputs( + """ + - {id: cals, multiple: true, max_length: 1023} + - {id: cals_default, multiple: true} + - {id: single} + """ + ) + main_cpp = generate_main(write_config(tmp_path, ESP32_HEADER, api)) + + document = json.loads(zstd_module().decompress(blob_in(main_cpp))) + inputs = document["pages"][0]["inputs"] + # multiple and max_length are only there when set + assert inputs == [ + {"key": fnv1_hash("cals"), "multiple": True, "max_length": 1023}, + {"key": fnv1_hash("cals_default"), "multiple": True}, + {"key": fnv1_hash("single")}, + ] + # Only a multiple entry names its buffer size + assert 'static char api_wizard_input_cals[1024] = "";' in main_cpp + assert 'static char api_wizard_input_cals_default[256] = "";' in main_cpp + assert 'static char api_wizard_input_single[256] = "";' in main_cpp + assert f"{{{fnv1_hash('cals')}u, api_wizard_input_cals, 1024}}" in main_cpp + assert ( + f"{{{fnv1_hash('cals_default')}u, api_wizard_input_cals_default, 256}}" + in main_cpp + ) + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + + +def test_inputs_without_multiple_keep_the_two_field_table( + tmp_path: Path, generate_main: Callable[[str | Path], str] +) -> None: + main_cpp = generate_main( + write_config(tmp_path, ESP32_HEADER, wizard_inputs("- id: single")) + ) + + assert f"{{{fnv1_hash('single')}u, api_wizard_input_single}}" in main_cpp + assert "MULTIPLE_INPUTS" not in {d.name for d in CORE.defines} diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py index bd84c8d9bb4..79feb398bda 100644 --- a/tests/components/api/__init__.py +++ b/tests/components/api/__init__.py @@ -23,11 +23,12 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "USE_API_WIZARD", "USE_API_WIZARD_INPUTS", "USE_API_WIZARD_LINKED_INPUTS", + "USE_API_WIZARD_MULTIPLE_INPUTS", "USE_API_WIZARD_STANDALONE_INPUTS", ): cg.add_define(define) cg.add_define("API_WIZARD_DATA_SIZE", 200) - cg.add_define("API_WIZARD_INPUT_COUNT", 2) + cg.add_define("API_WIZARD_INPUT_COUNT", 3) cg.add_define("API_MAX_SEND_QUEUE", 8) cg.add_define("MAX_API_CONNECTIONS", 1) cg.add_define("USE_SOCKET_IMPL_BSD_SOCKETS") diff --git a/tests/components/api/common-wizard.yaml b/tests/components/api/common-wizard.yaml index 5e1936a042d..d658da1c1f2 100644 --- a/tests/components/api/common-wizard.yaml +++ b/tests/components/api/common-wizard.yaml @@ -41,6 +41,13 @@ api: entity: - domain: weather - id: unset_input + - id: calendars_input + description: Calendars + multiple: true + max_length: 1023 + target: + entity: + - domain: calendar switch: - platform: template @@ -106,3 +113,16 @@ interval: data: type: daily entity_id: !lambda 'return id(weather_input).entity_id().c_str();' + - if: + condition: + api.wizard.input_is_set: calendars_input + then: + - homeassistant.action: + action: calendar.get_events + data: + duration: "24:00:00" + entity_id: !lambda 'return id(calendars_input).entity_id().c_str();' + - lambda: |- + ESP_LOGD("wizard", "%u calendars", (unsigned) id(calendars_input).entity_id_count()); + id(calendars_input).for_each_entity_id( + [](StringRef calendar) { ESP_LOGD("wizard", "Calendar %.*s", (int) calendar.size(), calendar.c_str()); }); diff --git a/tests/components/api/test_wizard.cpp b/tests/components/api/test_wizard.cpp index 033c8b627e5..54aeb4b4ed6 100644 --- a/tests/components/api/test_wizard.cpp +++ b/tests/components/api/test_wizard.cpp @@ -23,8 +23,15 @@ static char *wizard_input_other() { return buffer; } +static constexpr size_t CALENDARS_SIZE = 64; +static char *wizard_input_calendars() { + static char buffer[CALENDARS_SIZE] = ""; + return buffer; +} + static constexpr uint32_t WEATHER_KEY = 0x0a0b0c0d; static constexpr uint32_t OTHER_KEY = 0x11223344; +static constexpr uint32_t CALENDARS_KEY = 0x55667788; // The same shapes the generated code emits. The data is 200 bytes (API_WIZARD_DATA_SIZE), so its length takes two // bytes as a varint. @@ -43,6 +50,7 @@ const uint8_t API_WIZARD_DATA[API_WIZARD_DATA_SIZE] = { const WizardInputEntry API_WIZARD_INPUTS[API_WIZARD_INPUT_COUNT] = { {WEATHER_KEY, wizard_input_weather()}, {OTHER_KEY, wizard_input_other()}, + {CALENDARS_KEY, wizard_input_calendars(), CALENDARS_SIZE}, }; using Bytes = std::vector; @@ -144,5 +152,70 @@ TEST(DeviceWizard, StandaloneInputReadsTheBufferTheWizardWrites) { EXPECT_EQ(input.entity_id(), "weather.home"); } +TEST(DeviceWizard, MultipleInputStoresTheList) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + // One id is a list of one + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.c"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.c"); +} + +TEST(DeviceWizard, MultipleInputStoresTheListWithoutSpaces) { + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a , calendar.b"), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b"); + EXPECT_EQ(set_input(CALENDARS_KEY, " calendar.a , calendar.b ,calendar.c "), wizard_input_calendars()); + EXPECT_STREQ(wizard_input_calendars(), "calendar.a,calendar.b,calendar.c"); +} + +TEST(DeviceWizard, MultipleInputIgnoresTheWholeRequestForABadEntry) { + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.keep"), wizard_input_calendars()); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,nodot"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,,calendar.b"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, "calendar.a,"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ",calendar.a"), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, " "), nullptr); + EXPECT_EQ(set_input(CALENDARS_KEY, ""), nullptr); + EXPECT_STREQ(wizard_input_calendars(), "calendar.keep"); +} + +TEST(DeviceWizard, MultipleInputIsLimitedByItsBuffer) { + // Pad the last id so the list fills the buffer + std::string fits = "calendar.a,calendar.b,calendar." + std::string(CALENDARS_SIZE - 1 - 31, 'x'); + ASSERT_EQ(fits.size(), CALENDARS_SIZE - 1); + EXPECT_EQ(set_input(CALENDARS_KEY, fits.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + + std::string too_long = fits + "x"; + EXPECT_EQ(set_input(CALENDARS_KEY, too_long.c_str()), nullptr); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); + // Spaces do not count towards the limit + std::string spaced = fits; + spaced.replace(spaced.find(','), 1, " , "); + EXPECT_EQ(set_input(CALENDARS_KEY, spaced.c_str()), wizard_input_calendars()); + EXPECT_EQ(std::string(wizard_input_calendars()), fits); +} + +TEST(DeviceWizard, MultipleInputHelpersWalkTheList) { + strcpy(wizard_input_calendars(), ""); + WizardInput input(wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 0u); + std::vector seen; + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_TRUE(seen.empty()); + + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 1u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a"})); + + seen.clear(); + ASSERT_EQ(set_input(CALENDARS_KEY, "calendar.a, calendar.b,calendar.c"), wizard_input_calendars()); + EXPECT_EQ(input.entity_id_count(), 3u); + input.for_each_entity_id([&seen](StringRef id) { seen.push_back(id.str()); }); + EXPECT_EQ(seen, (std::vector{"calendar.a", "calendar.b", "calendar.c"})); + // The raw list is what homeassistant.action data receives + EXPECT_EQ(input.entity_id(), "calendar.a,calendar.b,calendar.c"); +} + } // namespace esphome::api #endif // USE_HOST From 9a35c5229b2d11acae95428e209404d2611de427 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:36:06 +0200 Subject: [PATCH 81/97] [uart] Reuse the ISR pin argument on an ESP8266 software serial reload (#20541) --- esphome/components/uart/uart_component_esp8266.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index b81c0af85f1..0819a1bfb00 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -257,16 +257,19 @@ void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_p this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); gpio_tx_pin_ = tx_pin; gpio_tx_pin_->setup(); - tx_pin_ = gpio_tx_pin_->to_isr(); tx_pin_.digital_write(true); } if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); - rx_pin_ = gpio_rx_pin_->to_isr(); uint8_t *buffer = this->rx_.buffer(); if (this->rx_.buffer_size() != rx_buffer_size) { delete[] buffer; // NOLINT From 21804a75debf9b96d45890011e4a3f5b708ba217 Mon Sep 17 00:00:00 2001 From: Mathieu Rene Date: Sun, 11 Oct 2026 18:41:14 -0400 Subject: [PATCH 82/97] [openthread] add mesh_local_eid openthread_info sensor (#13924) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../openthread_info_text_sensor.cpp | 1 + .../openthread_info_text_sensor.h | 18 ++++++++++++++++++ .../components/openthread_info/text_sensor.py | 8 ++++++++ .../openthread_info/test.esp32-c6-idf.yaml | 2 ++ 4 files changed, 29 insertions(+) diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index 44eb5468502..68ee263e7c5 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -16,6 +16,7 @@ void Eui64OpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Eui64", this); } void NetworkNameOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Name", this); } void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Key", this); } void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "PAN ID", this); } +void MeshLocalEidOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Mesh Local EID", this); } void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Extended PAN ID", this); } } // namespace esphome::openthread_info diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index ef7c5cc8e9f..1f97c577805 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -134,6 +134,24 @@ class ChannelOpenThreadInfo final : public OpenThreadInstancePollingComponent, p uint8_t last_channel_; }; +class MeshLocalEidOpenThreadInfo final : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance(otInstance *instance) override { + const otIp6Address *addr = otThreadGetMeshLocalEid(instance); + if (otIp6IsAddressEqual(addr, &this->last_addr_)) { + return; + } + this->last_addr_ = *addr; + char buf[OT_IP6_ADDRESS_STRING_SIZE]; + otIp6AddressToString(addr, buf, sizeof(buf)); + this->publish_state(buf); + } + void dump_config() override; + + protected: + otIp6Address last_addr_{}; +}; + class DatasetOpenThreadInfo : public OpenThreadInstancePollingComponent { public: void update_instance(otInstance *instance) override { diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index 4d3e8cd6c37..c7e8a602987 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -14,6 +14,7 @@ from esphome.types import ConfigType CONF_RLOC16 = "rloc16" CONF_EUI64 = "eui64" CONF_EXT_ADDR = "ext_addr" +CONF_MESH_LOCAL_EID = "mesh_local_eid" DEPENDENCIES = ["openthread"] @@ -46,6 +47,9 @@ NetworkKeyOpenThreadInfo = openthread_info_ns.class_( PanIdOpenThreadInfo = openthread_info_ns.class_( "PanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) +MeshLocalEidOpenThreadInfo = openthread_info_ns.class_( + "MeshLocalEidOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) ExtPanIdOpenThreadInfo = openthread_info_ns.class_( "ExtPanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) @@ -80,6 +84,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_PAN_ID): text_sensor.text_sensor_schema( # noqa: F821 PanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_MESH_LOCAL_EID): text_sensor.text_sensor_schema( + MeshLocalEidOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), cv.Optional(CONF_EXT_PAN_ID): text_sensor.text_sensor_schema( ExtPanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), @@ -103,4 +110,5 @@ async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_NETWORK_NAME) await setup_conf(config, CONF_NETWORK_KEY) await setup_conf(config, CONF_PAN_ID) + await setup_conf(config, CONF_MESH_LOCAL_EID) await setup_conf(config, CONF_EXT_PAN_ID) diff --git a/tests/components/openthread_info/test.esp32-c6-idf.yaml b/tests/components/openthread_info/test.esp32-c6-idf.yaml index 8c55546ce60..a1a91e9c675 100644 --- a/tests/components/openthread_info/test.esp32-c6-idf.yaml +++ b/tests/components/openthread_info/test.esp32-c6-idf.yaml @@ -26,6 +26,8 @@ text_sensor: name: "Network Key" pan_id: name: "PAN ID" + mesh_local_eid: + name: "Mesh Local EID" ext_pan_id: name: "Extended PAN ID" From 427e0c90641f2f919c27d372c31fbcb4403619ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 13:50:50 -1000 Subject: [PATCH 83/97] [const] Move the colour channel config keys to the shared constants (#20544) --- esphome/components/const/__init__.py | 4 ++++ esphome/components/tcs34725/sensor.py | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 51a5e012102..c44a323f65d 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -14,9 +14,11 @@ CONF_ALLOWED_IPS = "allowed_ips" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" +CONF_BLUE_CHANNEL = "blue_channel" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" +CONF_CLEAR_CHANNEL = "clear_channel" CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" @@ -29,6 +31,7 @@ CONF_DESCRIPTION = "description" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" +CONF_GREEN_CHANNEL = "green_channel" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" CONF_HOLD_STATE = "hold_state" @@ -50,6 +53,7 @@ CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" CONF_RECONNECT_INTERVAL = "reconnect_interval" +CONF_RED_CHANNEL = "red_channel" CONF_REQUEST_HEADERS = "request_headers" CONF_ROLE = "role" CONF_ROWS = "rows" diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index 898aa945438..e19d76a64ae 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, +) import esphome.config_validation as cv from esphome.const import ( CONF_COLOR_TEMPERATURE, @@ -20,11 +26,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_RED_CHANNEL = "red_channel" -CONF_GREEN_CHANNEL = "green_channel" -CONF_BLUE_CHANNEL = "blue_channel" -CONF_CLEAR_CHANNEL = "clear_channel" - tcs34725_ns = cg.esphome_ns.namespace("tcs34725") TCS34725Component = tcs34725_ns.class_( "TCS34725Component", cg.PollingComponent, i2c.I2CDevice From ce19978cfd6665b05a2eeb11b032696f1279ecc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:17:07 -1000 Subject: [PATCH 84/97] [ci] Tell too-big PR authors that review waits until the PR is under the cap (#20543) --- .github/scripts/auto-label-pr/reviews.js | 36 +++++++++++++----- .../auto-label-pr/tests/reviews.test.js | 38 +++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/auto-label-pr/tests/reviews.test.js diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index e9e848da6f2..8b1db61f463 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; message += - `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + - `this PR is on the large side `; + `Hey @${prAuthor}, thanks for the contribution. This PR is on the ` + + `large side `; if (tooManyLabels && tooManyChanges) { message += @@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, } message += `, which makes it harder for maintainers to review.\n\n`; + let condition; + if (tooManyLabels && tooManyChanges) { + condition = + `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` + + `at most ${MAX_LABELS} components/areas`; + } else if (tooManyLabels) { + condition = `touches at most ${MAX_LABELS} components/areas`; + } else { + condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`; + } message += - `Smaller, focused PRs tend to be reviewed much faster since they ` + - `fit into the short gaps between other maintainer work; large ones ` + - `often have to wait for a rare long uninterrupted block of time. ` + - `If you can break this up into smaller pieces that can be reviewed ` + - `independently, it will almost certainly land faster overall.\n\n`; + `**This PR will not be reviewed until it ${condition}.** We know ` + + `that is not what you want to hear after this much work, and we want ` + + `to be upfront so you do not wait for a review that is not coming: ` + + `please split the change. The label is removed automatically once ` + + `the PR ${condition}.\n\n`; message += - `Before putting more time in, it's also worth popping into ` + - `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + - `you scope things and flag anything already in flight.\n\n`; + `There is no exception you can request. Rarely, a maintainer agrees in ` + + `\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` + + `genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` + + `but that is rare enough that you should plan on splitting rather than ` + + `wait for it. Smaller, focused PRs are picked up much faster because ` + + `they fit into the short gaps between other maintainer work. For a new ` + + `component, the first PR brings the hub and a single platform (for ` + + `example \`sensor\`); each further platform is its own PR on top of it.\n\n`; message += `For more details (including how to split the work up), see: ` + `https://developers.esphome.io/contributing/submitting-your-work/` + @@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess) } module.exports = { + generateReviewMessages, handleReviews, handleMaintainerAccessComment }; diff --git a/.github/scripts/auto-label-pr/tests/reviews.test.js b/.github/scripts/auto-label-pr/tests/reviews.test.js new file mode 100644 index 00000000000..2390d2a0735 --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/reviews.test.js @@ -0,0 +1,38 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { generateReviewMessages } = require('../reviews'); + +const MAX_LABELS = 15; +const TOO_BIG_THRESHOLD = 1000; + +function tooBigMessage(labelCount, additions) { + const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }]; + const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD); + assert.equal(messages.length, 1); + return messages[0]; +} + +describe('too-big review message', () => { + it('names the line cap when the PR is too long', () => { + const message = tooBigMessage(3, 1500); + assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./); + assert.match(message, /removed automatically once the PR is at most 1000 lines/); + assert.doesNotMatch(message, /components\/areas\./); + }); + + it('names the area cap when the PR touches too many areas but is short', () => { + const message = tooBigMessage(20, 200); + assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./); + assert.doesNotMatch(message, /until it is at most 1000 lines/); + }); + + it('names both caps when both are exceeded', () => { + const message = tooBigMessage(20, 1500); + assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./); + }); + + it('tells new-component authors to bring one platform first', () => { + assert.match(tooBigMessage(3, 1500), /hub and a single platform/); + assert.match(tooBigMessage(3, 1500), /mega-pr/); + }); +}); From 826a7a252a8eecf22f14f54b05703c197262caf8 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:33:06 -0400 Subject: [PATCH 85/97] [tmp102] Add comparator alert binary sensor (#20540) --- esphome/components/tmp102/__init__.py | 19 +++++ esphome/components/tmp102/binary_sensor.py | 28 +++++++ esphome/components/tmp102/tmp102.cpp | 52 +++++++++--- esphome/components/tmp102/tmp102.h | 16 ++++ esphome/core/defines.h | 1 + .../component_tests/tmp102/config/alert.yaml | 19 +++++ .../tmp102/config/duplicate_alert.yaml | 23 ++++++ tests/component_tests/tmp102/test_config.py | 26 ++++++ tests/components/tmp102/__init__.py | 6 +- tests/components/tmp102/common.yaml | 5 ++ tests/components/tmp102/test_tmp102.cpp | 81 +++++++++++++++++++ 11 files changed, 265 insertions(+), 11 deletions(-) create mode 100644 esphome/components/tmp102/binary_sensor.py create mode 100644 tests/component_tests/tmp102/config/alert.yaml create mode 100644 tests/component_tests/tmp102/config/duplicate_alert.yaml diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 6fd5f481e72..077dd03ccc4 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -4,8 +4,12 @@ import esphome.codegen as cg from esphome.components import i2c, sensor from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv +from esphome.const import CONF_PLATFORM +import esphome.final_validate as fv from esphome.types import ConfigType +CONF_TMP102_ID = "tmp102_id" + DOMAIN = "tmp102" tmp102_ns = cg.esphome_ns.namespace("tmp102") @@ -83,3 +87,18 @@ def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: [CONF_TEMPERATURE_LOW], ) return config + + +def validate_unique_child(config: ConfigType, platform: str) -> ConfigType: + entries = fv.full_config.get().get(platform, []) + matches = [ + entry + for entry in entries + if entry.get(CONF_PLATFORM) == DOMAIN + and entry.get(CONF_TMP102_ID) == config[CONF_TMP102_ID] + ] + if len(matches) > 1: + raise cv.Invalid( + f"Only one TMP102 {platform} may reference each temperature sensor" + ) + return config diff --git a/esphome/components/tmp102/binary_sensor.py b/esphome/components/tmp102/binary_sensor.py new file mode 100644 index 00000000000..eb23206b981 --- /dev/null +++ b/esphome/components/tmp102/binary_sensor.py @@ -0,0 +1,28 @@ +"""TMP102 comparator alert entity.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "binary_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_BINARY_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(parent.set_alert_binary_sensor(var)) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index a1066b97619..09b29dd7ef4 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -10,8 +10,12 @@ static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; -#ifdef USE_TMP102_CONFIGURE +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +#endif +#ifdef USE_TMP102_CONFIGURE static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; @@ -20,7 +24,6 @@ static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; -static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; @@ -44,6 +47,9 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); +#endif #ifdef USE_TMP102_CONFIGURE if (!this->configure_) return; @@ -83,13 +89,13 @@ void TMP102Component::update() { } #endif if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } raw_temperature = i2c::i2ctohs(raw_temperature); @@ -98,6 +104,10 @@ void TMP102Component::update() { ESP_LOGD(TAG, "Got Temperature=%.1f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); }); } @@ -108,7 +118,7 @@ void TMP102Component::update_configured_() { return; if (this->config_ & TMP102_SHUTDOWN_BIT) { if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); @@ -134,7 +144,7 @@ bool TMP102Component::write_configuration_() { bool TMP102Component::check_configuration_() { uint16_t config; if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); @@ -144,7 +154,7 @@ bool TMP102Component::check_configuration_() { uint16_t high, low; if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return false; } matches = high == this->high_limit_ && low == this->low_limit_; @@ -153,7 +163,7 @@ bool TMP102Component::check_configuration_() { return true; ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); if (!this->write_configuration_()) { ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); } @@ -163,14 +173,38 @@ bool TMP102Component::check_configuration_() { void TMP102Component::read_temperature_() { uint16_t raw; if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } const float temperature = decode_temperature(raw, raw & 0x0001); ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); } #endif +#ifdef USE_TMP102_BINARY_SENSOR +void TMP102Component::invalidate_alert_() { + if (this->alert_binary_sensor_ != nullptr) + this->alert_binary_sensor_->invalidate_state(); +} + +bool TMP102Component::read_alert_state_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + ESP_LOGW(TAG, "Failed to read alert state"); + return false; + } + // AL equals POL while the comparator status is active. + const bool alert = bool(config & TMP102_ALERT_BIT) == bool(config & TMP102_ALERT_POLARITY_BIT); + this->alert_binary_sensor_->publish_state(alert); + return true; +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index b14b6c4cc1f..caf75e19be7 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -2,6 +2,9 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" +#ifdef USE_TMP102_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" @@ -11,6 +14,9 @@ namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_BINARY_SENSOR + SUB_BINARY_SENSOR(alert) +#endif #ifdef USE_TMP102_CONFIGURE void setup() override; void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { @@ -24,6 +30,16 @@ class TMP102Component final : public PollingComponent, public i2c::I2CDevice, pu void update() override; protected: + void set_warning_and_invalidate_alert_() { + this->status_set_warning(); +#ifdef USE_TMP102_BINARY_SENSOR + this->invalidate_alert_(); +#endif + } +#ifdef USE_TMP102_BINARY_SENSOR + bool read_alert_state_(); + void invalidate_alert_(); +#endif #ifdef USE_TMP102_CONFIGURE bool write_register_(uint8_t reg, uint16_t value); bool write_configuration_(); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d219296ca57..28abdb2218c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -244,6 +244,7 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_BINARY_SENSOR #define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER diff --git a/tests/component_tests/tmp102/config/alert.yaml b/tests/component_tests/tmp102/config/alert.yaml new file mode 100644 index 00000000000..3e1e42197d3 --- /dev/null +++ b/tests/component_tests/tmp102/config/alert.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + name: Alert diff --git a/tests/component_tests/tmp102/config/duplicate_alert.yaml b/tests/component_tests/tmp102/config/duplicate_alert.yaml new file mode 100644 index 00000000000..c56d0d96fa4 --- /dev/null +++ b/tests/component_tests/tmp102/config/duplicate_alert.yaml @@ -0,0 +1,23 @@ +--- +esphome: + name: tmp102-test + +esp32: + board: esp32dev + +i2c: + sda: GPIO21 + scl: GPIO22 + +sensor: + - platform: tmp102 + id: chip + name: Temperature + +binary_sensor: + - platform: tmp102 + tmp102_id: chip + name: Alert One + - platform: tmp102 + tmp102_id: chip + name: Alert Two diff --git a/tests/component_tests/tmp102/test_config.py b/tests/component_tests/tmp102/test_config.py index 020303d455d..49f2629baf8 100644 --- a/tests/component_tests/tmp102/test_config.py +++ b/tests/component_tests/tmp102/test_config.py @@ -7,6 +7,7 @@ import pytest from esphome.components import tmp102 as component from esphome.components.tmp102.sensor import CONFIG_SCHEMA +from esphome.config import read_config import esphome.config_validation as cv from esphome.core import CORE @@ -32,6 +33,31 @@ def test_advanced_options_enable_configuration_once( assert "USE_TMP102_CONFIGURE" in defines +def test_alert_entity_is_read_only( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("alert.yaml")) + assert "set_alert_binary_sensor" in source + assert "set_configuration" not in source + defines = " ".join(define.name for define in CORE.defines) + assert "USE_TMP102_BINARY_SENSOR" in defines + assert "USE_TMP102_CONFIGURE" not in defines + + +def test_duplicate_alert_entities_are_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], +) -> None: + CORE.config_path = component_config_path("duplicate_alert.yaml") + assert read_config({}) is None + captured = capsys.readouterr() + assert ( + "Only one TMP102 binary_sensor may reference each temperature sensor" + in captured.out + ) + + @pytest.mark.parametrize( ("extended", "high", "low"), [ diff --git a/tests/components/tmp102/__init__.py b/tests/components/tmp102/__init__.py index 9702134e8c6..f86177c0f92 100644 --- a/tests/components/tmp102/__init__.py +++ b/tests/components/tmp102/__init__.py @@ -6,8 +6,10 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: - CORE.testing_ensure_platform_registered("sensor") + for platform in ("sensor", "binary_sensor"): + CORE.testing_ensure_platform_registered(platform) cg.add_define("USE_TMP102_CONFIGURE") + cg.add_define("USE_TMP102_BINARY_SENSOR") manifest.to_code = to_code_testing - manifest.dependencies = manifest.dependencies + ["i2c", "sensor"] + manifest.dependencies = manifest.dependencies + ["i2c", "sensor", "binary_sensor"] diff --git a/tests/components/tmp102/common.yaml b/tests/components/tmp102/common.yaml index c764eae42b2..1e5f1f456ce 100644 --- a/tests/components/tmp102/common.yaml +++ b/tests/components/tmp102/common.yaml @@ -15,3 +15,8 @@ sensor: fault_queue: 6 temperature_high: 140°C temperature_low: 120°C + +binary_sensor: + - platform: tmp102 + tmp102_id: tmp102_extended_temperature + name: TMP102 Alert diff --git a/tests/components/tmp102/test_tmp102.cpp b/tests/components/tmp102/test_tmp102.cpp index b148b07716c..cd06d8a83d7 100644 --- a/tests/components/tmp102/test_tmp102.cpp +++ b/tests/components/tmp102/test_tmp102.cpp @@ -47,6 +47,7 @@ class TMP102Test : public ::testing::Test { protected: RegisterBus bus_; TMP102Component chip_; + binary_sensor::BinarySensor alert_; unsigned publications_{0}; void SetUp() override { @@ -112,6 +113,83 @@ TEST_F(TMP102Test, WritableConfigurationFields) { EXPECT_EQ(this->bus_.registers[2], 0x2580); } +TEST_F(TMP102Test, AlertEntityPreservesHardwareAndUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + EXPECT_EQ(this->bus_.transactions, 0); + for (uint16_t config : {0x60A0, 0x6080, 0x64A0, 0x6480}) { + this->bus_.registers[1] = config; + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x20) == bool(config & 0x400)); + EXPECT_EQ(this->bus_.registers[1], config); + } + EXPECT_EQ(this->bus_.writes, 0); +} + +TEST_F(TMP102Test, FailedTemperatureOrAlertReadInvalidatesAlert) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->chip_.setup(); + this->chip_.update(); + run_timers(55); + ASSERT_TRUE(this->alert_.has_state()); + for (int reg : {0, 1}) { + this->bus_.fail_read = reg; + this->chip_.update(); + run_timers(55); + EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); + this->bus_.fail_read = -1; + this->chip_.update(); + run_timers(55); + EXPECT_FALSE(this->chip_.status_has_warning()); + EXPECT_TRUE(this->alert_.has_state()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertUsesObservedPolarity) { + this->chip_.set_alert_binary_sensor(&this->alert_); + for (uint16_t config : {0x0080, 0x00A0, 0x0480, 0x04A0}) { + this->configure(config & ~0x0020); + this->chip_.setup(); + this->bus_.registers[1] = config; + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + EXPECT_EQ(this->alert_.state, bool(config & 0x0020) == bool(config & 0x0400)); + EXPECT_FALSE(this->chip_.status_has_warning()); + } +} + +TEST_F(TMP102Test, ConfiguredAlertInvalidatesDuringRecoveryAndRepublishes) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0090, 0x2D00, 0x2A80); + this->chip_.setup(); + this->chip_.update(); + ASSERT_TRUE(this->alert_.has_state()); + + this->bus_.registers = {0x1900, 0x60A0, 0x4B00, 0x5000}; + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + EXPECT_TRUE(this->chip_.status_has_warning()); + + this->chip_.update(); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + +TEST_F(TMP102Test, OneShotPublishesAlertAfterDelayedRead) { + this->chip_.set_alert_binary_sensor(&this->alert_); + this->configure(0x0180); + this->chip_.setup(); + this->chip_.update(); + EXPECT_FALSE(this->alert_.has_state()); + run_timers(45); + EXPECT_TRUE(this->alert_.has_state()); + EXPECT_TRUE(this->alert_.state); + EXPECT_FALSE(this->chip_.status_has_warning()); +} + TEST_F(TMP102Test, RestoresRegistersAfterChipOnlyResetBeforePublishing) { this->configure(0x0090, 0x2D00, 0x2A80); this->chip_.setup(); @@ -170,13 +248,16 @@ TEST_F(TMP102Test, ReadOnlyConfigurationBitsDoNotTriggerRecovery) { } TEST_F(TMP102Test, OneShotTriggerFailureSetsWarning) { + this->chip_.set_alert_binary_sensor(&this->alert_); this->configure(0x0180); this->chip_.setup(); + this->alert_.publish_state(false); this->bus_.fail_write = 1; this->chip_.update(); run_timers(45); EXPECT_EQ(this->publications_, 0); EXPECT_TRUE(this->chip_.status_has_warning()); + EXPECT_FALSE(this->alert_.has_state()); } TEST_F(TMP102Test, OneShotRestartsPendingReadAndReselectsTemperature) { From 22c17fe7e1552f3fe17e7e89d582a9685294d908 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:36:27 +0200 Subject: [PATCH 86/97] [remote_base] Add optional suffix bits to KeeLoq after the HCS301 word (#19891) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- esphome/components/remote_base/__init__.py | 39 +++- .../remote_base/keeloq_protocol.cpp | 63 ++++++- .../components/remote_base/keeloq_protocol.h | 9 +- .../config/keeloq_suffix.yaml | 33 ++++ .../remote_transmitter/test_keeloq_action.py | 18 ++ tests/components/remote_base/__init__.py | 2 +- tests/components/remote_base/keeloq_test.cpp | 177 ++++++++++++++++++ .../remote_receiver/common-actions.yaml | 4 +- .../remote_transmitter/common-buttons.yaml | 16 ++ .../components/remote_base/__init__.py | 1 + .../components/remote_base/test_keeloq.py | 45 +++++ 11 files changed, 393 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml create mode 100644 tests/component_tests/remote_transmitter/test_keeloq_action.py create mode 100644 tests/components/remote_base/keeloq_test.cpp create mode 100644 tests/unit_tests/components/remote_base/__init__.py create mode 100644 tests/unit_tests/components/remote_base/test_keeloq.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index a880169fb20..1911218b1f7 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -57,6 +57,8 @@ DOMAIN = "remote_base" CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" +CONF_SUFFIX = "suffix" +CONF_SUFFIX_BITS = "suffix_bits" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -255,8 +257,12 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) -def register_action(name, type_, schema): +def register_action( + name, type_, schema, *, validate: Callable[[ConfigType], ConfigType] | None = None +): validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) + if validate is not None: + validator = cv.All(validator, validate) registerer = automation.register_action( f"remote_transmitter.transmit_{name}", type_, @@ -939,6 +945,27 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: + # A lambda is only known on the device. A plain value has to fit the width. + suffix = config[CONF_SUFFIX] + bits = config[CONF_SUFFIX_BITS] + if isinstance(suffix, int) and isinstance(bits, int) and suffix >> bits: + raise cv.Invalid( + f"suffix 0x{suffix:X} does not fit in {bits} suffix_bits", [CONF_SUFFIX] + ) + return config + + +KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( + { + cv.Optional(CONF_SUFFIX, default=0): cv.All( + cv.hex_int, cv.Range(min=0, max=0xFFFF) + ), + cv.Optional(CONF_SUFFIX_BITS, default=0): cv.int_range(min=0, max=16), + } +) + + @register_binary_sensor("keeloq", KeeloqBinarySensor, KEELOQ_SCHEMA) def Keeloq_binary_sensor(var, config): cg.add( @@ -962,7 +989,9 @@ def keeloq_dumper(var, config): pass -@register_action("keeloq", KeeloqAction, KEELOQ_SCHEMA) +@register_action( + "keeloq", KeeloqAction, KEELOQ_ACTION_SCHEMA, validate=_keeloq_suffix_fits +) async def keeloq_action(var, config, args): template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint32) cg.add(var.set_address(template_)) @@ -972,6 +1001,12 @@ async def keeloq_action(var, config, args): cg.add(var.set_command(template_)) template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) cg.add(var.set_vlow(template_)) + # Without suffix bits nothing of the suffix is sent, so both stay at their C++ default. + if (suffix_bits := config[CONF_SUFFIX_BITS]) != 0: + template_ = await cg.templatable(config[CONF_SUFFIX], args, cg.uint16) + cg.add(var.set_suffix(template_)) + template_ = await cg.templatable(suffix_bits, args, cg.uint8) + cg.add(var.set_suffix_bits(template_)) # NEC diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index caa950218ec..fff8eb897f0 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -19,24 +19,29 @@ static constexpr uint8_t NBITS_SYNC_CNT = 16; static constexpr uint8_t NBITS_FIXED_DATA = NBITS_REPEAT + NBITS_VLOW + NBITS_BUTTONS + NBITS_SERIAL; static constexpr uint8_t NBITS_ENCRYPTED_DATA = NBITS_BUTTONS + NBITS_DISC + NBITS_SYNC_CNT; static constexpr uint8_t NBITS_DATA = NBITS_FIXED_DATA + NBITS_ENCRYPTED_DATA; +static constexpr uint8_t NBITS_SUFFIX_MAX = 16; +// A space this long is not part of the PWM: data spaces are at most 2 bit times, the guard is 39. +static constexpr uint32_t GAP_TIME_US = 4 * BIT_TIME_US; /* KeeLoq Protocol Coded using information from datasheet for Microchip HCS301 KeeLow Code Hopping Encoder -Encoder - Hopping code is generated at random. +Encoder - Hopping code is not generated. Send the encrypted field from the caller. +Optional suffix bits may follow the 66-bit HCS301 word (same PWM coding). Decoder - Hopping code is ignored and not checked when received. Serial number of -transmitter and nutton command is decoded. +transmitter and button command is decoded. Extra bits after the word are returned +as suffix / suffix_bits when present. */ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { uint32_t out_data = 0x0; - ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32, data.address, data.command, - data.encrypted); + ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32 " suffix_bits=%u", data.address, + data.command, data.encrypted, data.suffix_bits); ESP_LOGV(TAG, "Send Keeloq: data bits (%d + %d)", NBITS_ENCRYPTED_DATA, NBITS_FIXED_DATA); // Preamble = '01' x 12 @@ -92,6 +97,17 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->mark(1 * BIT_TIME_US); dst->space(2 * BIT_TIME_US); + const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; + for (uint8_t i = 0; i < extra; i++) { + if (data.suffix & (1 << i)) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } + } + // Guard time at end of packet dst->space(39 * BIT_TIME_US); } @@ -103,10 +119,11 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { .command = 0, .repeat = false, .vlow = false, - + .suffix = 0, + .suffix_bits = 0, }; - if (src.size() != (NBITS_PREAMBLE + NBITS_DATA) * 2) { + if (src.size() < (NBITS_PREAMBLE + NBITS_DATA) * 2) { return {}; } @@ -182,11 +199,43 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { return {}; } + if (out.repeat) { + src.expect_space(2 * BIT_TIME_US); + } else { + src.expect_space(BIT_TIME_US); + } + + while (out.suffix_bits < NBITS_SUFFIX_MAX) { + bool one = false; + if (src.expect_mark(2 * BIT_TIME_US)) { + one = false; + } else if (src.expect_mark(BIT_TIME_US)) { + one = true; + } else { + break; + } + // The last bit's space is merged into the guard gap, or the capture ends at idle. + if (!src.expect_space(one ? 2 * BIT_TIME_US : BIT_TIME_US) && src.is_valid() && + !src.peek_space_at_least(GAP_TIME_US)) { + break; + } + if (one) { + out.suffix |= 1 << out.suffix_bits; + } + out.suffix_bits++; + } + // The suffix ends at a gap or at the end of the capture. PWM that goes on is not a suffix. + if (src.is_valid() && !src.peek_space_at_least(GAP_TIME_US)) { + out.suffix = 0; + out.suffix_bits = 0; + } + return out; } void KeeloqProtocol::dump(const KeeloqData &data) { - ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x", data.address, data.command); + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", + data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 335fbd164b1..093f2904aa2 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -11,10 +11,11 @@ struct KeeloqData { uint8_t command; // Button Status S2-S1-S0-S3 bool repeat; // Repeated command bit bool vlow; // Battery status bit + uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word + uint8_t suffix_bits{0}; bool operator==(const KeeloqData &rhs) const { - // Treat 0x10 as a special, wildcard button press - // This allows us to match on just the address if wanted. + // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { return false; } @@ -37,6 +38,8 @@ template class KeeloqAction : public RemoteTransmitterActionBase TEMPLATABLE_VALUE(uint32_t, encrypted) TEMPLATABLE_VALUE(uint8_t, command) TEMPLATABLE_VALUE(bool, vlow) + TEMPLATABLE_VALUE(uint16_t, suffix) + TEMPLATABLE_VALUE(uint8_t, suffix_bits) void encode(RemoteTransmitData *dst, Ts... x) override { KeeloqData data{}; @@ -44,6 +47,8 @@ template class KeeloqAction : public RemoteTransmitterActionBase data.encrypted = this->encrypted_.value(x...); data.command = this->command_.value(x...); data.vlow = this->vlow_.value(x...); + data.suffix = this->suffix_.value(x...); + data.suffix_bits = this->suffix_bits_.value(x...); KeeloqProtocol().encode(dst, data); } }; diff --git a/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml new file mode 100644 index 00000000000..f14bc75f305 --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/keeloq_suffix.yaml @@ -0,0 +1,33 @@ +esphome: + name: test + +esp8266: + board: esp01_1m + +remote_transmitter: + pin: GPIO4 + carrier_duty_percent: 100% + +button: + - platform: template + name: Plain word + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + code: 0xd19ef0a9 + - platform: template + name: Constant suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: 0x2A + suffix_bits: 6 + - platform: template + name: Lambda suffix + on_press: + - remote_transmitter.transmit_keeloq: + address: 0x116ea01 + code: 0xd19ef0a9 + suffix: !lambda return 0x2A; + suffix_bits: !lambda return 6; diff --git a/tests/component_tests/remote_transmitter/test_keeloq_action.py b/tests/component_tests/remote_transmitter/test_keeloq_action.py new file mode 100644 index 00000000000..aafab1f2c8e --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_keeloq_action.py @@ -0,0 +1,18 @@ +"""transmit_keeloq sets the suffix only when suffix bits are sent.""" + +from collections.abc import Callable +from pathlib import Path + + +def test_keeloq_suffix_is_set_only_with_suffix_bits( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("keeloq_suffix.yaml")) + + # The plain word gets the same setters as before the suffix existed. + assert "remote_base_keeloqaction_id->set_suffix" not in main_cpp + # A constant and a lambda width both set the suffix. + for action in ("remote_base_keeloqaction_id_2", "remote_base_keeloqaction_id_3"): + assert f"{action}->set_suffix([]() -> uint16_t" in main_cpp + assert f"{action}->set_suffix_bits([]() -> uint8_t" in main_cpp diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index c52eb41ca44..90734b72f28 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -7,7 +7,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: async def to_code_testing(config: ConfigType) -> None: # Protocol sources are compiled only behind their define; keep the ones under test. - for protocol in ("hob2hood", "haier", "mirage", "aeha"): + for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq"): request_protocol(protocol) manifest.to_code = to_code_testing diff --git a/tests/components/remote_base/keeloq_test.cpp b/tests/components/remote_base/keeloq_test.cpp new file mode 100644 index 00000000000..e96d8b9d01d --- /dev/null +++ b/tests/components/remote_base/keeloq_test.cpp @@ -0,0 +1,177 @@ +#include +#include "esphome/components/remote_base/keeloq_protocol.h" + +namespace esphome::remote_base::testing { + +namespace { + +constexpr uint32_t BIT_US = 380; + +KeeloqData frame(uint16_t suffix, uint8_t suffix_bits) { + KeeloqData data{}; + data.encrypted = 0xd19ef0a9; + data.address = 0x116ea01; + data.command = 0x08; + data.suffix = suffix; + data.suffix_bits = suffix_bits; + return data; +} + +RawTimings encode(const KeeloqData &data) { + RemoteTransmitData tx; + KeeloqProtocol().encode(&tx, data); + return tx.get_data(); +} + +// A receiver starts on the first mark and merges the guard into the previous space. +RawTimings as_received(RawTimings timings, bool drop_tail = false) { + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + RawTimings merged; + for (int32_t item : timings) { + if (!merged.empty() && (merged.back() < 0) == (item < 0)) { + merged.back() += item; + } else { + merged.push_back(item); + } + } + if (drop_tail && !merged.empty() && merged.back() < 0) { + merged.pop_back(); + } + return merged; +} + +optional decode(const RawTimings &timings) { + return KeeloqProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE)); +} + +void expect_round_trip(uint16_t suffix, uint8_t suffix_bits, bool drop_tail = false) { + auto decoded = decode(as_received(encode(frame(suffix, suffix_bits)), drop_tail)); + ASSERT_TRUE(decoded.has_value()) << "suffix 0x" << std::hex << suffix << "/" << std::dec << int(suffix_bits); + if (!decoded.has_value()) { + return; + } + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->command, 0x08); + EXPECT_EQ(decoded->encrypted, 0xd19ef0a9u); + EXPECT_TRUE(decoded->repeat); + EXPECT_FALSE(decoded->vlow); + EXPECT_EQ(decoded->suffix, suffix); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); +} + +// 0xA5 sends 1, 0, 1 first. Replace the space of one of those bits while the other bits still follow. +optional decode_with_suffix_space(uint8_t bit, uint32_t space_us) { + RawTimings timings = as_received(encode(frame(0xA5, 8))); + timings[timings.size() - 2 * 8 + 2 * bit + 1] = -static_cast(space_us); + return decode(timings); +} + +} // namespace + +TEST(KeeloqProtocolTest, PlainWordHasNoSuffix) { expect_round_trip(0, 0); } + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheGuardSwallowsTheLastSpace) { + expect_round_trip(0x00, 8); + expect_round_trip(0x80, 8); + expect_round_trip(0xA5, 8); + expect_round_trip(0x0000, 16); + expect_round_trip(0x8000, 16); + expect_round_trip(0, 1); + expect_round_trip(1, 1); +} + +TEST(KeeloqProtocolTest, SuffixRoundTripsWhenTheCaptureEndsAtIdle) { + expect_round_trip(0x00, 8, true); + expect_round_trip(0x80, 8, true); + expect_round_trip(0x8000, 16, true); + expect_round_trip(1, 1, true); +} + +TEST(KeeloqProtocolTest, ExtraBitsThatEndAtIdleStayASuffix) { + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 4); + } +} + +TEST(KeeloqProtocolTest, FollowingPwmIsNotASuffix) { + // Drop only the guard item. The repeat bit keeps its own space, then more PWM follows. + RawTimings timings = encode(frame(0, 0)); + if (!timings.empty() && timings.front() < 0) { + timings.erase(timings.begin()); + } + ASSERT_FALSE(timings.empty()); + ASSERT_LT(timings.back(), 0); + timings.pop_back(); + for (uint8_t i = 0; i < 4; i++) { + timings.push_back(static_cast(2 * BIT_US)); + timings.push_back(-static_cast(BIT_US)); + } + timings.push_back(static_cast(3 * BIT_US)); + + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0); + EXPECT_EQ(decoded->suffix_bits, 0); + } +} + +TEST(KeeloqProtocolTest, ASpaceThatIsNeitherDataNorAGapDropsTheSuffix) { + // A 1 bit with the space of a 0 bit, and a 0 bit with the space of a 1 bit. + for (const auto &[bit, space_us] : {std::pair{2, BIT_US}, {1, 2 * BIT_US}}) { + auto decoded = decode_with_suffix_space(bit, space_us); + ASSERT_TRUE(decoded.has_value()) << "bit " << int(bit); + if (decoded.has_value()) { + EXPECT_EQ(decoded->address, 0x116ea01u); + EXPECT_EQ(decoded->suffix, 0) << "bit " << int(bit); + EXPECT_EQ(decoded->suffix_bits, 0) << "bit " << int(bit); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixFollowsARepeatBitOfZero) { + // The encoder always sends the repeat bit as 1. Turn it into a 0 bit, with and without a suffix. + for (uint8_t suffix_bits : {0, 8}) { + RawTimings timings = as_received(encode(frame(suffix_bits > 0 ? 0xA5 : 0, suffix_bits))); + const size_t repeat = timings.size() - 2 * suffix_bits - 2; + timings[repeat] = static_cast(2 * BIT_US); + if (suffix_bits > 0) { + timings[repeat + 1] = -static_cast(BIT_US); + } + auto decoded = decode(timings); + ASSERT_TRUE(decoded.has_value()) << int(suffix_bits); + if (decoded.has_value()) { + EXPECT_FALSE(decoded->repeat); + EXPECT_EQ(decoded->suffix, suffix_bits > 0 ? 0xA5 : 0); + EXPECT_EQ(decoded->suffix_bits, suffix_bits); + } + } +} + +TEST(KeeloqProtocolTest, ASuffixEndsAtAGap) { + // What follows the gap is the next frame. + auto decoded = decode_with_suffix_space(2, 4 * BIT_US); + ASSERT_TRUE(decoded.has_value()); + if (decoded.has_value()) { + EXPECT_EQ(decoded->suffix, 0x5); + EXPECT_EQ(decoded->suffix_bits, 3); + } +} + +} // namespace esphome::remote_base::testing diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index 4dadec6a015..55f489505cb 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -71,8 +71,8 @@ on_jvc: on_keeloq: then: - logger.log: - format: "on_keeloq: %lu %lu %u" - args: ["long(x.encrypted)", "long(x.address)", "x.command"] + format: "on_keeloq: %lu %lu %u %u %u" + args: ["long(x.encrypted)", "long(x.address)", "x.command", "x.suffix", "x.suffix_bits"] on_haier: then: - logger.log: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index 776a03e6809..f168a825d36 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -316,3 +316,19 @@ button: on_press: - remote_transmitter.transmit_hob2hood: command: light_on + - platform: template + name: KeeLoq HCS301 + on_press: + remote_transmitter.transmit_keeloq: + address: 0x57ffe7b + command: 0x02 + code: 0xd19ef0a9 + - platform: template + name: KeeLoq with suffix + on_press: + remote_transmitter.transmit_keeloq: + address: 0x116ea01 + command: 0x08 + code: 0xd19ef0a9 + suffix: 0x00 + suffix_bits: 8 diff --git a/tests/unit_tests/components/remote_base/__init__.py b/tests/unit_tests/components/remote_base/__init__.py new file mode 100644 index 00000000000..d6233f0230e --- /dev/null +++ b/tests/unit_tests/components/remote_base/__init__.py @@ -0,0 +1 @@ +"""Unit tests for remote_base config helpers.""" diff --git a/tests/unit_tests/components/remote_base/test_keeloq.py b/tests/unit_tests/components/remote_base/test_keeloq.py new file mode 100644 index 00000000000..bd0f817dd1f --- /dev/null +++ b/tests/unit_tests/components/remote_base/test_keeloq.py @@ -0,0 +1,45 @@ +"""KeeLoq suffix is a transmit key and has to fit in its width.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.remote_base import ( + CONF_SUFFIX, + CONF_SUFFIX_BITS, + KEELOQ_SCHEMA, + _keeloq_suffix_fits, +) +import esphome.config_validation as cv +from esphome.core import Lambda + + +def test_binary_sensor_rejects_suffix() -> None: + with pytest.raises(cv.Invalid): + KEELOQ_SCHEMA( + {"address": 0x1, "code": 0x2, CONF_SUFFIX: 1, CONF_SUFFIX_BITS: 8} + ) + + +def test_plain_suffix_must_fit() -> None: + fitted = {"address": 0x1, "code": 0x2, CONF_SUFFIX: 0xA5, CONF_SUFFIX_BITS: 8} + assert _keeloq_suffix_fits(fitted)[CONF_SUFFIX] == 0xA5 + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + _keeloq_suffix_fits({**fitted, CONF_SUFFIX: 0x100}) + + +def test_lambda_suffix_is_not_checked_here() -> None: + config = {"address": 0x1, "code": 0x2, CONF_SUFFIX: "id(x)", CONF_SUFFIX_BITS: 4} + assert _keeloq_suffix_fits(config)[CONF_SUFFIX] == "id(x)" + + +def test_transmit_action_checks_the_width() -> None: + schema = ACTION_REGISTRY["remote_transmitter.transmit_keeloq"].raw_schema + base = {"address": 0x1, "code": 0x2, CONF_SUFFIX_BITS: 8} + assert schema({**base, CONF_SUFFIX: 0xA5})[CONF_SUFFIX] == 0xA5 + assert isinstance( + schema({**base, CONF_SUFFIX: Lambda("return 0x100;")})[CONF_SUFFIX], Lambda + ) + with pytest.raises(cv.Invalid, match="0x100 does not fit in 8 suffix_bits"): + schema({**base, CONF_SUFFIX: 0x100}) From e50a0d902e2c1e5d4b72a2f52dfacecc7ffb51a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:42:21 -1000 Subject: [PATCH 87/97] [core] Expose the preferences storage helpers to components (#20535) --- esphome/preferences.py | 18 +++++------- tests/unit_tests/test_preferences.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/esphome/preferences.py b/esphome/preferences.py index fce85191302..6e7e4eda33d 100644 --- a/esphome/preferences.py +++ b/esphome/preferences.py @@ -19,18 +19,15 @@ STORAGE_FLASH = "flash" STORAGE_RTC = "rtc" -def _rtc_supported() -> bool: - """Whether the active platform has an RTC-backed preferences backend. - - Mirrors the C++ ``SOC_RTC_MEM_SUPPORTED`` guard in the ESP32 backend: the ESP32-C2 - and -C61 have no RTC memory at all, so RTC storage is unavailable there. - """ +def rtc_supported() -> bool: + """Whether the target has RTC-backed preferences (not ESP32-C2/C61); call at validation time.""" if CORE.is_esp8266: return True if CORE.is_esp32: from esphome.components.esp32 import get_esp32_variant from esphome.components.esp32.const import VARIANT_ESP32C2, VARIANT_ESP32C61 + # Mirrors the C++ SOC_RTC_MEM_SUPPORTED guard in the ESP32 backend return get_esp32_variant() not in (VARIANT_ESP32C2, VARIANT_ESP32C61) return False @@ -44,9 +41,10 @@ def _default_storage() -> str: return STORAGE_RTC if CORE.is_esp8266 else STORAGE_FLASH -def _validate_storage(value): +def validate_storage(value: object) -> str: + """Validate a ``storage:`` value, ``flash`` or ``rtc`` where supported.""" value = cv.one_of(STORAGE_FLASH, STORAGE_RTC, lower=True)(value) - if value == STORAGE_RTC and not _rtc_supported(): + if value == STORAGE_RTC and not rtc_supported(): raise cv.Invalid( f"'{STORAGE_RTC}' storage is not supported on this platform; only " f"'{STORAGE_FLASH}' is available" @@ -56,7 +54,7 @@ def _validate_storage(value): def storage_schema(): """Return an Optional(CONF_STORAGE) entry for merging into a component schema.""" - return {cv.Optional(CONF_STORAGE, default=_default_storage): _validate_storage} + return {cv.Optional(CONF_STORAGE, default=_default_storage): validate_storage} def request_rtc_storage() -> None: @@ -88,7 +86,7 @@ def validate_rtc_storage(value): "be disabled" ) return value - if not _rtc_supported(): + if not rtc_supported(): raise cv.Invalid("RTC preference storage is not supported on this platform") return value diff --git a/tests/unit_tests/test_preferences.py b/tests/unit_tests/test_preferences.py index 677eeee7f26..a1ab78bfbff 100644 --- a/tests/unit_tests/test_preferences.py +++ b/tests/unit_tests/test_preferences.py @@ -147,3 +147,47 @@ def test_rtc_rejected_on_unsupported_platform() -> None: def test_flash_allowed_on_unsupported_platform() -> None: _set_platform(PLATFORM_RP2040) assert _validate({CONF_STORAGE: "flash"})[CONF_STORAGE] == preferences.STORAGE_FLASH + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32, VARIANT_ESP32C3]) +def test_rtc_supported_on_esp32_with_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is True + + +@pytest.mark.parametrize("variant", [VARIANT_ESP32C2, VARIANT_ESP32C61]) +def test_rtc_supported_false_without_rtc_memory(variant: str) -> None: + _set_esp32(variant) + assert preferences.rtc_supported() is False + + +@pytest.mark.parametrize( + ("platform", "expected"), + [(PLATFORM_ESP8266, True), (PLATFORM_RP2040, False)], +) +def test_rtc_supported_per_platform(platform: str, expected: bool) -> None: + _set_platform(platform) + assert preferences.rtc_supported() is expected + + +def test_validate_storage_accepts_flash_everywhere() -> None: + _set_platform(PLATFORM_RP2040) + assert preferences.validate_storage("flash") == preferences.STORAGE_FLASH + assert preferences.validate_storage("FLASH") == preferences.STORAGE_FLASH + + +def test_validate_storage_accepts_rtc_where_supported() -> None: + _set_esp32(VARIANT_ESP32) + assert preferences.validate_storage("rtc") == preferences.STORAGE_RTC + + +def test_validate_storage_rejects_rtc_without_rtc_memory() -> None: + _set_esp32(VARIANT_ESP32C2) + with pytest.raises(cv.Invalid, match="not supported on this platform"): + preferences.validate_storage("rtc") + + +def test_validate_storage_rejects_unknown_value() -> None: + _set_esp32(VARIANT_ESP32) + with pytest.raises(cv.Invalid): + preferences.validate_storage("nvs") From 59d31505e97f370d451b4b405652e2bceb29b391 Mon Sep 17 00:00:00 2001 From: Jamie Meredith <160557+jlmeredith@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:48:47 -0600 Subject: [PATCH 88/97] [mipi_spi] Add LCDwiki ES3C35P model (ST77922 QSPI) (#19011) Co-authored-by: Claude Opus 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/mipi_spi/models/lcdwiki.py | 86 +++++++++++++++++++ .../mipi_spi/test_get_sequence.py | 23 +++++ 2 files changed, 109 insertions(+) create mode 100644 esphome/components/mipi_spi/models/lcdwiki.py diff --git a/esphome/components/mipi_spi/models/lcdwiki.py b/esphome/components/mipi_spi/models/lcdwiki.py new file mode 100644 index 00000000000..6ef8b1fad79 --- /dev/null +++ b/esphome/components/mipi_spi/models/lcdwiki.py @@ -0,0 +1,86 @@ +# Init sequence sourced from LCDwiki's own published Arduino example for this board: +# https://github.com/ydedox/st77922 (Example_01_Simple_test/Simple_test/Simple_test.ino) +# It is the commented-out panel table in that file, with the 0x71 and 0xBE values from the +# active one, which is what the shipped firmware writes. + +from esphome.components.mipi import MODE_RGB, SLPOUT, DriverChip, delay +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y + +# fmt: off +ES3C35P = DriverChip( + "ES3C35P", + width=320, + height=480, + draw_rounding=4, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + color_order=MODE_RGB, + invert_colors=True, + bus_mode=TYPE_QUAD, + data_rate="40MHz", # datasheet caps the write clock at 62.5MHz + cs_pin=10, + reset_delay=120, + no_slpout=True, # SLPOUT is in the init sequence, the panel needs 120ms after it + requires={"psram"}, + initsequence=( + (0xF1, 0x00), + (0x60, 0x00, 0x00, 0x00), + (0x65, 0x80), + (0x79, 0x06), + (0x7B, 0x00, 0x08, 0x08), + (0x80, 0x55, 0x62, 0x2F, 0x17, 0xF0, 0x52, 0x70, 0xD2, 0x52, 0x62, 0xEA), + (0x81, 0x26, 0x52, 0x72, 0x27), + (0x84, 0x92, 0x25), + (0x87, 0x10, 0x10, 0x58, 0x00, 0x02, 0x3A), + (0x88, 0x00, 0x00, 0x2C, 0x10, 0x04, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x06), + (0x89, 0x00, 0x00, 0x00), + (0x8A, 0x13, 0x00, 0x2C, 0x00, 0x00, 0x2C, 0x10, 0x10, 0x00, 0x3E, 0x19), + (0x8B, 0x15, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x97, 0x8E), + (0x8C, 0x1D, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x50, 0x0F, 0x01, 0xC5, 0x12, 0x09), + (0x8D, 0x0C), + (0x8E, 0x33, 0x01, 0x0C, 0x13, 0x01, 0x01), + (0xB3, 0x00, 0x30), + (0x71, 0xD0), + (0x66, 0x02, 0x3F), + (0xBE, 0x26, 0x00, 0x9D), + (0x70, 0x01, 0xA6, 0x11, 0x40, 0xE0, 0x00, 0x11, 0x60, 0x11, 0x00, 0x00, 0x1A), + (0x90, 0x04, 0x04, 0x55, 0x74, 0x00, 0x40, 0x43, 0x2D, 0x2D), + (0x91, 0x04, 0x04, 0x55, 0x75, 0x00, 0x40, 0x42, 0x2D, 0x2D), + (0x92, 0x04, 0x44, 0x55, 0xC0, 0x06, 0x00, 0x07, 0x05, 0x90, 0x2D), + (0x93, 0x04, 0x43, 0x11, 0x00, 0x00, 0x00, 0x00, 0x05, 0x90, 0x2D), + (0x94, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0x95, 0x96, 0x16, 0x00, 0x00, 0xFF), + (0x96, 0x44, 0x53, 0x03, 0x12, 0x23, 0x24, 0x06, 0x05, 0x9A, 0x2D, 0x00, 0x44), + (0x97, 0x44, 0x53, 0x47, 0x56, 0x20, 0x20, 0x02, 0x01, 0x9A, 0x2D, 0x00, 0x44), + (0xBA, 0x55, 0x9A, 0x2D, 0x9A, 0x2D), + (0x9A, 0x40, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9B, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9C, 0x5C, 0x12, 0x00, 0x00, 0x10, 0x12, 0x00, 0x00, 0x10, 0x02, 0x00, 0x00, 0x00), + (0x9D, 0x8A, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0x9E, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0xB4, 0x1D, 0x1C, 0x1E, 0x0B, 0x14, 0x02, 0x13, 0x09, 0x1E, 0x00, 0x1E, 0x10), + (0xB5, 0x1D, 0x1C, 0x1E, 0x0A, 0x15, 0x03, 0x11, 0x08, 0x1E, 0x01, 0x1E, 0x12), + (0xB6, 0x77, 0x77, 0x00, 0x0A, 0xFF, 0x0A, 0xFF), + (0x86, 0xC6, 0x04, 0xB1, 0x02, 0x58, 0x12, 0x58, 0x0C, 0x13, 0x01, 0xA5, 0x00, 0xA5, 0xA5), + (0xB7, 0x07, 0x0A, 0x0E, 0x06, 0x05, 0x03, 0x2B, 0x03, 0x03, 0x42, 0x07, 0x10, 0x10, 0x2E, 0x3F, 0x0D), + (0xB8, 0x07, 0x0A, 0x0D, 0x05, 0x05, 0x02, 0x2B, 0x02, 0x03, 0x42, 0x06, 0x10, 0x0F, 0x2E, 0x3F, 0x0D), + (0xB9, 0x23, 0x23), + (0xBF, 0x10, 0x14, 0x14, 0x0B, 0x0B, 0x0B), + (0xF2, 0x00), + (0x73, 0x04, 0xDA, 0x12, 0x54, 0x47), + (0x77, 0x6B, 0x5B, 0xFD, 0xC3, 0xC5), + (0x7A, 0x15, 0x27), + (0x7B, 0x04, 0x57), + (0x7E, 0x01, 0x0E), + (0xBF, 0x36), + (0xE3, 0x40, 0x40), + (0xF0, 0x00), + (0xD0, 0x00), + (SLPOUT,), + delay(120), + ), +) +# fmt: on + +# The speakerless variant uses the same display +ES3C35P.extend("ES3C35P-NS") diff --git a/tests/component_tests/mipi_spi/test_get_sequence.py b/tests/component_tests/mipi_spi/test_get_sequence.py index f8614b17a3a..118f844c848 100644 --- a/tests/component_tests/mipi_spi/test_get_sequence.py +++ b/tests/component_tests/mipi_spi/test_get_sequence.py @@ -8,8 +8,11 @@ from esphome.components.mipi import ( BRIGHTNESS, CONF_INVERT_COLORS, CONF_PIXEL_MODE, + SLPOUT, + SWRESET, DriverChip, ) +from esphome.components.mipi_spi.display import MODELS from esphome.const import CONF_BRIGHTNESS # A minimal config with no reset pin: enough for get_sequence(add_madctl=False) to run @@ -96,3 +99,23 @@ def test_get_sequence_omits_brightness_when_not_set() -> None: sequence = chip.get_sequence(_BASE_CONFIG, add_madctl=False) assert not any(_has_brightness_command(sequence, v) for v in range(256)) + + +def _count_subsequence(sequence: tuple[int, ...], part: tuple[int, ...]) -> int: + return sum( + sequence[i : i + len(part)] == part + for i in range(len(sequence) - len(part) + 1) + ) + + +def test_es3c35p_waits_after_reset_and_issues_its_own_slpout() -> None: + """The ES3C35P settles 120ms after reset and sends SLPOUT itself with a 120ms wait.""" + sequence = MODELS["ES3C35P"].get_sequence( + _BASE_CONFIG, add_madctl=False, add_reset=True + ) + + assert sequence[:4] == (SWRESET, 0, 120, 255) + # Exactly one SLPOUT, followed by the panel's 120ms settle, and none appended by the framework + assert _count_subsequence(sequence, (SLPOUT, 0, 120, 255)) == 1 + assert _count_subsequence(sequence, (SLPOUT, 0, 10, 255)) == 0 + assert "ES3C35P-NS" in MODELS From 643f29fae6434393bea9c64cff62edf8ebe8d562 Mon Sep 17 00:00:00 2001 From: Bascht74 <66269310+Bascht74@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:49:19 +0200 Subject: [PATCH 89/97] [uart] Let a UART say it has no line timing (#20227) --- esphome/components/ble_nus/__init__.py | 3 +- esphome/components/tcp_uart/__init__.py | 2 +- esphome/components/uart/__init__.py | 29 ++++++++++ esphome/components/usb_cdc_acm/__init__.py | 2 +- esphome/components/usb_uart/__init__.py | 5 +- tests/component_tests/uart/test_unclocked.py | 57 ++++++++++++++++++++ 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tests/component_tests/uart/test_unclocked.py diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index 54f111e4af9..5d431622412 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -3,6 +3,7 @@ from esphome.components.logger import request_log_listener from esphome.components.uart import ( UARTComponent, debug_to_code, + mark_unclocked, maybe_empty_debug, uart_ns, ) @@ -42,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(BLENUS), + cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked), cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of( *[CONF_LOGS, CONF_UART], lower=True ), diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py index 35326d77d52..df9c1720ee6 100644 --- a/esphome/components/tcp_uart/__init__.py +++ b/esphome/components/tcp_uart/__init__.py @@ -35,7 +35,7 @@ TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) BASE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(TcpUart), + cv.GenerateID(): cv.All(cv.declare_id(TcpUart), uart.mark_unclocked), cv.Required(CONF_PORT): cv.port, cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 2250b9d5088..5eea8060ff9 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from logging import getLogger import math import re @@ -444,6 +445,34 @@ UART_DEVICE_SCHEMA = cv.Schema( KEY_UART_DEVICES = "uart_devices" +@dataclass +class UARTData: + # UARTs whose received bytes do not keep the timing of a serial line. + unclocked: list[ID] = field(default_factory=list) + + +def _get_data() -> UARTData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UARTData() + return CORE.data[DOMAIN] + + +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. + + A gap between them says nothing about where a frame ends. Use it on the UART's declared id, + cv.All(cv.declare_id(MyUart), uart.mark_unclocked), so that every to_code sees it. + """ + _get_data().unclocked.append(uart_id) + return uart_id + + +def is_unclocked(uart_id: ID) -> bool: + """Return True if uart_id was passed to mark_unclocked().""" + # By name: a generated id is named only after the schemas ran. + return any(str(uart) == str(uart_id) for uart in _get_data().unclocked) + + def final_validate_device_schema( name: str, *, diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index e49531c80bc..08cd13e3d27 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -29,7 +29,7 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( # Schema for individual CDC ACM interface instances INTERFACE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), } ) diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 581727b326a..0875966071d 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -6,6 +6,7 @@ from esphome.components.uart import ( CONF_FLUSH_TIMEOUT, UARTComponent, UARTParityOptions, + mark_unclocked, ) from esphome.components.usb_host import ( get_max_packet_size, @@ -162,7 +163,9 @@ def channel_schema(type_: "Type") -> cv.Schema: cv.ensure_list( cv.Schema( { - cv.GenerateID(): cv.declare_id(USBUartChannel), + cv.GenerateID(): cv.All( + cv.declare_id(USBUartChannel), mark_unclocked + ), cv.Optional(CONF_BUFFER_SIZE, default=256): cv.int_range( min=64, max=8192 ), diff --git a/tests/component_tests/uart/test_unclocked.py b/tests/component_tests/uart/test_unclocked.py new file mode 100644 index 00000000000..bbd131037d3 --- /dev/null +++ b/tests/component_tests/uart/test_unclocked.py @@ -0,0 +1,57 @@ +"""Tests for marking UARTs whose bytes do not arrive with the timing of a serial line.""" + +from esphome.components import ble_nus, tcp_uart, uart, usb_cdc_acm +from esphome.const import CONF_ID, PlatformFramework +from esphome.core import ID +from tests.component_tests.types import SetCoreConfigCallable + + +def test_only_a_marked_uart_is_unclocked() -> None: + marked = ID("link", is_declaration=True) + assert uart.mark_unclocked(marked) is marked + assert uart.is_unclocked(ID("link")) + assert not uart.is_unclocked(ID("bus")) + + +def test_a_generated_id_is_found_once_named() -> None: + generated = uart.mark_unclocked( + ID(None, is_declaration=True, type=uart.UARTComponent) + ) + name = generated.resolve([]) + assert uart.is_unclocked(ID(name)) + + +def test_tcp_uart_is_unclocked() -> None: + tcp_uart.CONFIG_SCHEMA({CONF_ID: "link", "role": "server", "port": 6638}) + assert uart.is_unclocked(ID("link")) + + +def test_every_usb_uart_channel_is_unclocked( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, platform_data={"variant": "ESP32S3"}) + # Its schema is built on import and needs the target. + from esphome.components import usb_uart + + schema = usb_uart.channel_schema(usb_uart._TYPES_BY_NAME["FT2232"]) + schema( + { + "channels": [ + {CONF_ID: "ch_a", "baud_rate": 9600}, + {CONF_ID: "ch_b", "baud_rate": 115200}, + ] + } + ) + assert uart.is_unclocked(ID("ch_a")) + assert uart.is_unclocked(ID("ch_b")) + + +def test_usb_cdc_acm_interface_is_unclocked() -> None: + usb_cdc_acm.INTERFACE_SCHEMA({CONF_ID: "cdc"}) + assert uart.is_unclocked(ID("cdc")) + + +def test_ble_nus_is_unclocked(set_core_config: SetCoreConfigCallable) -> None: + set_core_config(PlatformFramework.NRF52_ZEPHYR) + ble_nus.CONFIG_SCHEMA({CONF_ID: "nus", "type": "uart"}) + assert uart.is_unclocked(ID("nus")) From 40425610d5b19dfe8ac6e5cc9ac8593cb89a2ceb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:55:22 -1000 Subject: [PATCH 90/97] [remote_base] Keep constant abbwelcome payloads in shared flash tables (#20311) --- esphome/components/remote_base/__init__.py | 21 +++++------ .../remote_base/abbwelcome_protocol.h | 35 +++++------------- .../config/abbwelcome_payload_tables.yaml | 36 +++++++++++++++++++ .../test_abbwelcome_payload_tables.py | 21 +++++++++++ 4 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml create mode 100644 tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 1911218b1f7..eca57bca8a3 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -44,7 +44,7 @@ from esphome.const import ( CONF_WAND_ID, CONF_ZERO, ) -from esphome.core import ID, coroutine +from esphome.core import coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType, TemplateArgsType @@ -2393,17 +2393,14 @@ async def abbwelcome_action(var, config, args): await cg.templatable(config[CONF_MESSAGE_ID], args, cg.uint8) ) ) - if CONF_DATA in config: - data_ = config[CONF_DATA] - if cg.is_template(data_): - template_ = await cg.templatable( - data_, args, cg.std_vector.template(cg.uint8) - ) - cg.add(var.set_data_template(template_)) - else: - arr_id = ID(f"{var.base}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data_)) - cg.add(var.set_data_static(arr, len(data_))) + # TemplatableBytes starts in lambda mode with no function, so a transmit without data sets an empty payload + await automation.templatable_bytes( + config.get(CONF_DATA, []), + args, + var.set_data_template, + var.set_data_static, + "abbwelcome_data", + ) # Mirage diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index a309c124eed..901950d3634 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -112,13 +112,14 @@ class ABBWelcomeData { } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } uint8_t get_message_id() const { return this->data_[4 + 2 * this->get_address_length()]; } - void set_data(std::vector data) { - uint8_t size = std::min(MAX_DATA_LENGTH, static_cast(data.size())); + void set_data(const uint8_t *data, size_t len) { + uint8_t size = static_cast(std::min(MAX_DATA_LENGTH, len)); this->data_[2] &= (0xff ^ DATA_LENGTH_MASK); this->data_[2] |= (size & DATA_LENGTH_MASK); if (size) - std::copy_n(data.begin(), size, this->data_.begin() + 5 + 2 * this->get_address_length()); + std::copy_n(data, size, this->data_.begin() + 5 + 2 * this->get_address_length()); } + void set_data(const std::vector &data) { this->set_data(data.data(), data.size()); } std::vector get_data() const { std::vector data(this->data_.begin() + 5 + 2 * this->get_address_length(), this->data_.begin() + 5 + 2 * this->get_address_length() + this->get_data_size()); @@ -233,14 +234,8 @@ template class ABBWelcomeAction : public RemoteTransmitterAction TEMPLATABLE_VALUE(uint8_t, message_type) TEMPLATABLE_VALUE(uint8_t, message_id) TEMPLATABLE_VALUE(bool, auto_message_id) - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) + void encode(RemoteTransmitData *dst, Ts... x) override { ABBWelcomeData data; data.set_three_byte_address(this->three_byte_address_.value(x...)); @@ -250,25 +245,11 @@ template class ABBWelcomeAction : public RemoteTransmitterAction data.set_message_type(this->message_type_.value(x...)); data.set_message_id(this->message_id_.value(x...)); data.auto_message_id = this->auto_message_id_.value(x...); - std::vector data_vec; - if (this->len_ > 0) { - // Static mode: copy from flash to vector - data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else if (this->len_ < 0) { - // Template mode: call function - data_vec = this->data_.func(x...); - } - data.set_data(data_vec); + this->data_.template visit( + [&data](const uint8_t *bytes, size_t len) { data.set_data(bytes, len); }, x...); data.finalize(); ABBWelcomeProtocol().encode(dst, data); } - - protected: - ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } data_; }; } // namespace esphome::remote_base diff --git a/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml new file mode 100644 index 00000000000..66e0fe4e7fa --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/abbwelcome_payload_tables.yaml @@ -0,0 +1,36 @@ +--- +esphome: + name: test + on_boot: + then: + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x01 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x02 + data: [0x10, 0x20, 0x30] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x03 + data: [] + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x04 + - remote_transmitter.transmit_abbwelcome: + source_address: 0x1234 + destination_address: 0x5678 + message_type: 0x05 + data: !lambda return {0x09}; + +esp32: + board: esp32dev + +remote_transmitter: + pin: 4 + carrier_duty_percent: 100% diff --git a/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py new file mode 100644 index 00000000000..a653b5137ef --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_abbwelcome_payload_tables.py @@ -0,0 +1,21 @@ +"""Tests for abbwelcome constant data in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_data_shares_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal data shares one table, empty or missing data is an empty table, lambdas stay templates.""" + main_cpp = generate_main(component_config_path("abbwelcome_payload_tables.yaml")) + + tables = re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + assert [v for _, v in tables] == ["{0x10, 0x20, 0x30}"] + assert main_cpp.count(f"set_data_static({tables[0][0]}, 3);") == 2 + assert main_cpp.count("set_data_static(nullptr, 0);") == 2 + assert main_cpp.count("set_data_template(") == 1 From da0c59a57c6abc9e8fc176c17f949ed947a71f22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:57:06 -1000 Subject: [PATCH 91/97] [http_request] Keep action request headers in a shared flash table (#20225) --- esphome/components/http_request/__init__.py | 28 ++++++-- .../components/http_request/http_request.h | 21 ++++-- .../http_request/config/request_headers.yaml | 72 +++++++++++++++++++ .../http_request/test_request_headers.py | 37 ++++++++++ 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 tests/component_tests/http_request/config/request_headers.yaml create mode 100644 tests/component_tests/http_request/test_request_headers.py diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index a2c181a5ae0..89fe5f8b1cc 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -39,6 +39,7 @@ HttpRequestIDF = http_request_ns.class_("HttpRequestIDF", HttpRequestComponent) HttpRequestHost = http_request_ns.class_("HttpRequestHost", HttpRequestComponent) HttpContainer = http_request_ns.class_("HttpContainer") +RequestHeader = http_request_ns.struct("RequestHeader") HttpRequestSendAction = http_request_ns.class_( "HttpRequestSendAction", automation.Action @@ -360,12 +361,27 @@ async def http_request_action_to_code( for key in json_: template_ = await cg.templatable(json_[key], args, cg.std_string) cg.add(var.add_json(key, template_)) - request_headers = config.get(CONF_REQUEST_HEADERS, {}) - if request_headers: - cg.add(var.init_request_headers(len(request_headers))) - for key, value in request_headers.items(): - template_ = await cg.templatable(value, args, cg.const_char_ptr) - cg.add(var.add_request_header(key, template_)) + if request_headers := config.get(CONF_REQUEST_HEADERS): + # A user lambda may keep static state, so only all-constant lists are shared + share = not any(map(cg.is_template, request_headers.values())) + # Constant headers ignore their arguments, so drop the names: identical lists + # from scripts with different parameter names then render the same table. + header_args = [(arg_type, "") for arg_type, _ in args] if share else args + headers = [ + cg.StructInitializer( + "", + ("name", key), + ("value", await cg.templatable(value, header_args, cg.const_char_ptr)), + ) + for key, value in request_headers.items() + ] + table = cg.shared_progmem_array( + "http_request_headers", + RequestHeader.template(*[arg_type for arg_type, _ in args]), + headers, + share=share, + ) + cg.add(var.set_request_headers(table, len(headers))) for value in config.get(CONF_COLLECT_HEADERS, []): cg.add(var.add_collect_header(value.lower())) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 68696118ea8..424fcf84e86 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -22,6 +22,12 @@ struct Header { std::string value; }; +/// One request header of an action; codegen puts these in a shared flash table. +template struct RequestHeader { + const char *name; + const char *(*value)(Ts...); +}; + // Some common HTTP status codes enum HttpStatus { HTTP_STATUS_OK = 200, @@ -492,9 +498,12 @@ template class HttpRequestSendAction final : public Actionrequest_headers_.init(count); } - void add_request_header(const char *key, TemplatableFn value) { - this->request_headers_.push_back({key, value}); + static_assert(std::is_trivially_copyable_v> && + sizeof(RequestHeader) % sizeof(uint32_t) == 0, + "ESP8266 reads request headers from flash in whole words"); + /// Codegen only: the table must outlive the action. + void set_request_headers(const RequestHeader *headers, size_t count) { + this->request_headers_ = ConstVector>(headers, count); } void add_collect_header(const char *value) { this->lower_case_collect_headers_.emplace_back(value); } @@ -530,8 +539,8 @@ template class HttpRequestSendAction final : public Action request_headers; request_headers.reserve(this->request_headers_.size()); - for (const auto &[key, val] : this->request_headers_) { - request_headers.push_back({key, val.value(x...)}); + for (const auto &header : this->request_headers_) { + request_headers.push_back({header.name, header.value(x...)}); } auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, @@ -596,7 +605,7 @@ template class HttpRequestSendAction final : public Action>> request_headers_{}; + ConstVector> request_headers_{}; std::vector lower_case_collect_headers_{"content-type", "content-length"}; FixedVector>> json_{}; std::function json_func_{nullptr}; diff --git a/tests/component_tests/http_request/config/request_headers.yaml b/tests/component_tests/http_request/config/request_headers.yaml new file mode 100644 index 00000000000..bdd4cd66304 --- /dev/null +++ b/tests/component_tests/http_request/config/request_headers.yaml @@ -0,0 +1,72 @@ +esphome: + name: test + +esp32: + board: esp32dev + +wifi: + ssid: test + password: testtest + +http_request: + +button: + - platform: template + id: shared_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: shared_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - platform: template + id: other + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: text/plain + - platform: template + id: lambda_a + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + - platform: template + id: lambda_b + on_press: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: !lambda return "a"; + +script: + - id: param_a + parameters: + level: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a + - id: param_b + parameters: + value: int + then: + - http_request.get: + url: http://example.com + request_headers: + Accept: application/json + X-Mode: a diff --git a/tests/component_tests/http_request/test_request_headers.py b/tests/component_tests/http_request/test_request_headers.py new file mode 100644 index 00000000000..4eefa55b20b --- /dev/null +++ b/tests/component_tests/http_request/test_request_headers.py @@ -0,0 +1,37 @@ +"""Request headers are PROGMEM tables, shared only between all-constant lists.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_request_headers_share_constant_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("request_headers.yaml")) + + # Actions in config order: the five buttons, then the two scripts + tables = [ + (table, int(count)) + for table, count in re.findall( + r"->set_request_headers\((\w+), (\d+)\);", main_cpp + ) + ] + assert len(tables) == 7 + shared_a, shared_b, other, lambda_a, lambda_b, param_a, param_b = tables + assert shared_a == shared_b + assert shared_a[1] == 2 + assert other[1] == 1 + # A list with a lambda may keep static state, so it never shares even with an identical one + assert lambda_a[0] != lambda_b[0] + assert len({shared_a[0], other[0], lambda_a[0], lambda_b[0]}) == 4 + for table, _ in tables: + assert re.search(rf"\b{table}\[\] PROGMEM = \{{", main_cpp) + # Constant headers ignore their arguments, so scripts whose parameters differ + # only by name share a table, and it is the only one of its argument type + assert param_a == param_b + assert param_a[0] not in {shared_a[0], other[0], lambda_a[0], lambda_b[0]} + assert re.findall(r"RequestHeader<[^>]+> (\w+)\[\] PROGMEM = \{", main_cpp) == [ + param_a[0] + ] From e16b3a6245f4322c26b4756fe848d3e12c14c3fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:57:20 -1000 Subject: [PATCH 92/97] [api] Share one flash string pool between API action and Home Assistant action strings (#20281) --- esphome/codegen.py | 1 + esphome/components/api/__init__.py | 40 +++-------------- esphome/cpp_generator.py | 13 ++++++ .../api/test_action_metadata.py | 44 ++++++++++++------- tests/unit_tests/test_cpp_generator.py | 31 +++++++++++++ 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/esphome/codegen.py b/esphome/codegen.py index 4db01a21740..9ed36de0d55 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -44,6 +44,7 @@ from esphome.cpp_generator import ( # noqa: F401 safe_exp, set_cpp_standard, shared_progmem_array, + shared_progmem_string, statement, static_const_array, static_function, diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f538c0b5e50..f105c1d7f79 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -54,7 +54,7 @@ from esphome.const import ( from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import Expression, MockObj, TemplateArgsType import esphome.final_validate as fv -from esphome.helpers import cpp_string_escape, fnv1_hash +from esphome.helpers import fnv1_hash from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType @@ -537,28 +537,11 @@ FINAL_VALIDATE_SCHEMA = cv.All( ) -def _add_action_strings( - index: int, strings: list[str | None], interned: dict[str, MockObj] -) -> MockObj: - """Emit the PROGMEM string table for one action. - - Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical - strings are shared between actions through `interned`. - """ - entries: list[MockObj] = [] - for string in strings: - if string is None: - entries.append(cg.nullptr) - continue - if (var := interned.get(string)) is None: - var = interned[string] = cg.progmem_array( - ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char), - string, - ) - entries.append(var) +def _add_action_strings(index: int, strings: list[str | None]) -> MockObj: + """Emit the PROGMEM string table for one action.""" return cg.progmem_array( ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr), - entries, + [cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings], ) @@ -611,7 +594,6 @@ async def to_code(config: ConfigType) -> None: has_metadata = _has_action_metadata(actions) if has_metadata: cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") - interned_strings: dict[str, MockObj] = {} # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.MockObj] = [] for index, conf in enumerate(actions): @@ -656,7 +638,7 @@ async def to_code(config: ConfigType) -> None: service_template_args.append(native) func_args.append((native, name)) strings = _action_strings(conf, has_metadata) - table = _add_action_strings(index, strings, interned_strings) + table = _add_action_strings(index, strings) if CORE.is_esp8266: scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types @@ -828,14 +810,6 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) -def _field_string(value: str) -> Expression: - # ESP8266 can only keep a string in flash as its own PROGMEM array - literal = cg.RawExpression(cpp_string_escape(value)) - if CORE.is_esp8266: - return cg.shared_progmem_array("ha_field_str", cg.char, literal) - return literal - - async def _new_service_call_action( server_id: ID, action_id: ID, @@ -858,12 +832,12 @@ async def _new_service_call_action( ) async def field(key: str | None, value: Any, output_type: Any = None) -> Expression: - key_exp = cg.nullptr if key is None else _field_string(key) + key_exp = cg.nullptr if key is None else cg.shared_progmem_string(key) if cg.is_template(value): # output_type=None lets lambdas return numbers or char pointers; C++ converts them lam = await cg.process_lambda(value, args, return_type=output_type) return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})") - return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr) + return cg.ArrayInitializer(key_exp, cg.shared_progmem_string(value), cg.nullptr) entries = [await field(None, service, cg.std_string)] for group in groups: diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 9c9fed66012..1196669662c 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -540,6 +540,19 @@ def shared_progmem_array( return array +def shared_progmem_string(value: str) -> Expression: + """Return a string that stays in flash: one shared PROGMEM array per text on ESP8266. + + Other platforms keep string literals in flash already, so they get the plain literal. + """ + from esphome.cpp_types import char + + text = StringLiteral(value) + if CORE.is_esp8266: + return shared_progmem_array("progmem_str", char, text) + return text + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") diff --git a/tests/component_tests/api/test_action_metadata.py b/tests/component_tests/api/test_action_metadata.py index adbfdf306eb..69c9605a675 100644 --- a/tests/component_tests/api/test_action_metadata.py +++ b/tests/component_tests/api/test_action_metadata.py @@ -2,6 +2,7 @@ from collections.abc import Callable from pathlib import Path +import re import pytest @@ -28,35 +29,46 @@ CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yam def test_metadata_is_emitted_as_progmem_table( generate_main: Callable[[str | Path], str], ) -> None: - """Every action string is a PROGMEM array referenced from one PROGMEM table.""" + """Every action string is a literal referenced from one PROGMEM table.""" main_cpp = generate_main(CONFIG) - assert ( - 'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp - ) - assert ( - 'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";' - in main_cpp - ) - assert ( - 'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";' - in main_cpp - ) assert ( "static constexpr const char * api_action0_strings[] PROGMEM = {" - "api_action_str0, api_action_str1, api_action_str2, api_action_str3, " - "api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp + '"play_buzzer", "Play an RTTTL melody on the buzzer", "song_str", ' + '"RTTTL melody string", "two_short:d=4,o=5,b=100:16e6,16e6", "volume", ' + "nullptr, nullptr};" in main_cpp ) # An action without metadata still carries the metadata slots (as nullptr) assert ( "static constexpr const char * api_action1_strings[] PROGMEM = {" - "api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp + '"plain_action", nullptr, "value", nullptr, nullptr};' in main_cpp ) assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines} assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None +def test_esp8266_strings_are_shared_progmem_arrays( + generate_main: Callable[[str | Path], str], +) -> None: + """On ESP8266 each string is its own PROGMEM array, referenced from the table.""" + main_cpp = generate_main(CONFIG_ESP8266) + + def array_for(text: str) -> str: + match = re.search( + rf'static constexpr char (progmem_str\w*)\[\] PROGMEM = "{text}";', main_cpp + ) + assert match, text + return match.group(1) + + assert array_for("play_buzzer") + assert ( + "static constexpr const char * api_action1_strings[] PROGMEM = {" + f"{array_for('plain_action')}, nullptr, {array_for('value')}, nullptr, nullptr}};" + in main_cpp + ) + + def test_esp8266_sizes_scratch_buffer_for_largest_action( generate_main: Callable[[str | Path], str], ) -> None: @@ -76,7 +88,7 @@ def test_shorthand_variables_emit_no_metadata( assert ( "static constexpr const char * api_action0_strings[] PROGMEM = " - "{api_action_str0, api_action_str1};" in main_cpp + '{"plain_action", "value"};' in main_cpp ) assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines} diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 6fdd197c952..9529e448008 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -4,6 +4,12 @@ import math import pytest from esphome import cpp_generator as cg, cpp_types as ct +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE, ID, EsphomeError @@ -887,6 +893,31 @@ class TestSharedProgmemArray: assert str(array) == "table_2" +class TestProgmemString: + @staticmethod + def _set_platform(platform: str) -> None: + CORE.config = {} + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} + + def test_esp8266_shares_one_array_per_text(self) -> None: + self._set_platform(PLATFORM_ESP8266) + a = cg.shared_progmem_string("level") + b = cg.shared_progmem_string("level") + c = cg.shared_progmem_string('say "hi"') + assert a is b + assert str(a) == "progmem_str" + assert str(c) == "progmem_str_2" + assert [str(st) for st in CORE.global_statements] == [ + 'static constexpr char progmem_str[] PROGMEM = "level";', + 'static constexpr char progmem_str_2[] PROGMEM = "say \\042hi\\042";', + ] + + def test_other_platforms_return_the_literal(self) -> None: + self._set_platform(PLATFORM_ESP32) + assert str(cg.shared_progmem_string('say "hi"')) == '"say \\042hi\\042"' + assert not CORE.global_statements + + def test_extern_progmem_array_is_an_externally_linked_global() -> None: array = cg.extern_progmem_array("esphome::api::API_TABLE", ct.uint8, [1, 2]) assert str(array) == "esphome::api::API_TABLE" From 9295342c16f7eecdb69f1f2730deb91845c17616 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 14:58:49 -1000 Subject: [PATCH 93/97] [event] Keep event types in a shared flash table (#20217) --- esphome/components/api/api.proto | 2 +- esphome/components/api/api_pb2.h | 2 +- esphome/components/event/__init__.py | 4 +- esphome/components/event/event.cpp | 7 +-- esphome/components/event/event.h | 26 +++++++--- tests/component_tests/event/__init__.py | 0 .../event/config/event_types.yaml | 19 +++++++ .../component_tests/event/test_event_types.py | 25 +++++++++ tests/components/event/event_types_test.cpp | 51 +++++++++++++++++++ 9 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/event/__init__.py create mode 100644 tests/component_tests/event/config/event_types.yaml create mode 100644 tests/component_tests/event/test_event_types.py create mode 100644 tests/components/event/event_types_test.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1b06348e2ce..f8f31d81df0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -2570,7 +2570,7 @@ message ListEntitiesEventResponse { EntityCategory entity_category = 7; string device_class = 8 [(max_data_length) = 47]; - repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector"]; + repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector"]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } message EventResponse { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 17436d33aab..14204e97aaa 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -3460,7 +3460,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } #endif StringRef device_class{nullptr, 0}; // null until set, encode only - const FixedVector *event_types{}; + const ConstVector *event_types{}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index c40130634f9..b09d9791414 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -98,7 +98,9 @@ async def setup_event_core_( ) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - cg.add(var.set_event_types(event_types)) + if event_types: + table = cg.shared_progmem_array("event_types", cg.const_char_ptr, event_types) + cg.add(var.set_event_types_static(table, len(event_types))) setup_device_class(config) diff --git a/esphome/components/event/event.cpp b/esphome/components/event/event.cpp index 3170c7a8dd0..671e11e7d21 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -28,11 +28,8 @@ void Event::trigger(const char *event_type) { #endif } -void Event::set_event_types(const FixedVector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } +void Event::set_event_types_copy_(const char *const *event_types, size_t count) { + this->types_.assign_copy(event_types, count); this->last_event_type_ = nullptr; // Reset when types change } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index f57327e33f0..203ee52ac8e 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -11,6 +11,9 @@ namespace esphome::event { +/// A codegen table in flash, or a heap copy of a runtime list (see ConstVector). +using EventTypes = ConstVector; + #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ @@ -24,20 +27,29 @@ class Event : public EntityBase { void trigger(const char *event_type); void trigger(const std::string &event_type) { this->trigger(event_type.c_str()); } - /// Set the event types supported by this event; called by generated code with string literals. + /// Codegen only: points at a table that outlives the event (see ConstVector::assign_static). + void set_event_types_static(const char *const *event_types, size_t count) { + this->types_.assign_static(event_types, count); + this->last_event_type_ = nullptr; + } + /// Runtime lists are copied; the strings must still outlive the event. void set_event_types(std::initializer_list event_types) { - this->types_ = event_types; - this->last_event_type_ = nullptr; // Reset when types change + this->set_event_types_copy_(event_types.begin(), event_types.size()); } /// Copy the event types of another event, for components that wrap one. - void set_event_types(const FixedVector &event_types); + void set_event_types(const EventTypes &event_types) { + this->set_event_types_copy_(event_types.data(), event_types.size()); + } + void set_event_types(const FixedVector &event_types) { + this->set_event_types_copy_(event_types.begin(), event_types.size()); + } // Deleted overloads to catch incorrect std::string usage at compile time with clear error messages void set_event_types(std::initializer_list event_types) = delete; void set_event_types(const FixedVector &event_types) = delete; /// Return the event types supported by this event. - const FixedVector &get_event_types() const { return this->types_; } + const EventTypes &get_event_types() const { return this->types_; } /// Return the last triggered event type, or empty StringRef if no event triggered yet. StringRef get_last_event_type() const { return StringRef::from_maybe_nullptr(this->last_event_type_); } @@ -68,8 +80,10 @@ class Event : public EntityBase { } protected: + void set_event_types_copy_(const char *const *event_types, size_t count); + LazyCallbackManager event_callback_; - FixedVector types_; + EventTypes types_; private: /// Last triggered event type - must point to entry in types_ to ensure valid lifetime. diff --git a/tests/component_tests/event/__init__.py b/tests/component_tests/event/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/event/config/event_types.yaml b/tests/component_tests/event/config/event_types.yaml new file mode 100644 index 00000000000..8e849d4a782 --- /dev/null +++ b/tests/component_tests/event/config/event_types.yaml @@ -0,0 +1,19 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +event: + - platform: template + id: event_a + name: Event A + event_types: [pressed, held] + - platform: template + id: event_b + name: Event B + event_types: [pressed, held] + - platform: template + id: event_c + name: Event C + event_types: [ring] diff --git a/tests/component_tests/event/test_event_types.py b/tests/component_tests/event/test_event_types.py new file mode 100644 index 00000000000..30e653bc70d --- /dev/null +++ b/tests/component_tests/event/test_event_types.py @@ -0,0 +1,25 @@ +"""Event types are shared PROGMEM tables of type pointers.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_event_types_use_shared_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("event_types.yaml")) + + calls = { + var: table + for var, table, _ in re.findall( + r"(\w+)->set_event_types_static\((\w+), (\d+)\);", main_cpp + ) + } + assert set(calls) == {"event_a", "event_b", "event_c"} + assert calls["event_a"] == calls["event_b"] != calls["event_c"] + assert ( + f'static constexpr const char * {calls["event_a"]}[] PROGMEM = {{"pressed", "held"}};' + in main_cpp + ) diff --git a/tests/components/event/event_types_test.cpp b/tests/components/event/event_types_test.cpp new file mode 100644 index 00000000000..0bc99e1a8e3 --- /dev/null +++ b/tests/components/event/event_types_test.cpp @@ -0,0 +1,51 @@ +#include + +#include "esphome/components/event/event.h" + +namespace esphome::event::testing { + +static const char *const TYPES[] = {"pressed", "held"}; + +TEST(EventTypes, StaticTableIsViewed) { + Event event; + event.set_event_types_static(TYPES, 2); + EXPECT_EQ(event.get_event_types().data(), TYPES); + EXPECT_EQ(event.get_event_types().size(), 2u); +} + +TEST(EventTypes, CopiesAnotherEventAndResetsLastType) { + Event source; + source.set_event_types({"x", "y"}); + Event copy; + copy.set_event_types_static(TYPES, 2); + copy.trigger("held"); + ASSERT_TRUE(copy.has_event()); + copy.set_event_types(source.get_event_types()); + EXPECT_FALSE(copy.has_event()); + EXPECT_NE(copy.get_event_types().data(), TYPES); + EXPECT_NE(copy.get_event_types().data(), source.get_event_types().data()); + EXPECT_EQ(copy.get_event_types().size(), 2u); + EXPECT_STREQ(copy.get_event_types()[1], "y"); +} + +TEST(EventTypes, CopiesItsOwnList) { + Event event; + event.set_event_types({"a", "b"}); + event.set_event_types(event.get_event_types()); // copies before freeing the old list + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[1], "b"); +} + +TEST(EventTypes, CopiesFromFixedVector) { + FixedVector types; + types.init(2); + types.push_back("on"); + types.push_back("off"); + Event event; + event.set_event_types(types); + EXPECT_NE(event.get_event_types().data(), types.begin()); + EXPECT_EQ(event.get_event_types().size(), 2u); + EXPECT_STREQ(event.get_event_types()[0], "on"); +} + +} // namespace esphome::event::testing From 993fd8d7a212b57416149802af9c795cf03e1f5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:04:16 -1000 Subject: [PATCH 94/97] [thermostat] Keep presets in shared flash tables (#20080) --- esphome/components/thermostat/climate.py | 107 +++++++----------- .../thermostat/thermostat_climate.cpp | 65 ++++++----- .../thermostat/thermostat_climate.h | 46 +++++--- tests/component_tests/thermostat/__init__.py | 0 .../thermostat/config/presets.yaml | 53 +++++++++ .../thermostat/test_presets.py | 28 +++++ 6 files changed, 189 insertions(+), 110 deletions(-) create mode 100644 tests/component_tests/thermostat/__init__.py create mode 100644 tests/component_tests/thermostat/config/presets.yaml create mode 100644 tests/component_tests/thermostat/test_presets.py diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 3cc4dc70091..8a954b181eb 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -39,7 +39,6 @@ from esphome.const import ( CONF_HEAT_MODE, CONF_HEAT_OVERRUN, CONF_HUMIDITY_SENSOR, - CONF_ID, CONF_IDLE_ACTION, CONF_MAX_COOLING_RUN_TIME, CONF_MAX_HEATING_RUN_TIME, @@ -72,6 +71,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.helpers import cpp_string_escape from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" @@ -93,6 +93,8 @@ ThermostatClimate = thermostat_ns.class_( ThermostatClimateTargetTempConfig = thermostat_ns.struct( "ThermostatClimateTargetTempConfig" ) +ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") +ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") ON_BOOT_RESTORE_FROM = { "MEMORY": OnBootRestoreFrom.MEMORY, @@ -116,6 +118,7 @@ ClimatePreset = climate_ns.enum("ClimatePreset") PRESET_CONFIG_SCHEMA = cv.Schema( { + # Still accepted for existing configs; presets are flash table entries, not variables. cv.GenerateID(): cv.declare_id(ThermostatClimateTargetTempConfig), cv.Required(CONF_NAME): cv.string_strict, cv.Optional(CONF_MODE): validate_climate_mode, @@ -674,7 +677,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, } ), - cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_PRESET): cv.All( + cv.ensure_list(PRESET_CONFIG_SCHEMA), cv.Length(max=255) + ), cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from, cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( single=True @@ -964,85 +969,51 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: - # Separate standard and custom presets, and build preset config variables - standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = [] - custom_presets: list[tuple[str, cg.MockObj]] = [] + # Presets live in shared PROGMEM tables; the thermostat keeps a pointer and a count. + standard_presets: list[cg.StructInitializer] = [] + custom_presets: list[cg.StructInitializer] = [] + nan = float("nan") + unset = cg.RawExpression("std::nullopt") for preset_config in config[CONF_PRESET]: name = preset_config[CONF_NAME] - standard_preset = None - if name.upper() in climate.CLIMATE_PRESETS: - standard_preset = climate.CLIMATE_PRESETS[name.upper()] - + low = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_LOW) + high = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH) if two_points_available: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] + temperatures = (nan, low, high) + else: + temperatures = (high if high is not None else low, nan, nan) + target = ThermostatClimateTargetTempConfig( + *(nan if t is None else t for t in temperatures), + preset_config.get(CONF_MODE, unset), + preset_config.get(CONF_FAN_MODE, unset), + preset_config.get(CONF_SWING_MODE, unset), + ) + if (standard := climate.CLIMATE_PRESETS.get(name.upper())) is not None: + standard_presets.append( + cg.StructInitializer( + ThermostatPresetEntry, ("preset", standard), ("config", target) + ) ) else: - preset_target_config = None - - preset_target_variable = cg.new_variable( - preset_config[CONF_ID], preset_target_config - ) - - if CONF_MODE in preset_config: - cg.add(preset_target_variable.set_mode(preset_config[CONF_MODE])) - - if CONF_FAN_MODE in preset_config: - cg.add( - preset_target_variable.set_fan_mode(preset_config[CONF_FAN_MODE]) - ) - - if CONF_SWING_MODE in preset_config: - cg.add( - preset_target_variable.set_swing_mode( - preset_config[CONF_SWING_MODE] + custom_presets.append( + cg.StructInitializer( + ThermostatCustomPresetEntry, + ("name", cg.RawExpression(cpp_string_escape(name))), + ("config", target), ) ) - if standard_preset is not None: - standard_presets.append((standard_preset, preset_target_variable)) - else: - custom_presets.append((name, preset_target_variable)) - - # Build initializer list for standard presets if standard_presets: - cg.add( - var.set_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatPresetEntry"), - ("preset", preset), - ("config", preset_var), - ) - for preset, preset_var in standard_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_presets", ThermostatPresetEntry, standard_presets ) - - # Build initializer list for custom presets + cg.add(var.set_preset_config(table, len(standard_presets))) if custom_presets: - cg.add( - var.set_custom_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatCustomPresetEntry"), - ("name", cg.RawExpression(f'"{name}"')), - ("config", preset_var), - ) - for name, preset_var in custom_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_custom_presets", ThermostatCustomPresetEntry, custom_presets ) + cg.add(var.set_custom_preset_config(table, len(custom_presets))) if CONF_DEFAULT_PRESET in config: default_preset_name = config[CONF_DEFAULT_PRESET] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index abfe1b027a5..5d24759c2d9 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -322,8 +322,8 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - for (const auto &entry : this->preset_config_) { - traits.add_supported_preset(entry.preset); + for (uint8_t i = 0; i < this->preset_count_; i++) { + traits.add_supported_preset(this->preset_entry_(i).preset); } // Custom presets are stored on Climate base class and wired via get_traits() @@ -1175,17 +1175,30 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm } } +ThermostatPresetEntry ThermostatClimate::preset_entry_(uint8_t index) const { + ThermostatPresetEntry entry; + progmem_memcpy(&entry, &this->preset_config_[index], sizeof(entry)); + return entry; +} + +ThermostatCustomPresetEntry ThermostatClimate::custom_preset_entry_(uint8_t index) const { + ThermostatCustomPresetEntry entry; + progmem_memcpy(&entry, &this->custom_preset_config_[index], sizeof(entry)); + return entry; +} + void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { // Linear search through preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->preset_config_) { + optional config; + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); if (entry.preset == preset) { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset))); if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) { // Fire preset changed trigger @@ -1205,16 +1218,17 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { void ThermostatClimate::change_custom_preset_(const char *custom_preset, size_t len) { // Linear search through custom preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->custom_preset_config_) { + optional config; + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); // Compare first len chars, then verify entry.name ends there (same length) if (strncmp(entry.name, custom_preset, len) == 0 && entry.name[len] == '\0') { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset); if (this->change_preset_internal_(*config) || !this->has_custom_preset() || this->get_custom_preset() != custom_preset) { @@ -1279,13 +1293,14 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { +void ThermostatClimate::set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count) { this->custom_preset_config_ = presets; + this->custom_preset_count_ = count; // Populate Climate base class custom presets vector std::vector names; - names.reserve(presets.size()); - for (const auto &entry : this->custom_preset_config_) { - names.push_back(entry.name); + names.reserve(count); + for (uint8_t i = 0; i < count; i++) { + names.push_back(this->custom_preset_entry_(i).name); } this->set_supported_custom_presets(names); } @@ -1294,7 +1309,8 @@ ThermostatClimate::ThermostatClimate() = default; void ThermostatClimate::set_default_preset(const char *custom_preset) { // Find the preset in custom_preset_config_ and store pointer from there - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); if (strcmp(entry.name, custom_preset) == 0) { this->default_custom_preset_ = entry.name; return; @@ -1545,9 +1561,10 @@ void ThermostatClimate::dump_config() { YESNO(this->supports_dehumidification_ || this->supports_humidification_), YESNO(this->supports_dehumidification_), YESNO(this->supports_humidification_)); - if (!this->preset_config_.empty()) { + if (this->preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported PRESETS:"); - for (const auto &entry : this->preset_config_) { + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset)); ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? LOG_STR_LITERAL(" (default)") : ""); @@ -1555,9 +1572,10 @@ void ThermostatClimate::dump_config() { } } - if (!this->custom_preset_config_.empty()) { + if (this->custom_preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); const auto *preset_name = entry.name; ESP_LOGCONFIG(TAG, " %s:%s", preset_name, (this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0) @@ -1568,13 +1586,4 @@ void ThermostatClimate::dump_config() { } } -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature) - : default_temperature(default_temperature) {} - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature_low, - float default_temperature_high) - : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} - } // namespace esphome::thermostat diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index b7d46eae227..e9c0ae75fdc 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -49,9 +49,21 @@ struct ThermostatClimateTimer { struct ThermostatClimateTargetTempConfig { public: - ThermostatClimateTargetTempConfig(); - ThermostatClimateTargetTempConfig(float default_temperature); - ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high); + constexpr ThermostatClimateTargetTempConfig() = default; + constexpr ThermostatClimateTargetTempConfig(float default_temperature) : default_temperature(default_temperature) {} + constexpr ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high) + : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} + /// Full form used by the generated preset tables in flash. + constexpr ThermostatClimateTargetTempConfig(float default_temperature, float default_temperature_low, + float default_temperature_high, optional mode, + optional fan_mode, + optional swing_mode) + : default_temperature(default_temperature), + default_temperature_low(default_temperature_low), + default_temperature_high(default_temperature_high), + fan_mode_(fan_mode), + swing_mode_(swing_mode), + mode_(mode) {} void set_fan_mode(climate::ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; } void set_swing_mode(climate::ClimateSwingMode swing_mode) { this->swing_mode_ = swing_mode; } @@ -60,10 +72,6 @@ struct ThermostatClimateTargetTempConfig { float default_temperature{NAN}; float default_temperature_low{NAN}; float default_temperature_high{NAN}; - float cool_deadband_{NAN}; - float cool_overrun_{NAN}; - float heat_deadband_{NAN}; - float heat_overrun_{NAN}; optional fan_mode_{}; optional swing_mode_{}; optional mode_{}; @@ -175,8 +183,12 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_supports_humidification(bool supports_humidification); void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } - void set_custom_preset_config(std::initializer_list presets); + /// `presets` is a PROGMEM table that outlives the thermostat. + void set_preset_config(const PresetEntry *presets, uint8_t count) { + this->preset_config_ = presets; + this->preset_count_ = count; + } + void set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count); Trigger<> *get_cool_action_trigger(); Trigger<> *get_supplemental_cool_action_trigger(); @@ -328,6 +340,9 @@ class ThermostatClimate final : public climate::Climate, public Component { bool humidification_required_(); void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config); + /// Copies of table entries; the tables live in PROGMEM and hold byte-sized fields. + PresetEntry preset_entry_(uint8_t index) const; + CustomPresetEntry custom_preset_entry_(uint8_t index) const; /// Minimum allowable duration in seconds for action timers const uint8_t min_timer_duration_{1}; @@ -349,6 +364,9 @@ class ThermostatClimate final : public climate::Climate, public Component { /// state will attempt to be restored if possible OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY}; + uint8_t preset_count_{0}; + uint8_t custom_preset_count_{0}; + /// Whether the controller supports auto/cooling/drying/fanning/heating. /// /// A false value for any given attribute means that the controller has no such action @@ -522,13 +540,13 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Climate action timers std::array timer_{}; - /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) - FixedVector preset_config_{}; - /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") - FixedVector custom_preset_config_{}; + /// Standard preset configurations (Eg. AWAY, ECO, etc), a PROGMEM table of preset_count_ entries + const PresetEntry *preset_config_{nullptr}; + /// Custom preset configurations (eg. "My Custom Preset"), a PROGMEM table of custom_preset_count_ entries + const CustomPresetEntry *custom_preset_config_{nullptr}; private: - /// Default custom preset to use on start up (pointer to entry in custom_preset_config_) + /// Default custom preset to use on start up (name pointer from an entry in custom_preset_config_) const char *default_custom_preset_{nullptr}; }; diff --git a/tests/component_tests/thermostat/__init__.py b/tests/component_tests/thermostat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/thermostat/config/presets.yaml b/tests/component_tests/thermostat/config/presets.yaml new file mode 100644 index 00000000000..956562ff63f --- /dev/null +++ b/tests/component_tests/thermostat/config/presets.yaml @@ -0,0 +1,53 @@ +--- +esphome: + name: test + +logger: + +esp32: + board: esp32dev + +sensor: + - platform: template + id: temp_sensor + lambda: return 21.0; + +climate: + - platform: thermostat + id: thermo_a + name: Thermo A + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C + - platform: thermostat + id: thermo_b + name: Thermo B + sensor: temp_sensor + min_heating_off_time: 300s + min_heating_run_time: 300s + min_idle_time: 30s + heat_action: + - logger.log: heat + idle_action: + - logger.log: idle + preset: + - name: Away + default_target_temperature_low: 15°C + mode: HEAT + - name: Eco + default_target_temperature_low: 17°C + - name: Night Mode + default_target_temperature_low: 16°C diff --git a/tests/component_tests/thermostat/test_presets.py b/tests/component_tests/thermostat/test_presets.py new file mode 100644 index 00000000000..129cb1587fa --- /dev/null +++ b/tests/component_tests/thermostat/test_presets.py @@ -0,0 +1,28 @@ +"""Tests for the thermostat preset codegen.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_presets_shared_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Thermostats with the same presets share one PROGMEM table per kind.""" + main_cpp = generate_main(component_config_path("presets.yaml")) + + standard = re.findall( + r"static constexpr thermostat::ThermostatPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + custom = re.findall( + r"static constexpr thermostat::ThermostatCustomPresetEntry (\w+)\[\] PROGMEM", + main_cpp, + ) + assert len(standard) == 1 + assert len(custom) == 1 + for var in ("thermo_a", "thermo_b"): + assert f"{var}->set_preset_config({standard[0]}, 2);" in main_cpp + assert f"{var}->set_custom_preset_config({custom[0]}, 1);" in main_cpp + assert "climate::CLIMATE_MODE_HEAT" in main_cpp From 5d64b65d725663015ea4c0903b300f2d3aafc5e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:12:09 -1000 Subject: [PATCH 95/97] [esp32_ble_server] Keep constant set_value payloads in shared flash tables (#20314) --- .../components/esp32_ble_server/__init__.py | 68 ++++++++++++++---- .../esp32_ble_server/ble_characteristic.cpp | 8 +++ .../esp32_ble_server/ble_characteristic.h | 1 + .../esp32_ble_server/ble_descriptor.cpp | 9 +-- .../esp32_ble_server/ble_descriptor.h | 3 +- .../esp32_ble_server/ble_server_automations.h | 26 ++++--- .../esp32_ble_server/config/set_value.yaml | 37 ++++++++++ .../esp32_ble_server/test_esp32_ble_server.py | 72 +++++++++++++++++++ .../bytebuffer/test_wrap_values.cpp | 9 ++- tests/components/esp32_ble_server/common.yaml | 7 ++ 10 files changed, 204 insertions(+), 36 deletions(-) create mode 100644 tests/component_tests/esp32_ble_server/config/set_value.yaml diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 75a099a3c49..d37b2f4020d 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,4 +1,6 @@ import encodings +import struct +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,8 +27,10 @@ from esphome.const import ( __version__ as ESPHOME_VERSION, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigType, TemplateArgsType AUTO_LOAD = ["esp32_ble", "bytebuffer"] CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] @@ -111,8 +115,6 @@ BLECharacteristicNotifyAction = esp32_ble_server_automations_ns.class_( ) bytebuffer_ns = cg.esphome_ns.namespace("bytebuffer") Endianness_ns = bytebuffer_ns.namespace("Endian") -ByteBuffer_ns = bytebuffer_ns.namespace("ByteBuffer") -ByteBuffer = bytebuffer_ns.class_("ByteBuffer") PROPERTY_MAP = { @@ -141,6 +143,15 @@ class ValueType: return value +def float32(value: Any) -> float: + value = cv.float_(value) + try: + struct.pack(" list[int]: + """The bytes of a constant value, as ByteBuffer::wrap packed them at runtime.""" + value = value_config[CONF_DATA] + if isinstance(value, str): + return list(value.encode(value_config[CONF_STRING_ENCODING])) + if isinstance(value, list): + return value + order = ">" if value_config[CONF_ENDIANNESS] == "BIG" else "<" + return list(struct.pack(order + _STRUCT_FORMATS[value_config[CONF_TYPE]], value)) + + async def parse_value(value_config, args): value = value_config[CONF_DATA] if isinstance(value, cv.Lambda): return await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + # An initializer list calls the set_value(std::initializer_list) overload + return cg.ArrayInitializer(*value_bytes(value_config)) - if isinstance(value, str): - value = list(value.encode(value_config[CONF_STRING_ENCODING])) - if isinstance(value, list): - # Generate initializer list {1, 2, 3} instead of std::vector({1, 2, 3}) - # This calls the set_value(std::initializer_list) overload - return cg.ArrayInitializer(*value) - val = cg.RawExpression(f"{value_config[CONF_TYPE]}({cg.safe_exp(value)})") - return ByteBuffer_ns.wrap(val, value_config[CONF_ENDIANNESS]) + +async def set_value_buffer( + var: MockObj, value_config: ConfigType, args: TemplateArgsType +) -> None: + value = value_config[CONF_DATA] + if not isinstance(value, cv.Lambda): + value = value_bytes(value_config) + await automation.templatable_bytes( + value, args, var.set_buffer_template, var.set_buffer_static, "ble_server_value" + ) def calculate_num_handles(service_config): @@ -662,8 +702,7 @@ async def to_code(config): async def ble_server_characteristic_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_SET_VALUE_ACTION") return var @@ -682,8 +721,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a async def ble_server_descriptor_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION") return var diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 75a33993797..1571a6077bd 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -30,6 +30,14 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } +void BLECharacteristic::set_value(const uint8_t *data, size_t length) { + if (length == 0) { // An empty constant is a nullptr table + this->value_.clear(); + return; + } + this->value_.assign(data, data + length); +} + void BLECharacteristic::set_value(std::vector &&buffer) { this->value_ = std::move(buffer); } void BLECharacteristic::set_value(std::initializer_list data) { diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index b7a3fae1a5b..905f5b7a0d0 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -30,6 +30,7 @@ class BLECharacteristic final { ~BLECharacteristic(); void set_value(ByteBuffer buffer); + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(const std::string &buffer); diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 228313e2d8e..7810dba9f5c 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -49,17 +49,18 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { this->state_ = CREATING; } -void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value_impl_(buffer.data(), buffer.size()); } +void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value(buffer.data(), buffer.size()); } -void BLEDescriptor::set_value(std::initializer_list data) { this->set_value_impl_(data.begin(), data.size()); } +void BLEDescriptor::set_value(std::initializer_list data) { this->set_value(data.begin(), data.size()); } -void BLEDescriptor::set_value_impl_(const uint8_t *data, size_t length) { +void BLEDescriptor::set_value(const uint8_t *data, size_t length) { if (length > this->value_.attr_max_len) { ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", length, this->value_.attr_max_len); return; } this->value_.attr_len = length; - memcpy(this->value_.attr_value, data, length); + if (length != 0) // An empty constant is a nullptr table + memcpy(this->value_.attr_value, data, length); } void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 5096d39f284..1ffd4034346 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -26,6 +26,7 @@ class BLEDescriptor { void do_create(BLECharacteristic *characteristic); ESPBTUUID get_uuid() const { return this->uuid_; } + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } @@ -42,8 +43,6 @@ class BLEDescriptor { } protected: - void set_value_impl_(const uint8_t *data, size_t length); - BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index e5463847fa8..d90b2a28fd5 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -16,6 +16,16 @@ namespace esphome::esp32_ble_server::esp32_ble_server_automations { using namespace esp32_ble; +/// Sets a characteristic or descriptor value from a constant flash table or a lambda's vector. +// Not TemplatableBytes::visit, which would copy the lambda's vector instead of moving it into the value. +template void apply_value(T *target, const V &value, Xs &&...x) { + if (value.is_static()) { + target->set_value(value.data(), value.size()); + } else { + target->set_value(value.value(x...)); + } +} + class BLETriggers { public: #ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE @@ -67,25 +77,23 @@ class BLECharacteristicSetValueActionManager { template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } + TEMPLATABLE_BYTES(buffer) void play(const Ts &...x) override { // If the listener is already set, do nothing if (BLECharacteristicSetValueActionManager::get_instance()->has_listener(this->parent_)) return; // Set initial value - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); // Set the listener for read events // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference // (e.g. climate on_control's ClimateCall&). See #17142. this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { apply_value(this->parent_, this->buffer_, x...); }); } protected: @@ -115,10 +123,8 @@ template class BLECharacteristicNotifyAction final : public Acti template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } - void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } + TEMPLATABLE_BYTES(buffer) + void play(const Ts &...x) override { apply_value(this->parent_, this->buffer_, x...); } protected: BLEDescriptor *parent_; diff --git a/tests/component_tests/esp32_ble_server/config/set_value.yaml b/tests/component_tests/esp32_ble_server/config/set_value.yaml new file mode 100644 index 00000000000..69f1a17dc80 --- /dev/null +++ b/tests/component_tests/esp32_ble_server/config/set_value.yaml @@ -0,0 +1,37 @@ +esphome: + name: test + +esp32: + variant: esp32 + +esp32_ble_server: + id: ble_server + services: + - uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d + characteristics: + - id: test_characteristic + uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423 + read: true + value: [1, 2, 3, 4] + descriptors: + - id: test_descriptor + uuid: 0x4414 + value: "Initial" + - uuid: 2a24b789-7a1b-4535-af3e-ee76a35cc99a + write: true + on_write: + - ble_server.characteristic.set_value: + id: test_characteristic + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.descriptor.set_value: + id: test_descriptor + value: + data: 4660 + type: uint16_t + endianness: BIG + - ble_server.characteristic.set_value: + id: test_characteristic + value: !lambda return x; 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 21a12d9cf2e..e3006d2c692 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 @@ -9,10 +9,16 @@ import pytest from esphome.components.esp32_ble_server import ( CCCD_DESCRIPTOR_UUID, + CONF_ENDIANNESS, + CONF_STRING_ENCODING, CUD_DESCRIPTOR_UUID, DEVICE_INFORMATION_SERVICE_UUID, + float32, uuid_is, + value_bytes, ) +import esphome.config_validation as cv +from esphome.const import CONF_DATA, CONF_TYPE @pytest.mark.parametrize( @@ -73,3 +79,69 @@ def test_advertising_required( main_cpp = generate_main(component_config_path(config_file)) assert f"set_advertising_required({str(required).lower()})" in main_cpp + + +# The same values and bytes as tests/components/bytebuffer/test_wrap_values.cpp, which pins +# what ByteBuffer::wrap produced from the expressions this component used to emit. +@pytest.mark.parametrize( + ("data", "type_", "endianness", "expected"), + [ + (18, "uint8_t", "LITTLE", [0x12]), + (18, "uint16_t", "LITTLE", [0x12, 0x00]), + (4660, "uint16_t", "BIG", [0x12, 0x34]), + (305419896, "uint32_t", "LITTLE", [0x78, 0x56, 0x34, 0x12]), + ( + 1311768467294899695, + "uint64_t", + "BIG", + [0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF], + ), + (-5, "int8_t", "LITTLE", [0xFB]), + (-2, "int16_t", "BIG", [0xFF, 0xFE]), + (-2, "int32_t", "LITTLE", [0xFE, 0xFF, 0xFF, 0xFF]), + (-2, "int64_t", "LITTLE", [0xFE] + [0xFF] * 7), + (123.1, "float", "BIG", [0x42, 0xF6, 0x33, 0x33]), + (0.1, "float", "LITTLE", [0xCD, 0xCC, 0xCC, 0x3D]), + (0.1, "double", "LITTLE", [0x9A, 0x99, 0x99, 0x99, 0x99, 0x99, 0xB9, 0x3F]), + (2.5, "double", "BIG", [0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ], +) +def test_value_bytes_match_bytebuffer( + data: float, type_: str, endianness: str, expected: list[int] +) -> None: + config = { + CONF_DATA: data, + CONF_TYPE: type_, + CONF_ENDIANNESS: endianness, + CONF_STRING_ENCODING: "utf_8", + } + assert value_bytes(config) == expected + + +def test_value_bytes_strings_and_lists() -> None: + assert value_bytes({CONF_DATA: "hé", CONF_STRING_ENCODING: "utf_8"}) == [ + 0x68, + 0xC3, + 0xA9, + ] + assert value_bytes({CONF_DATA: [1, 2], CONF_STRING_ENCODING: "utf_8"}) == [1, 2] + + +def test_set_value_constants_share_a_flash_table( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("set_value.yaml")) + + assert main_cpp.count("ble_server_value[] PROGMEM = {0x12, 0x34};") == 1 + assert main_cpp.count("set_buffer_static(ble_server_value, 2);") == 2 + assert "set_buffer_template(" in main_cpp + assert "ByteBuffer::wrap" not in main_cpp + + +def test_float32_rejects_out_of_range() -> None: + for value in (1e40, -1e40): + with pytest.raises(cv.Invalid, match="out of range for a float"): + float32(value) + assert float32(3.4028235e38) == 3.4028235e38 + assert float32(float("inf")) == float("inf") diff --git a/tests/components/bytebuffer/test_wrap_values.cpp b/tests/components/bytebuffer/test_wrap_values.cpp index 9a470d8a088..bcae7125bf2 100644 --- a/tests/components/bytebuffer/test_wrap_values.cpp +++ b/tests/components/bytebuffer/test_wrap_values.cpp @@ -8,8 +8,7 @@ namespace esphome::bytebuffer { -// The expressions esp32_ble_server emits for typed constant values, and the bytes they produce. -// Doubles are written as float literals, so they carry float precision. +// Typed values as ByteBuffer::wrap packs them; esp32_ble_server's value_bytes() is tested against the same bytes. TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { EXPECT_EQ(ByteBuffer::wrap(uint8_t(18), LITTLE).get_data(), (std::vector{0x12})); EXPECT_EQ(ByteBuffer::wrap(uint16_t(18), LITTLE).get_data(), (std::vector{0x12, 0x00})); @@ -24,9 +23,9 @@ TEST(ByteBufferWrapValues, MatchesGeneratedConstants) { (std::vector{0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff})); EXPECT_EQ(ByteBuffer::wrap(float(123.1f), BIG).get_data(), (std::vector{0x42, 0xf6, 0x33, 0x33})); EXPECT_EQ(ByteBuffer::wrap(float(0.1f), LITTLE).get_data(), (std::vector{0xcd, 0xcc, 0xcc, 0x3d})); - EXPECT_EQ(ByteBuffer::wrap(double(0.1f), LITTLE).get_data(), - (std::vector{0x00, 0x00, 0x00, 0xa0, 0x99, 0x99, 0xb9, 0x3f})); - EXPECT_EQ(ByteBuffer::wrap(double(2.5f), BIG).get_data(), + EXPECT_EQ(ByteBuffer::wrap(double(0.1), LITTLE).get_data(), + (std::vector{0x9a, 0x99, 0x99, 0x99, 0x99, 0x99, 0xb9, 0x3f})); + EXPECT_EQ(ByteBuffer::wrap(double(2.5), BIG).get_data(), (std::vector{0x40, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})); } diff --git a/tests/components/esp32_ble_server/common.yaml b/tests/components/esp32_ble_server/common.yaml index b513c43234b..01994e2d7a7 100644 --- a/tests/components/esp32_ble_server/common.yaml +++ b/tests/components/esp32_ble_server/common.yaml @@ -110,6 +110,13 @@ esp32_ble_server: - ble_server.descriptor.set_value: id: test_change_descriptor value: "Changed" + # Empty constants + - ble_server.characteristic.set_value: + id: test_change_characteristic + value: "" + - ble_server.descriptor.set_value: + id: test_change_descriptor + value: "" # Lambdas that read the trigger argument - ble_server.characteristic.set_value: id: test_change_characteristic From ed6ab4565df661d0f3da70980c23457825ea82cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:12:59 -1000 Subject: [PATCH 96/97] [core] Emit filter, action and condition lists one item at a time (#20116) --- esphome/automation.py | 24 ++++--- esphome/components/api/__init__.py | 2 +- esphome/components/binary_sensor/__init__.py | 3 +- esphome/components/sensor/__init__.py | 5 +- esphome/components/sensor/filter.h | 3 + esphome/components/text_sensor/__init__.py | 3 +- esphome/core/base_automation.h | 33 ++++++++-- .../automation/test_condition_groups.py | 2 +- .../sensor/test_timeout_throttle.py | 10 +-- .../template/config/per_item_lists.yaml | 62 +++++++++++++++++++ .../template/test_per_item_lists.py | 30 +++++++++ 11 files changed, 151 insertions(+), 26 deletions(-) create mode 100644 tests/component_tests/template/config/per_item_lists.yaml create mode 100644 tests/component_tests/template/test_per_item_lists.py diff --git a/esphome/automation.py b/esphome/automation.py index fea5769050e..2f90cbfcbbf 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -724,7 +724,7 @@ async def condition_group_to_code( if len(conditions) == 1: return conditions[0] return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions ) @@ -840,11 +840,13 @@ async def if_action_to_code( condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) if CONF_THEN in config: - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) if has_else: - actions = await build_action_list(config[CONF_ELSE], template_arg, args) - cg.add(var.add_else(actions)) + for action in await build_action_list(config[CONF_ELSE], template_arg, args): + cg.add(var.add_else_action(action)) + cg.add(var.finish_else()) return var @@ -867,8 +869,9 @@ async def while_action_to_code( ) -> MockObj: condition = await build_condition(config[CONF_CONDITION], template_arg, args) var = cg.new_Pvariable(action_id, template_arg, condition) - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -897,7 +900,9 @@ async def repeat_action_to_code( cg.TemplateArguments(cg.uint32, *template_arg.args), [(cg.uint32, "iteration"), *args], ) - cg.add(var.add_then(actions)) + for action in actions: + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -1113,7 +1118,8 @@ async def _new_automation( 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) - cg.add(obj.add_actions(actions)) + for action in actions: + cg.add(obj.add_action(action)) return obj diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index f105c1d7f79..9611228ba1d 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -664,7 +664,7 @@ async def to_code(config: ConfigType) -> None: unregister_id, var, ) - cg.add(auto.add_actions([unregister_action])) + cg.add(auto.add_action(unregister_action)) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) if CORE.is_esp8266 and has_user_actions: diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 60e20aa1935..109a22c7bca 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -607,7 +607,8 @@ async def setup_binary_sensor_core_(var, config): if filters_config := config.get(CONF_FILTERS): cg.add_define("USE_BINARY_SENSOR_FILTER") filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) - cg.add(var.add_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_binary_sensor_automations, var, config) diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2b404442a4d..1440e07f763 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -672,7 +672,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), *filters) @FILTER_REGISTRY.register( @@ -1033,7 +1033,8 @@ async def setup_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") filters = await _build_chain_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_sensor_automations, var, config) diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 510ee94c6a7..01949f45df2 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -545,6 +545,9 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, template class OrFilter : public Filter { public: explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } + // Codegen passes each filter as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Fs) == N) explicit OrFilter(Fs *...filters) : filters_{filters...} {} void initialize(Sensor *parent, Filter *next) override { Filter::initialize(parent, next); diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index e9c70f7a2e3..959ce77c7a9 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -208,7 +208,8 @@ async def setup_text_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") filters = await build_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_text_sensor_automations, var, config) diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 69def222da2..09cde1261d0 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -22,6 +22,9 @@ template class AndCondition : public Condition explicit AndCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit AndCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -40,6 +43,9 @@ template class OrCondition : public Condition { explicit OrCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit OrCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -67,6 +73,9 @@ template class XorCondition : public Condition explicit XorCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit XorCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -311,10 +320,9 @@ template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} - // Precondition: add_then/add_else must be called at most once per instance. - // Codegen always batches the full action list into a single call. Calling - // twice would re-append the same inline continuation pointer and form a - // self-loop in the next_ chain. + // Precondition: build each branch either with one add_then/add_else call, or with + // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. + // Appending the inline continuation twice forms a self-loop in the next_ chain. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->then_continuation_); @@ -325,6 +333,13 @@ template class IfAction : public Action { this->else_.add_action(&this->else_continuation_.action); } + // Codegen appends branch actions one at a time, then finishes each branch once; a list literal + // would be a constant array in .rodata, which is RAM on ESP8266. + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->then_continuation_); } + void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } + void finish_else() requires(HasElse) { this->else_.add_action(&this->else_continuation_.action); } + void play_complex(const Ts &...x) override { this->num_running_++; if (this->condition_->check(x...)) { @@ -364,11 +379,14 @@ template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class WhileLoopContinuation; @@ -428,11 +446,14 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; diff --git a/tests/component_tests/automation/test_condition_groups.py b/tests/component_tests/automation/test_condition_groups.py index effe7b5b0bd..5d9f9b828ed 100644 --- a/tests/component_tests/automation/test_condition_groups.py +++ b/tests/component_tests/automation/test_condition_groups.py @@ -15,7 +15,7 @@ def test_single_condition_groups_are_unwrapped( assert "IfAction(lambdacondition_id);" in main_cpp assert "IfAction(lambdacondition_id_2);" in main_cpp group = re.search( - r"new\((\w+)\) AndCondition<2>\(\{lambdacondition_id_3, lambdacondition_id_4\}\);", + r"new\((\w+)\) AndCondition<2>\(lambdacondition_id_3, lambdacondition_id_4\);", main_cpp, ) assert group is not None diff --git a/tests/component_tests/sensor/test_timeout_throttle.py b/tests/component_tests/sensor/test_timeout_throttle.py index 8154471e472..ceecd2f1226 100644 --- a/tests/component_tests/sensor/test_timeout_throttle.py +++ b/tests/component_tests/sensor/test_timeout_throttle.py @@ -15,10 +15,10 @@ def test_timeout_throttle_pair_is_merged( len(re.findall(r"new\(\w+\) sensor::TimeoutThrottleFilter\(1000\);", main_cpp)) == 1 ) - merged = re.search(r"merged->set_filters\(\{(\w+)\}\);", main_cpp) - assert merged is not None - assert f"new({merged.group(1)}) sensor::TimeoutThrottleFilter(1000);" in main_cpp + merged = re.findall(r"merged->add_filter\((\w+)\);", main_cpp) + assert len(merged) == 1 + assert f"new({merged[0]}) sensor::TimeoutThrottleFilter(1000);" in main_cpp # Every other shape keeps its two filters for sensor_id in ("other_periods", "other_order", "value_list", "configured_value"): - call = re.search(rf"{sensor_id}->set_filters\(\{{([^}}]*)\}}\);", main_cpp) - assert call is not None and len(call.group(1).split(",")) == 2, sensor_id + calls = re.findall(rf"{sensor_id}->add_filter\(\w+\);", main_cpp) + assert len(calls) == 2, sensor_id diff --git a/tests/component_tests/template/config/per_item_lists.yaml b/tests/component_tests/template/config/per_item_lists.yaml new file mode 100644 index 00000000000..89f1a80ce2d --- /dev/null +++ b/tests/component_tests/template/config/per_item_lists.yaml @@ -0,0 +1,62 @@ +esphome: + name: test + +esp32: + board: esp32dev + +logger: + +globals: + - id: counter + type: int + initial_value: "0" + +sensor: + - platform: template + id: temp + lambda: return 1.0; + filters: + - multiply: 2.0 + - offset: 1.0 + - or: + - throttle: 2s + - delta: 1.0 + +binary_sensor: + - platform: template + id: button + lambda: return true; + filters: + - invert: + - delayed_on: 10ms + +text_sensor: + - platform: template + id: label + lambda: return {"x"}; + filters: + - to_upper: + - append: "!" + +interval: + - interval: 10s + then: + - if: + condition: + and: + - lambda: return id(counter) == 0; + - binary_sensor.is_on: button + then: + - lambda: id(counter) += 1; + - logger.log: even + else: + - logger.log: odd + - repeat: + count: 2 + then: + - logger.log: again + - while: + condition: + lambda: return id(counter) < 3; + then: + - lambda: id(counter) += 1; diff --git a/tests/component_tests/template/test_per_item_lists.py b/tests/component_tests/template/test_per_item_lists.py new file mode 100644 index 00000000000..adf1498e37e --- /dev/null +++ b/tests/component_tests/template/test_per_item_lists.py @@ -0,0 +1,30 @@ +"""Filters and action lists are added one item at a time, so no pointer list lands in .rodata.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_filters_and_actions_are_added_per_item( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("per_item_lists.yaml")) + + for list_call in ( + "add_filters(", + "set_filters(", + "add_actions(", + "add_then(", + "add_else(", + ): + assert list_call not in main_cpp + 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 + assert main_cpp.count("->add_else_action(") == 1 + 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 + assert not re.search(r"(Condition|OrFilter)<[^>]*>\(\{", main_cpp) From 0cd239dfd1176026963bf04fa81989a59f31dce7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:16:34 -1000 Subject: [PATCH 97/97] [uart] Keep constant switch, button and write payloads in flash (#20082) --- esphome/components/uart/__init__.py | 21 +++++------- esphome/components/uart/automation.h | 29 +++------------- esphome/components/uart/button/__init__.py | 10 +++--- .../components/uart/button/uart_button.cpp | 2 +- esphome/components/uart/button/uart_button.h | 11 +++--- esphome/components/uart/switch/__init__.py | 34 ++++++++----------- .../components/uart/switch/uart_switch.cpp | 8 ++--- esphome/components/uart/switch/uart_switch.h | 19 +++++++---- esphome/components/uart/uart.h | 1 + esphome/components/uart/uart_component.cpp | 13 +++++++ esphome/components/uart/uart_component.h | 7 ++++ tests/component_tests/uart/__init__.py | 0 .../uart/config/payload_tables.yaml | 34 +++++++++++++++++++ .../uart/test_payload_tables.py | 28 +++++++++++++++ 14 files changed, 137 insertions(+), 80 deletions(-) create mode 100644 tests/component_tests/uart/__init__.py create mode 100644 tests/component_tests/uart/config/payload_tables.yaml create mode 100644 tests/component_tests/uart/test_payload_tables.py diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 5eea8060ff9..d1ac806b5fd 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -137,6 +137,10 @@ def validate_raw_data(value): ) +# Lengths are stored as uint16_t; reject longer payloads at validation. +validate_raw_payload = cv.All(validate_raw_data, cv.Length(max=65535)) + + def validate_rx_pin(value): value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: @@ -596,7 +600,7 @@ async def register_uart_device(var, config): cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(UARTComponent), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_raw_payload), }, key=CONF_DATA, ), @@ -605,18 +609,9 @@ async def register_uart_device(var, config): async def uart_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static, "uart_data" + ) return var diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index e5a9fa7c7bc..b134c8af9f6 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -8,36 +8,15 @@ namespace esphome::uart { template class UARTWriteAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers - this->code_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - // Store pointer to static data in flash (no RAM copy) - void set_data_static(const uint8_t *data, size_t len) { - this->code_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->len_ >= 0) { - // Static mode: use pointer and length - this->parent_->write_array(this->code_.data, static_cast(this->len_)); + if (this->data_.is_static()) { + this->parent_->write_array_progmem(this->data_.data(), this->data_.size()); } else { - // Template mode: call function - auto val = this->code_.func(x...); - this->parent_->write_array(val); + this->parent_->write_array(this->data_.value(x...)); } } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Code { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } code_; }; } // namespace esphome::uart diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 95fe21271db..821d8264dc4 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import button, uart import esphome.config_validation as cv from esphome.const import CONF_DATA -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload CODEOWNERS = ["@ssieb"] @@ -17,7 +17,7 @@ CONFIG_SCHEMA = ( button.button_schema(UARTButton) .extend( { - cv.Required(CONF_DATA): validate_raw_data, + cv.Required(CONF_DATA): validate_raw_payload, } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -31,6 +31,4 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data(cg.ArrayInitializer(*data))) + cg.add(var.set_data(automation.progmem_bytes("uart_data", data), len(data))) diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index b55207cb46e..99b5ee548d0 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -7,7 +7,7 @@ ESPHOME_LOG_TAG(TAG, "uart.button"); void UARTButton::press_action() { ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); - this->write_array(this->data_.data(), this->data_.size()); + this->write_array_progmem(this->data_, this->data_len_); } void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 47f45d4899c..903f945fd30 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -4,20 +4,21 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/button/button.h" -#include - namespace esphome::uart { class UARTButton final : public button::Button, public UARTDevice, public Component { public: - void set_data(std::vector &&data) { this->data_ = std::move(data); } - void set_data(std::initializer_list data) { this->data_ = std::vector(data); } + void set_data(const uint8_t *data, uint16_t len) { + this->data_ = data; + this->data_len_ = len; + } void dump_config() override; protected: void press_action() override; - std::vector data_; + const uint8_t *data_{nullptr}; + uint16_t data_len_{0}; }; } // namespace esphome::uart diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index 290bbed5d30..3288ceeee20 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import switch, uart import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_SEND_EVERY -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload DEPENDENCIES = ["uart"] @@ -18,11 +18,11 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_DATA): cv.Any( - validate_raw_data, + validate_raw_payload, cv.Schema( { - cv.Optional(CONF_TURN_OFF): validate_raw_data, - cv.Optional(CONF_TURN_ON): validate_raw_data, + cv.Optional(CONF_TURN_OFF): validate_raw_payload, + cv.Optional(CONF_TURN_ON): validate_raw_payload, } ), ), @@ -40,20 +40,16 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, dict): - if data_on := data.get(CONF_TURN_ON): - if isinstance(data_on, bytes): - data_on = [HexInt(x) for x in data_on] - cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) - if data_off := data.get(CONF_TURN_OFF): - if isinstance(data_off, bytes): - data_off = [HexInt(x) for x in data_off] - cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) - else: - data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data_on(cg.ArrayInitializer(*data))) + single_state = not isinstance(data, dict) + if single_state: + data = {CONF_TURN_ON: data} + for key, setter in ( + (CONF_TURN_ON, var.set_data_on), + (CONF_TURN_OFF, var.set_data_off), + ): + if payload := data.get(key): + cg.add(setter(automation.progmem_bytes("uart_data", payload), len(payload))) + if single_state: cg.add(var.set_single_state(True)) if CONF_SEND_EVERY in config: cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 6b6aa273f7a..1af497ffcfd 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -17,13 +17,13 @@ void UARTSwitch::loop() { } void UARTSwitch::write_command_(bool state) { - if (state && !this->data_on_.empty()) { + if (state && this->data_on_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending on data", this->get_name().c_str()); - this->write_array(this->data_on_.data(), this->data_on_.size()); + this->write_array_progmem(this->data_on_, this->data_on_len_); } - if (!state && !this->data_off_.empty()) { + if (!state && this->data_off_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending off data", this->get_name().c_str()); - this->write_array(this->data_off_.data(), this->data_off_.size()); + this->write_array_progmem(this->data_off_, this->data_off_len_); } } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index c924c7d4e54..96d8833d7a8 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -5,7 +5,6 @@ #include "esphome/components/switch/switch.h" #include -#include namespace esphome::uart { @@ -13,10 +12,14 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo public: void loop() override; - void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } - void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } - void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } - void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } + void set_data_on(const uint8_t *data, uint16_t len) { + this->data_on_ = data; + this->data_on_len_ = len; + } + void set_data_off(const uint8_t *data, uint16_t len) { + this->data_off_ = data; + this->data_off_len_ = len; + } void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } void set_single_state(bool single) { this->single_state_ = single; } @@ -25,8 +28,10 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo protected: void write_command_(bool state); void write_state(bool state) override; - std::vector data_on_; - std::vector data_off_; + const uint8_t *data_on_{nullptr}; + const uint8_t *data_off_{nullptr}; + uint16_t data_on_len_{0}; + uint16_t data_off_len_{0}; bool single_state_{false}; uint32_t send_every_; uint32_t last_transmission_; diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index d1c67733634..7a34a20583e 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -25,6 +25,7 @@ class UARTDevice { size_t get_rx_timeout() { return this->parent_->get_rx_timeout(); } void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } + void write_array_progmem(const uint8_t *data, size_t len) { this->parent_->write_array_progmem(data, len); } void write_array(const std::vector &data) { this->parent_->write_array(data); } template void write_array(const std::array &data) { this->parent_->write_array(data.data(), data.size()); diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 82bc5927024..e77e37cd089 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -46,4 +46,17 @@ void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { this->set_rx_full_threshold(val); } +#ifdef USE_ESP8266 +void UARTComponent::write_array_progmem(const uint8_t *data, size_t len) { + uint8_t buf[32]; + while (len > 0) { + const size_t chunk = std::min(len, sizeof(buf)); + progmem_memcpy(buf, data, chunk); + this->write_array(buf, chunk); + data += chunk; + len -= chunk; + } +} +#endif + } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 6d446fd6a6f..14709d44906 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -65,6 +65,13 @@ class UARTComponent { // @param len Length of the array. virtual void write_array(const uint8_t *data, size_t len) = 0; + // Writes bytes that may live in PROGMEM; ESP8266 cannot read flash bytewise, so it copies chunks. +#ifdef USE_ESP8266 + void write_array_progmem(const uint8_t *data, size_t len); +#else + void write_array_progmem(const uint8_t *data, size_t len) { this->write_array(data, len); } +#endif + // Reads a single byte from the UART bus. // @param data Pointer to the byte where the read data will be stored. // @return True if a byte was successfully read, false otherwise. diff --git a/tests/component_tests/uart/__init__.py b/tests/component_tests/uart/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/uart/config/payload_tables.yaml b/tests/component_tests/uart/config/payload_tables.yaml new file mode 100644 index 00000000000..e93ab4754d0 --- /dev/null +++ b/tests/component_tests/uart/config/payload_tables.yaml @@ -0,0 +1,34 @@ +--- +esphome: + name: test + on_boot: + then: + - uart.write: [0x01, 0x02, 0x03] + - uart.write: !lambda return {0x09}; + - uart.write: "" + +esp32: + board: esp32dev + +uart: + tx_pin: 17 + rx_pin: 16 + baud_rate: 9600 + +switch: + - platform: uart + id: shared_switch + name: Shared Switch + data: [0x01, 0x02, 0x03] + - platform: uart + id: on_off_switch + name: On Off Switch + data: + turn_on: "ON" + turn_off: [0x04] + +button: + - platform: uart + id: shared_button + name: Shared Button + data: [0x01, 0x02, 0x03] diff --git a/tests/component_tests/uart/test_payload_tables.py b/tests/component_tests/uart/test_payload_tables.py new file mode 100644 index 00000000000..2ba341136a7 --- /dev/null +++ b/tests/component_tests/uart/test_payload_tables.py @@ -0,0 +1,28 @@ +"""Tests for UART constant payloads in shared PROGMEM tables.""" + +from collections.abc import Callable +from pathlib import Path +import re + + +def test_constant_payloads_share_progmem_tables( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + """Equal payloads share one table across switch, button and write action.""" + main_cpp = generate_main(component_config_path("payload_tables.yaml")) + + tables = dict( + re.findall( + r"static constexpr uint8_t (\w+)\[\] PROGMEM = (\{[^}]*\});", main_cpp + ) + ) + shared = next(k for k, v in tables.items() if v == "{0x01, 0x02, 0x03}") + assert sorted(tables.values()) == sorted( + ["{0x01, 0x02, 0x03}", "{0x4F, 0x4E}", "{0x04}"] + ) + assert f"shared_switch->set_data_on({shared}, 3);" in main_cpp + assert f"shared_button->set_data({shared}, 3);" in main_cpp + assert f"set_data_static({shared}, 3);" in main_cpp + assert "set_data_static(nullptr, 0);" in main_cpp + assert "set_data_template(" in main_cpp